Skip to content

Commit 13c130e

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 0eb8196 commit 13c130e

22 files changed

Lines changed: 1089 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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from pydantic import (
2+
BaseModel,
3+
ConfigDict,
4+
Field,
5+
StrictFloat,
6+
ValidationError,
7+
model_validator,
8+
)
9+
10+
from dodal.common.general_maths.interval import ClosedInterval
11+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import (
12+
SystemAspectBaseParser,
13+
)
14+
15+
16+
class LateralMotorSpec(BaseModel):
17+
"""The positions scale for an axial wedge motor (as specified by configuration, typically **JSON**).
18+
19+
Notes:
20+
- The (potentially counterintuitive) class' attribute names match beamline scientists' "domain jargon".
21+
22+
- Each lateral motor drives one absorber wedge sideways across the x-ray beam:
23+
- expected sideways motions are pure horizontal or pure vertical
24+
- but that's a detail, azimuthal orientation around the beam should not matter.
25+
- The position of the scale zero relative to the wedge location is hereby nailed down.
26+
- The wedge motor scale has
27+
- an **out** position
28+
- and then a range of "active" positions, ( where the mathematical assumption of a linear taper is reasonable ).
29+
30+
- Although the units here will default to **mm** when unspecified (i.e. when omitted),
31+
the units setting is still likely to appear in the JSON, as a handy reminder to human readers of the configuration file.
32+
33+
Attributes:
34+
units: At present only "mm" are supported.
35+
out: Motor position consistent with having fully retracted the wedge from the x-ray beam.
36+
threshold: Motor position for minimal absorption in the permitted active absorbing position range.
37+
max: Motor position for minimal absorption in the permitted active range.
38+
tolerance: Motor position accepted margin for readout error.
39+
"""
40+
41+
units: str = Field(default="mm")
42+
out: StrictFloat
43+
threshold: StrictFloat
44+
max: StrictFloat
45+
tolerance: StrictFloat = Field(default=5.0e-3)
46+
47+
# Base Model internal setting to make this class immutable
48+
model_config = ConfigDict(frozen=True, extra="forbid")
49+
50+
@model_validator(mode="after")
51+
def validate_attributes(self) -> "LateralMotorSpec":
52+
# counter-intuitive naming, but depending on
53+
# motor scale orientation relative to wedge orientation
54+
# max (thickness) position could greater or smaller than threshold
55+
_lower = min(self.out, self.max)
56+
_upper = max(self.out, self.max)
57+
_interval = ClosedInterval(lower=_lower, upper=_upper)
58+
if self.threshold in _interval:
59+
return self
60+
msg: str = r"Inconsistent wedge geometry: Threshold not between max and out."
61+
raise ValidationError(msg)
62+
63+
64+
class LateralMotorsConfig(SystemAspectBaseParser[LateralMotorSpec]):
65+
"""Maps from highest level configuration (JSON) dict to extract lateral motor specifications.
66+
67+
Uses base class method **extract_specifications** and some pythonic type handling magic.
68+
69+
See Also:
70+
Base of this parser class, namely **SystemAspectBaseParser**
71+
"""
72+
73+
pass
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import re
2+
from typing import Any, Final
3+
4+
from pydantic import (
5+
BaseModel,
6+
ConfigDict,
7+
Field,
8+
field_validator,
9+
model_validator,
10+
)
11+
from pydantic_core import PydanticCustomError
12+
13+
from dodal.devices.beamlines.i19.transmission.spec_from_config.energy_interval_spec import (
14+
EnergyIntervalSpec,
15+
)
16+
from dodal.devices.beamlines.i19.transmission.spec_from_config.fitted_absorption_curve_spec import (
17+
FittedAbsorptionCurveSpec,
18+
)
19+
from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import (
20+
SystemAspectBaseParser,
21+
)
22+
23+
24+
class MaterialNameValidation:
25+
"""Absorber materials naming validation regular expression.
26+
27+
Either permits (at start of name):
28+
- leading underscore followed immediately by a letter,
29+
or,
30+
- leading letter,
31+
then any number of alphanumerics or underscores or hyphens.
32+
"""
33+
34+
MATERIAL_NAME_REGEXP: Final = r"^([A-Za-z]|_[A-Za-z])[A-Za-z0-9_-]*$"
35+
36+
@staticmethod
37+
def validate_material_name(*, material_name: str) -> None:
38+
_matcher = re.compile(MaterialNameValidation.MATERIAL_NAME_REGEXP)
39+
if _matcher.match(material_name) is None:
40+
raise PydanticCustomError(
41+
"invalid_material_name",
42+
"Material name '{m}' needed to be alphanumeric or underscores, (hyphens permitted after first character).",
43+
{"m": material_name},
44+
)
45+
46+
47+
class AbsorptionVsEnergyRelation(BaseModel):
48+
"""Configuration dict for one piece of an absorption spectrum, one energy range specific fit curve.
49+
50+
Note:
51+
This relation class is a pairing. The internal pair consists of :
52+
- The x-ray energy interval over which this piece of the spectrum is valid (a.k.a. domain).
53+
- The parameters of a fitted absorption curve.
54+
55+
Attributes:
56+
valid_energies: Specifies x-ray energy *domain* valid for the fitted curve.
57+
fit_parameters: Specifies parameters needed to calculate absorption values on the fitted curve.
58+
59+
*See also, these closely related classes:*
60+
**FittedAbsorptionCurveSpec**: Fitted curve specification
61+
**EnergyIntervalSpec**: Energy range (interval) specification
62+
**ClosedInterval**: General maths class underpinning interval definition
63+
"""
64+
65+
valid_energies: EnergyIntervalSpec
66+
fit_parameters: FittedAbsorptionCurveSpec
67+
68+
# Base Model internal setting to make this class immutable
69+
model_config = ConfigDict(frozen=True, extra="forbid")
70+
71+
72+
class MaterialAbsorptionSpectrumSpec(BaseModel):
73+
"""Configuration dict for the absorption spectrum of a specific material.
74+
75+
Note:
76+
One spectrum absorption curve covers a specific energy range where it is valid.
77+
One or more such curves make up one absorption spectrum.
78+
79+
Attributes:
80+
absorption_curves: List of absorption curves
81+
"""
82+
83+
absorption_curves: list[AbsorptionVsEnergyRelation] = Field(..., min_length=1)
84+
85+
# Base Model internal setting to make this class immutable and valid
86+
model_config = ConfigDict(frozen=True, extra="forbid")
87+
88+
@model_validator(mode="before")
89+
@classmethod
90+
def _coerce_raw_list_to_absorption_curves_dict(cls, data: Any) -> Any:
91+
"""Wrap spectrum pieces listing internally as a dict."""
92+
if isinstance(data, list):
93+
return {"absorption_curves": data}
94+
if isinstance(data, dict):
95+
return data
96+
raise ValueError(
97+
"Absorption spectrum data should be a list or dict of fitted curves."
98+
)
99+
100+
101+
class MaterialAbsorptionSpectralConfig(
102+
SystemAspectBaseParser[MaterialAbsorptionSpectrumSpec]
103+
):
104+
"""Configuration dict for the absorption spectra of all specified absorber materials.
105+
106+
Maps each material name to its absorption spectrum specification, as extracted from configuration (JSON).
107+
108+
Note:
109+
Base class does all the heavy lifting
110+
"""
111+
112+
# Base Model internal setting to make this class immutable and valid
113+
model_config = ConfigDict(frozen=True)
114+
115+
@field_validator("root")
116+
@classmethod
117+
def _ensure_material_has_been_validly_named(
118+
cls,
119+
all_absorber_materials_specifications: dict[
120+
str, MaterialAbsorptionSpectrumSpec
121+
],
122+
) -> dict[str, MaterialAbsorptionSpectrumSpec]:
123+
"""Validates all names for absorber materials.
124+
125+
Raises:
126+
ValidationError should it find any material name is not compliant.
127+
"""
128+
_specified_absorber_materials = all_absorber_materials_specifications.keys()
129+
for absorber_material_name in _specified_absorber_materials:
130+
MaterialNameValidation.validate_material_name(
131+
material_name=absorber_material_name
132+
)
133+
return all_absorber_materials_specifications
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from typing import (
2+
Any,
3+
Generic,
4+
TypeVar,
5+
)
6+
7+
from pydantic import BaseModel, RootModel
8+
9+
# A Type variable bound to the transmission system sub-specifications
10+
# ( each specified aspect of the overall transmission system )
11+
TxSubSpecT = TypeVar("TxSubSpecT", bound=BaseModel)
12+
13+
14+
class SystemAspectBaseParser(RootModel[dict[str, TxSubSpecT]], Generic[TxSubSpecT]):
15+
"""Generic Base Model handling dict validation and extraction from the (JSON) configuration highest level dict."""
16+
17+
@classmethod
18+
def extract_specifications(
19+
cls, *, transmission_system_configuration: dict[str, Any]
20+
) -> tuple[str, TxSubSpecT]:
21+
"""Extracts specification and validates configuration for one aspect of the transmission system."""
22+
_parsed_blob = cls.model_validate(transmission_system_configuration)
23+
_system_aspects = _parsed_blob.root.items()
24+
return next(iter(_system_aspects))
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from pydantic import (
2+
BaseModel,
3+
ConfigDict,
4+
)
5+
6+
from dodal.devices.beamlines.i19.transmission.spec_from_config.lateral_motor_spec import (
7+
LateralMotorsConfig,
8+
)
9+
from dodal.devices.beamlines.i19.transmission.spec_from_config.material_absorption_spectrum_spec import (
10+
MaterialAbsorptionSpectralConfig,
11+
)
12+
from dodal.devices.beamlines.i19.transmission.spec_from_config.usage_priority_spec import (
13+
UsagePriorityConfig,
14+
)
15+
from dodal.devices.beamlines.i19.transmission.spec_from_config.wedges_spec import (
16+
WedgesConfig,
17+
)
18+
from dodal.devices.beamlines.i19.transmission.spec_from_config.wheels_spec import (
19+
WheelsConfig,
20+
)
21+
22+
23+
class TransmissionSystemSpec(BaseModel):
24+
"""Maps input configuration (i.e. JSON ) to highest level dict for I19 transmission system.
25+
26+
Note:
27+
Sub-dictionaries carry the details of each aspect of the system.
28+
29+
Attributes:
30+
lateral_motors: Position (scale) for wedge motors.
31+
materials: Absorption spectra for all absorber filter materials in wedges or foils.
32+
wedges: The shape, motor and material details for all system variable depth wedge absorbers.
33+
wheels: The slot occupancy, foil materials, motor name for all system filter wheels.
34+
usage_priority: Beamline scientists policy on which absorbers to prefer using first.
35+
"""
36+
37+
lateral_motors: LateralMotorsConfig
38+
materials: MaterialAbsorptionSpectralConfig
39+
wedges: WedgesConfig
40+
wheels: WheelsConfig
41+
usage: UsagePriorityConfig
42+
43+
# Base Model internal setting to make this class immutable and valid
44+
model_config = ConfigDict(frozen=True, extra="forbid")

0 commit comments

Comments
 (0)