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,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")
Comment on lines +10 to +22

Copy link
Copy Markdown
Contributor

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:

from pydantic import BaseModel, model_validator
from pint import UnitRegistry

ureg = UnitRegistry()

class FoilThicknessSpec(BaseModel):
    units: str
    value: float

    @property
    def quantity(self):
        return self.value * ureg(self.units)

    @property
    def thickness_um(self):
        return self.quantity.to("micrometer").magnitude

or if we don't want to normalise we can at least make sure it's a length:

from pydantic import BaseModel, field_validator
from pint import UnitRegistry

ureg = UnitRegistry()

class FoilThicknessSpec(BaseModel):
    units: str
    value: float

    @field_validator("units")
    @classmethod
    def validate_length_unit(cls, v: str) -> str:
        try:
            unit = ureg.Unit(v)
        except Exception:
            raise ValueError(f"Unknown unit '{v}'")

        if unit.dimensionality != ureg.meter.dimensionality:
            raise ValueError(
                f"Unit '{v}' is not a unit of length"
            )

        return v

@CoePaul CoePaul Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

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

Copy link
Copy Markdown
Contributor

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 SupportedThicknessUnits and use that here?



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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: out_position would make more sense to me

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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"
The JSON starts on their page ( and all our fields here are in their language )
and then eventually we can - having absorbed all the values )
"transliterate" names in deeper parts of the code base - should we so choose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
out: StrictFloat
out_position: StrictFloat = Field(alias="out")

Will load in out and put it in out_position. Though I appreciate this has the potential to confuse if comparing the JSON with the code. Feel free to take it or leave it.

@CoePaul CoePaul Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another source of confusion is that wheel would call its "out_index"
( and later in the business logic out_position and out_index will be doing the same job ).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should:

Suggested change
_interval = ClosedInterval(lower=_lower, upper=_upper)
if self.threshold in _interval:
if _lower <= self.threshold <= _upper:

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This feels more readable on one line:

Suggested change
msg: str = r"Inconsistent wedge geometry: Threshold not between max and out."
raise ValueError(msg)
raise ValueError("Inconsistent wedge geometry: Threshold not between max and out.")

Should: It doesn't need to be a raw string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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
it can be literal yes as you suggest



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
Comment thread
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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Must: See general comment on SystemAspectBaseParser

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