Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
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,32 @@
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: str = Field(..., pattern=r"^(keV|kiloelectronvolts)$")
lower: StrictFloat
upper: StrictFloat

# 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."
if self.lower > 0.0
else f"Energy bound use {self.lower} to define lower end of x-ray energy interval, must be greater than zero."
)
raise ValueError(_msg)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Final

from pydantic import BaseModel, ConfigDict, Field, StrictFloat, model_validator

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

PHYSICAL_ROLL_OFF: Final[FloatInterval] = ClosedInterval(lower=-4.0, upper=-2.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
roll_off: StrictFloat
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")

@model_validator(mode="after")
def validate_attributes(self) -> "FittedAbsorptionCurveSpec":
if self.photon_absorption > 0.0 and self.roll_off in PHYSICAL_ROLL_OFF:
return self
if not self.photon_absorption > 0:
raise ValueError(
f"Photon absorption constant {self.photon_absorption} is invalid."
)
_msg = (
f"Absorption roll off {self.roll_off} does not seem likely on physics grounds."
if self.roll_off < 0
else "Absorption roll off is a negative exponent typically close to -3.0 ∓ 1"
)
raise ValueError(_msg)
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from pydantic import (
BaseModel,
ConfigDict,
StrictFloat,
)


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: str
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,92 @@
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:
- expected sideways motions are pure horizontal or pure vertical
- but that's a detail, azimuthal orientation around the beam should not matter.
- The position of the scale zero relative to the wedge location is hereby nailed down.
- The wedge motor scale has
- an **out** position
- and then a range of "active" positions, ( where the mathematical assumption of a linear taper is reasonable ).

- 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, as a handy reminder to human readers of the configuration file.

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 minimal absorption in the permitted active range.
tolerance: Motor position accepted margin for readout error.
"""

units: str = Field(default="mm")
out: StrictFloat
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 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:
return self
msg: str = r"Inconsistent wedge geometry: Threshold not between max and out."
raise ValueError(msg)


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 wheel 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
"""

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(
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]
Loading
Loading