-
Notifications
You must be signed in to change notification settings - Fork 13
Adds parsing / validation of JSON configuration, for I19 transmission hardware. #2173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """Utility library that captures system specification state from JSON. | ||
|
|
||
| Beamline scientists have the freedom to edit the JSON files. | ||
|
|
||
| The latest state of JSON at system start up should convert to instances of the Spec(ification) classes, | ||
| ( which are pydantic (v2) BaseModel classes ). | ||
| For these validated BaseModels the absorber instrumentation classes are built, that the transmission system logic will govern. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| from typing import Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, StrictFloat, model_validator | ||
|
|
||
|
|
||
| class EnergyIntervalSpec(BaseModel): | ||
| """JSON built sub-dict of one energy range, used in specifying material absorption spectra. | ||
|
|
||
| Note: | ||
| The range of energies is a closed interval, so inclusive of end values. | ||
|
|
||
| Attributes: | ||
| units: At present only 'keV' (or 'kiloelectronvolts') are supported. | ||
| lower: The lower end of the energy interval (range) for specific absorption fit curve. | ||
| upper: The upper end of the energy interval (range) for specific absorption fit curve. | ||
| """ | ||
|
|
||
| units: Literal["keV", "kiloelectronvolts"] | ||
| lower: StrictFloat = Field(gt=0.0) | ||
| upper: StrictFloat = Field(gt=0.0) | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
|
|
||
| @model_validator(mode="after") | ||
| def validate_attributes(self) -> "EnergyIntervalSpec": | ||
| if 0.0 < self.lower < self.upper: | ||
| return self | ||
| _msg = f"Energy interval lower {self.lower} and upper {self.upper} bounds are in wrong order." | ||
| raise ValueError(_msg) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| from typing import Final | ||
|
|
||
| from pydantic import BaseModel, ConfigDict, Field, StrictFloat | ||
|
|
||
| # A roll off exponent outside the -2.0 to -4.0 range is deemed unphysical | ||
| ROLL_OFF_UPPER_BOUND: Final[float] = -2.0 | ||
| ROLL_OFF_LOWER_BOUND: Final[float] = -4.0 | ||
|
|
||
|
|
||
| class FittedAbsorptionCurveSpec(BaseModel): | ||
| """JSON built sub-dict of one absorption curve, used in specifying material absorption spectra. | ||
|
|
||
| Note: | ||
| One spectrum absorption curve covers a specific energy range where it is valid. | ||
| One or more such curves make up one absorption spectrum. | ||
|
|
||
| Attributes: | ||
| photon_absorption: | ||
| Material characteristic scaling constant which defines absorption curve. | ||
| roll_off: | ||
| Power-law exponent for variation with energy, usually something like -2.75 ∓ 0.24. | ||
| residuals_polynomial_coeffs: | ||
| Optional residuals correction, zeroth order parameter first. | ||
| """ | ||
|
|
||
| photon_absorption: StrictFloat = Field(gt=0.0) | ||
| roll_off: StrictFloat = Field(gt=ROLL_OFF_LOWER_BOUND, lt=ROLL_OFF_UPPER_BOUND) | ||
| residuals_polynomial_coeffs: list[StrictFloat] = Field(default_factory=list) | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| from pydantic import ( | ||
| BaseModel, | ||
| ConfigDict, | ||
| StrictFloat, | ||
| ) | ||
|
|
||
| from dodal.common.general_maths.absorber_geometry import SupportedThicknessUnits | ||
|
|
||
|
|
||
| class FoilThicknessSpec(BaseModel): | ||
| """The specification for a flat foil thickness (as specified by configuration, typically **JSON**). | ||
|
|
||
| Attributes: | ||
| units: The length unit used to specify the foil thickness. | ||
| value: The foil thickness in the specified unit. | ||
| """ | ||
|
|
||
| units: SupportedThicknessUnits | ||
| value: StrictFloat | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
|
|
||
|
|
||
| class AbsorberSpec(BaseModel): | ||
| """The specification for a FixedDepth absorber (as specified by configuration, typically **JSON**). | ||
|
|
||
| Attributes: | ||
| material: The name of the absorber material, as present the in the materials section of the configuration. | ||
| thickness: ThicknessProvider geometry of the flat foil absorber. | ||
| """ | ||
|
|
||
| material: str | ||
| thickness: FoilThicknessSpec | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
|
|
||
|
|
||
| class FoilSpec(BaseModel): | ||
| """The specification for a filter slot in a wheel. | ||
|
|
||
| Attributes: | ||
| absorber: Absorbing filter configuration for a given slot. | ||
| """ | ||
|
|
||
| absorber: AbsorberSpec | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,95 @@ | ||||||||
| from typing import Literal | ||||||||
|
|
||||||||
| from pydantic import ( | ||||||||
| BaseModel, | ||||||||
| ConfigDict, | ||||||||
| Field, | ||||||||
| StrictFloat, | ||||||||
| model_validator, | ||||||||
| ) | ||||||||
|
|
||||||||
| from dodal.common.general_maths.interval import ClosedInterval | ||||||||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.name_validation import ( | ||||||||
| AxisNameValidation, | ||||||||
| ) | ||||||||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import ( | ||||||||
| SystemAspectBaseParser, | ||||||||
| ) | ||||||||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.system_configuration import ( | ||||||||
| SystemConfiguration, | ||||||||
| ) | ||||||||
|
|
||||||||
|
|
||||||||
| class LateralMotorSpec(BaseModel): | ||||||||
| """The positions scale for an axial wedge motor (as specified by configuration, typically **JSON**). | ||||||||
|
|
||||||||
| Notes: | ||||||||
| - The (potentially counterintuitive) class' attribute names match beamline scientists' "domain jargon". | ||||||||
|
|
||||||||
| - Each lateral motor drives one absorber wedge sideways across the x-ray beam: | ||||||||
| - typically the motion axis aligns with a beamline coordinate axis ( x or y ) | ||||||||
| - The wedge motor scale has | ||||||||
| - an **out** position | ||||||||
| - and a range of "active" positions, where the taper is smooth. | ||||||||
| - The wedge "internal" coordinate system defines tip as position zero | ||||||||
| - in general the motor position zero is offset, | ||||||||
| - the wedge's tip parameter reflects that offset. | ||||||||
|
|
||||||||
| - Although the units here will default to **mm** when unspecified (i.e. when omitted), | ||||||||
| the units setting is still likely to appear in the JSON, reminding humans. | ||||||||
|
|
||||||||
| Attributes: | ||||||||
| units: At present only "mm" are supported. | ||||||||
| out: Motor position consistent with having fully retracted the wedge from the x-ray beam. | ||||||||
| threshold: Motor position for minimal absorption in the permitted active absorbing position range. | ||||||||
| max: Motor position for maximum absorption in the permitted active range. | ||||||||
| tolerance: Motor position accepted margin for readout error. | ||||||||
| """ | ||||||||
|
|
||||||||
| units: Literal["mm"] | ||||||||
| out: StrictFloat | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I know - but in all the beamline side the scientists already call it "out"
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the names in the JSON are fixed I believe you can use aliases to convert them on load e.g.
Suggested change
Will load in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. another source of confusion is that wheel would call its "out_index" |
||||||||
| threshold: StrictFloat | ||||||||
| max: StrictFloat | ||||||||
| tolerance: StrictFloat = Field(default=5.0e-3) | ||||||||
|
|
||||||||
| # Base Model internal setting to make this class immutable | ||||||||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||||||||
|
|
||||||||
| @model_validator(mode="after") | ||||||||
| def validate_attributes(self) -> "LateralMotorSpec": | ||||||||
| # counter-intuitive naming, but depending on | ||||||||
| # motor scale orientation relative to wedge orientation | ||||||||
| # max (thickness) position could be greater or smaller than threshold | ||||||||
| _lower = min(self.out, self.max) | ||||||||
| _upper = max(self.out, self.max) | ||||||||
| _interval = ClosedInterval(lower=_lower, upper=_upper) | ||||||||
| if self.threshold in _interval: | ||||||||
|
Comment on lines
+65
to
+66
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should:
Suggested change
is a lot more readable as it's standard python, compared to a class that I have to go and look up the definition of.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes but having created the bloody interval thing I'm going to use it
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess maybe that's an argument that shouldn't have it at all then. But I don't want to rehash things that are out of scope of the PR. In this specific instance the above is more readable and more pythonic. |
||||||||
| return self | ||||||||
| msg: str = r"Inconsistent wedge geometry: Threshold not between max and out." | ||||||||
| raise ValueError(msg) | ||||||||
|
Comment on lines
+68
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: This feels more readable on one line:
Suggested change
Should: It doesn't need to be a raw string.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was an f string when I was reporting back max and out - but then I stopped doing that |
||||||||
|
|
||||||||
|
|
||||||||
| class LateralMotorsConfig(SystemAspectBaseParser[LateralMotorSpec]): | ||||||||
| """Maps from highest level configuration (JSON) dict to extract lateral motor specifications. | ||||||||
|
|
||||||||
| Uses base class method **extract_specifications** and some pythonic type handling magic. | ||||||||
|
|
||||||||
| See Also: | ||||||||
| Base of this parser class, namely **SystemAspectBaseParser** | ||||||||
| """ | ||||||||
|
|
||||||||
| def validate_key_name(self, *, key_name: str) -> None: | ||||||||
| AxisNameValidation.validate_axis_name(axis_name=key_name) | ||||||||
|
|
||||||||
| @classmethod | ||||||||
| def extract_motors_specifications( | ||||||||
| cls, | ||||||||
| *, | ||||||||
| system_configuration: SystemConfiguration, | ||||||||
| motor_identifier: str, | ||||||||
| ) -> LateralMotorSpec: | ||||||||
| """Extracts lateral motor specification from configuration of the transmission system.""" | ||||||||
| _motors: dict[str, LateralMotorSpec] = cls.get_aspect_specifications( | ||||||||
| system_configuration=system_configuration | ||||||||
| ) | ||||||||
| return _motors[motor_identifier] | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| from typing import Any | ||
|
|
||
| from pydantic import ( | ||
| BaseModel, | ||
| ConfigDict, | ||
| Field, | ||
| field_validator, | ||
| model_validator, | ||
| ) | ||
|
|
||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.energy_interval_spec import ( | ||
| EnergyIntervalSpec, | ||
| ) | ||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.fitted_absorption_curve_spec import ( | ||
| FittedAbsorptionCurveSpec, | ||
| ) | ||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.name_validation import ( | ||
| MaterialNameValidation, | ||
| ) | ||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.system_aspect_base_parser import ( | ||
| SystemAspectBaseParser, | ||
| ) | ||
| from dodal.devices.beamlines.i19.transmission.spec_from_config.system_configuration import ( | ||
| SystemConfiguration, | ||
| ) | ||
|
|
||
|
|
||
| class AbsorptionVsEnergyRelation(BaseModel): | ||
| """Configuration dict for one piece of an absorption spectrum, one energy range specific fit curve. | ||
|
|
||
| Note: | ||
| This relation class is a pairing. The internal pair consists of : | ||
| - The x-ray energy interval over which this piece of the spectrum is valid (a.k.a. domain). | ||
| - The parameters of a fitted absorption curve. | ||
|
|
||
| Attributes: | ||
| valid_energies: Specifies x-ray energy *domain* valid for the fitted curve. | ||
| fit_parameters: Specifies parameters needed to calculate absorption values on the fitted curve. | ||
|
|
||
| *See also, these closely related classes:* | ||
| **FittedAbsorptionCurveSpec**: Fitted curve specification | ||
| **EnergyIntervalSpec**: Energy range (interval) specification | ||
| **ClosedInterval**: General maths class underpinning interval definition | ||
|
DominicOram marked this conversation as resolved.
|
||
| """ | ||
|
|
||
| valid_energies: EnergyIntervalSpec | ||
| fit_parameters: FittedAbsorptionCurveSpec | ||
|
|
||
| # Base Model internal setting to make this class immutable | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
|
|
||
|
|
||
| class MaterialAbsorptionSpectrumSpec(BaseModel): | ||
| """Configuration dict for the absorption spectrum of a specific material. | ||
|
|
||
| Note: | ||
| One spectrum absorption curve covers a specific energy range where it is valid. | ||
| One or more such curves make up one absorption spectrum. | ||
|
|
||
| Attributes: | ||
| absorption_curves: List of absorption curves | ||
| """ | ||
|
|
||
| absorption_curves: list[AbsorptionVsEnergyRelation] = Field(..., min_length=1) | ||
|
|
||
| # Base Model internal setting to make this class immutable and valid | ||
| model_config = ConfigDict(frozen=True, extra="forbid") | ||
|
|
||
| @model_validator(mode="before") | ||
| @classmethod | ||
| def _coerce_raw_list_to_absorption_curves_dict(cls, data: Any) -> Any: | ||
| """Wrap spectrum pieces listing internally as a dict.""" | ||
| if isinstance(data, list): | ||
| return {"absorption_curves": data} | ||
| if isinstance(data, dict): | ||
| return data | ||
| raise ValueError( | ||
| "Absorption spectrum data should be a list or dict of fitted curves." | ||
| ) | ||
|
|
||
|
|
||
| class MaterialAbsorptionSpectralConfig( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Must: See general comment on |
||
| SystemAspectBaseParser[MaterialAbsorptionSpectrumSpec] | ||
| ): | ||
| """Configuration dict for the absorption spectra of all specified absorber materials. | ||
|
|
||
| Maps each material name to its absorption spectrum specification, as extracted from configuration (JSON). | ||
|
|
||
| Note: | ||
| Base class does most of the work - except for material name validation. | ||
| """ | ||
|
|
||
| def validate_key_name(self, *, key_name: str) -> None: | ||
| MaterialNameValidation.validate_material_name(material_name=key_name) | ||
|
|
||
| @field_validator("root") | ||
| @classmethod | ||
| def _ensure_at_least_one_absorber_material_has_been_specified( | ||
| cls, | ||
| all_absorber_materials_specifications: dict[ | ||
| str, MaterialAbsorptionSpectrumSpec | ||
| ], | ||
| ) -> dict[str, MaterialAbsorptionSpectrumSpec]: | ||
| """Invalidates configuration if that features zero absorber materials. | ||
|
|
||
| Raises: | ||
| ValueError - if no absorbers are present. | ||
| """ | ||
| _specified_absorber_materials = all_absorber_materials_specifications.keys() | ||
| if len(_specified_absorber_materials) < 1: | ||
| raise ValueError( | ||
| "Empty absorber materials configuration! This is not valid input." | ||
| ) | ||
| return all_absorber_materials_specifications | ||
|
|
||
| @classmethod | ||
| def extract_absorber_material_specifications( | ||
| cls, | ||
| *, | ||
| system_configuration: SystemConfiguration, | ||
| material_name: str, | ||
| ) -> MaterialAbsorptionSpectrumSpec: | ||
| """Extracts the absorption spectrum specification for a specific material.""" | ||
| _materials_spectra: dict[str, MaterialAbsorptionSpectrumSpec] = ( | ||
| cls.get_aspect_specifications(system_configuration=system_configuration) | ||
| ) | ||
| return _materials_spectra[material_name] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could: It's not clear how you're going to use this later but if the user is going to specify different units and you want them normalised we could do it here, something like:
or if we don't want to normalise we can at least make sure it's a length:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the conversions already committed to general_maths module
have set the ground work for which units are accepted and which are not
( they - after all - came in the furniture removal van from my Jython partial implementation of i19 attenuation )
this code here is "just" dovetailing to that existing convertor which will do cm to unit and and unit to cm
for a few of the likely units
I don't think I want to permit people to specify a foil thickness in furlongs, (nor barleycorns) even if it is a valid length unit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, then maybe we should import
SupportedThicknessUnitsand use that here?