Skip to content

Commit 9da488d

Browse files
committed
Add transmission system configuration validating reader classes
* Use pydantic BaseModel classes to capture blobs from Transmission System specification config JSON files for I19 beamlines EH-1 / EH-2
1 parent 26f3a2c commit 9da488d

25 files changed

Lines changed: 2024 additions & 217 deletions

src/dodal/devices/beamlines/i19/transmission/spec_from_json/__init__.py renamed to src/dodal/devices/beamlines/i19/transmission/spec_from_config/__init__.py

File renamed without changes.

src/dodal/devices/beamlines/i19/transmission/spec_from_json/energy_interval_spec.py renamed to src/dodal/devices/beamlines/i19/transmission/spec_from_config/energy_interval_spec.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
from pydantic import BaseModel, ConfigDict, Field, StrictFloat, model_validator
32

43

@@ -19,7 +18,7 @@ class EnergyIntervalSpec(BaseModel):
1918
upper: StrictFloat
2019

2120
# Base Model internal setting to make this class immutable
22-
model_config = ConfigDict(frozen=True)
21+
model_config = ConfigDict(frozen=True, extra="forbid")
2322

2423
@model_validator(mode="after")
2524
def validate_attributes(self) -> "EnergyIntervalSpec":

src/dodal/devices/beamlines/i19/transmission/spec_from_json/fitted_absorption_curve_spec.py renamed to src/dodal/devices/beamlines/i19/transmission/spec_from_config/fitted_absorption_curve_spec.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
from dodal.common.general_maths.interval import ClosedInterval, FloatInterval
66

7-
PHYSICAL_ROLL_OFF: Final[FloatInterval] = ClosedInterval(lower=-4.0,upper=-2.0)
7+
PHYSICAL_ROLL_OFF: Final[FloatInterval] = ClosedInterval(lower=-4.0, upper=-2.0)
8+
89

910
class FittedAbsorptionCurveSpec(BaseModel):
1011
"""JSON built sub-dict of one absorption curve, used in specifying material absorption spectra.
@@ -27,7 +28,7 @@ class FittedAbsorptionCurveSpec(BaseModel):
2728
residuals_polynomial_coeffs: list[StrictFloat] = Field(default_factory=list)
2829

2930
# Base Model internal setting to make this class immutable
30-
model_config = ConfigDict(frozen=True)
31+
model_config = ConfigDict(frozen=True, extra="forbid")
3132

3233
@model_validator(mode="after")
3334
def validate_attributes(self) -> "FittedAbsorptionCurveSpec":
@@ -38,7 +39,7 @@ def validate_attributes(self) -> "FittedAbsorptionCurveSpec":
3839
f"Photon absorption constant {self.photon_absorption} is invalid."
3940
)
4041
_msg = (
41-
f"Absorption roll off {self.roll_off} does not seem likely on physics grounds."
42+
f"Absorption roll off {self.roll_off} does not seem likely on physics grounds."
4243
if self.roll_off < 0
4344
else "Absorption roll off is a negative exponent typically close to -3.0 ∓ 1"
4445
)
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from pydantic import (
2+
BaseModel,
3+
ConfigDict,
4+
StrictFloat,
5+
)
6+
7+
8+
class FoilThicknessSpec(BaseModel):
9+
"""The specification for a flat foil thickness (as specified by configuration, typically **JSON**).
10+
11+
Attributes:
12+
units: The length unit used to specify the foil thickness.
13+
value: The foil thickness in the specified unit.
14+
"""
15+
16+
units: str
17+
value: StrictFloat
18+
19+
# Base Model internal setting to make this class immutable
20+
model_config = ConfigDict(frozen=True, extra="forbid")
21+
22+
23+
class AbsorberSpec(BaseModel):
24+
"""The specification for a FixedDepth absorber (as specified by configuration, typically **JSON**).
25+
26+
Attributes:
27+
material: The name of the absorber material, as present the in the materials section of the configuration.
28+
thickness: ThicknessProvider geometry of the flat foil absorber.
29+
"""
30+
31+
material: str
32+
thickness: FoilThicknessSpec
33+
34+
# Base Model internal setting to make this class immutable
35+
model_config = ConfigDict(frozen=True, extra="forbid")
36+
37+
38+
class FoilSpec(BaseModel):
39+
"""The specification for a filter slot in a wheel.
40+
41+
Attributes:
42+
absorber: Absorbing filter configuration for a given slot.
43+
"""
44+
45+
absorber: AbsorberSpec
46+
47+
# Base Model internal setting to make this class immutable
48+
model_config = ConfigDict(frozen=True, extra="forbid")
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from pydantic import (
2+
BaseModel,
3+
ConfigDict,
4+
Field,
5+
StrictFloat,
6+
model_validator,
7+
)
8+
9+
from dodal.common.general_maths.interval import ClosedInterval
10+
from dodal.devices.beamlines.i19.transmission.spec_from_config.name_validation import (
11+
AxisNameValidation,
12+
)
13+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import (
14+
SystemAspectBaseParser,
15+
)
16+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_configuration import (
17+
SystemConfiguration,
18+
)
19+
20+
21+
class LateralMotorSpec(BaseModel):
22+
"""The positions scale for an axial wedge motor (as specified by configuration, typically **JSON**).
23+
24+
Notes:
25+
- The (potentially counterintuitive) class' attribute names match beamline scientists' "domain jargon".
26+
27+
- Each lateral motor drives one absorber wedge sideways across the x-ray beam:
28+
- expected sideways motions are pure horizontal or pure vertical
29+
- but that's a detail, azimuthal orientation around the beam should not matter.
30+
- The position of the scale zero relative to the wedge location is hereby nailed down.
31+
- The wedge motor scale has
32+
- an **out** position
33+
- and then a range of "active" positions, ( where the mathematical assumption of a linear taper is reasonable ).
34+
35+
- Although the units here will default to **mm** when unspecified (i.e. when omitted),
36+
the units setting is still likely to appear in the JSON, as a handy reminder to human readers of the configuration file.
37+
38+
Attributes:
39+
units: At present only "mm" are supported.
40+
out: Motor position consistent with having fully retracted the wedge from the x-ray beam.
41+
threshold: Motor position for minimal absorption in the permitted active absorbing position range.
42+
max: Motor position for minimal absorption in the permitted active range.
43+
tolerance: Motor position accepted margin for readout error.
44+
"""
45+
46+
units: str = Field(default="mm")
47+
out: StrictFloat
48+
threshold: StrictFloat
49+
max: StrictFloat
50+
tolerance: StrictFloat = Field(default=5.0e-3)
51+
52+
# Base Model internal setting to make this class immutable
53+
model_config = ConfigDict(frozen=True, extra="forbid")
54+
55+
@model_validator(mode="after")
56+
def validate_attributes(self) -> "LateralMotorSpec":
57+
# counter-intuitive naming, but depending on
58+
# motor scale orientation relative to wedge orientation
59+
# max (thickness) position could greater or smaller than threshold
60+
_lower = min(self.out, self.max)
61+
_upper = max(self.out, self.max)
62+
_interval = ClosedInterval(lower=_lower, upper=_upper)
63+
if self.threshold in _interval:
64+
return self
65+
msg: str = r"Inconsistent wedge geometry: Threshold not between max and out."
66+
raise ValueError(msg)
67+
68+
69+
class LateralMotorsConfig(SystemAspectBaseParser[LateralMotorSpec]):
70+
"""Maps from highest level configuration (JSON) dict to extract lateral motor specifications.
71+
72+
Uses base class method **extract_specifications** and some pythonic type handling magic.
73+
74+
See Also:
75+
Base of this parser class, namely **SystemAspectBaseParser**
76+
"""
77+
78+
def validate_key_name(self, *, key_name: str) -> None:
79+
AxisNameValidation.validate_axis_name(axis_name=key_name)
80+
81+
@classmethod
82+
def extract_motors_specifications(
83+
cls,
84+
*,
85+
system_configuration: SystemConfiguration,
86+
motor_identifier: str,
87+
) -> LateralMotorSpec:
88+
"""Extracts wheel specification from configuration of the transmission system."""
89+
_motors: dict[str, LateralMotorSpec] = cls.get_aspect_specifications(
90+
system_configuration=system_configuration
91+
)
92+
return _motors[motor_identifier]
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from typing import Any
2+
3+
from pydantic import (
4+
BaseModel,
5+
ConfigDict,
6+
Field,
7+
field_validator,
8+
model_validator,
9+
)
10+
11+
from dodal.devices.beamlines.i19.transmission.spec_from_config.energy_interval_spec import (
12+
EnergyIntervalSpec,
13+
)
14+
from dodal.devices.beamlines.i19.transmission.spec_from_config.fitted_absorption_curve_spec import (
15+
FittedAbsorptionCurveSpec,
16+
)
17+
from dodal.devices.beamlines.i19.transmission.spec_from_config.name_validation import (
18+
MaterialNameValidation,
19+
)
20+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import (
21+
SystemAspectBaseParser,
22+
)
23+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_configuration import (
24+
SystemConfiguration,
25+
)
26+
27+
28+
class AbsorptionVsEnergyRelation(BaseModel):
29+
"""Configuration dict for one piece of an absorption spectrum, one energy range specific fit curve.
30+
31+
Note:
32+
This relation class is a pairing. The internal pair consists of :
33+
- The x-ray energy interval over which this piece of the spectrum is valid (a.k.a. domain).
34+
- The parameters of a fitted absorption curve.
35+
36+
Attributes:
37+
valid_energies: Specifies x-ray energy *domain* valid for the fitted curve.
38+
fit_parameters: Specifies parameters needed to calculate absorption values on the fitted curve.
39+
40+
*See also, these closely related classes:*
41+
**FittedAbsorptionCurveSpec**: Fitted curve specification
42+
**EnergyIntervalSpec**: Energy range (interval) specification
43+
**ClosedInterval**: General maths class underpinning interval definition
44+
"""
45+
46+
valid_energies: EnergyIntervalSpec
47+
fit_parameters: FittedAbsorptionCurveSpec
48+
49+
# Base Model internal setting to make this class immutable
50+
model_config = ConfigDict(frozen=True, extra="forbid")
51+
52+
53+
class MaterialAbsorptionSpectrumSpec(BaseModel):
54+
"""Configuration dict for the absorption spectrum of a specific material.
55+
56+
Note:
57+
One spectrum absorption curve covers a specific energy range where it is valid.
58+
One or more such curves make up one absorption spectrum.
59+
60+
Attributes:
61+
absorption_curves: List of absorption curves
62+
"""
63+
64+
absorption_curves: list[AbsorptionVsEnergyRelation] = Field(..., min_length=1)
65+
66+
# Base Model internal setting to make this class immutable and valid
67+
model_config = ConfigDict(frozen=True, extra="forbid")
68+
69+
@model_validator(mode="before")
70+
@classmethod
71+
def _coerce_raw_list_to_absorption_curves_dict(cls, data: Any) -> Any:
72+
"""Wrap spectrum pieces listing internally as a dict."""
73+
if isinstance(data, list):
74+
return {"absorption_curves": data}
75+
if isinstance(data, dict):
76+
return data
77+
raise ValueError(
78+
"Absorption spectrum data should be a list or dict of fitted curves."
79+
)
80+
81+
82+
class MaterialAbsorptionSpectralConfig(
83+
SystemAspectBaseParser[MaterialAbsorptionSpectrumSpec]
84+
):
85+
"""Configuration dict for the absorption spectra of all specified absorber materials.
86+
87+
Maps each material name to its absorption spectrum specification, as extracted from configuration (JSON).
88+
89+
Note:
90+
Base class does most of the work - except for material name validation.
91+
"""
92+
93+
def validate_key_name(self, *, key_name: str) -> None:
94+
MaterialNameValidation.validate_material_name(material_name=key_name)
95+
96+
@field_validator("root")
97+
@classmethod
98+
def _ensure_at_least_one_absorber_material_has_been_specified(
99+
cls,
100+
all_absorber_materials_specifications: dict[
101+
str, MaterialAbsorptionSpectrumSpec
102+
],
103+
) -> dict[str, MaterialAbsorptionSpectrumSpec]:
104+
"""Invalidates configuration if that features zero absorber materials.
105+
106+
Raises:
107+
ValueError - if no absorbers are present.
108+
"""
109+
_specified_absorber_materials = all_absorber_materials_specifications.keys()
110+
if len(_specified_absorber_materials) < 1:
111+
raise ValueError(
112+
"Empty absorber materials configuration! This is not valid input."
113+
)
114+
return all_absorber_materials_specifications
115+
116+
@classmethod
117+
def extract_absorber_material_specifications(
118+
cls,
119+
*,
120+
system_configuration: SystemConfiguration,
121+
material_name: str,
122+
) -> MaterialAbsorptionSpectrumSpec:
123+
"""Extracts the absorption spectrum specification for a specific material."""
124+
_materials_spectra: dict[str, MaterialAbsorptionSpectrumSpec] = (
125+
cls.get_aspect_specifications(system_configuration=system_configuration)
126+
)
127+
return _materials_spectra[material_name]

0 commit comments

Comments
 (0)