Source code for ts_ids_components.xrd.datacube

"""
Two-theta scan datacube components for XRD IDSs.

The scan modes where 2-theta is the varying dimension are called **2-Theta
Scans** (e.g. coupled reflection, grazing incidence, reflectivity,
transmission) -- as opposed to modes like rocking curves, pole figures, or
reciprocal space maps, whose primary scan variable is a different axis
entirely (see below). :py:class:`TwoThetaScanDatacube` models one 2-Theta
Scan's raw per-point data: every value recorded at each step, not just a
2-theta/intensity pair:

* Bruker D8 Advance ``RawData0.xml``: each ``Datum`` row is
  ``effective_time_per_step, scan_point_index, 2Theta, Theta, Intensity`` (5
  values). ``Theta`` moves in lockstep with ``2Theta`` in the D8 Advance's
  coupled Theta/Theta scan.
* Malvern Panalytical Empyrean ``.xrdml``: a coupled scan varies both
  ``2Theta`` and ``Omega`` simultaneously, with ``Phi`` fixed.
* Rigaku ``Profile*.txt``: each row is ``2Theta, Intensity, Weight`` (3
  values); ``Weight`` is the RAS-format attenuation/correction factor applied
  when an attenuator is switched in near the direct beam. Rigaku's wider
  ``MesurementConditions*.xml`` axis table also lists ``Omega``, ``Chi``, and
  other hardware axes available on the same platform.

Every 2-Theta Scan mode -- regardless of vendor or specific
application -- varies 2-theta as its dimension with intensity as a measure;
that dimension/measure shape, not "pharma powder XRD" as such, is the precise
criterion :py:class:`TwoThetaScanDatacube` supports. Scan modes with a
different primary scan variable (rocking curves, pole figures, reciprocal
space maps -- primary dimension is omega, phi, or a reciprocal-space grid,
not 2-theta) aren't 2-Theta Scans, so they don't fit this shape and have no
datacube component defined here yet. If a customer needs one of those modes,
it should get its own datacube class following the same fixed-length/
vendor-subclass pattern as :py:class:`TwoThetaScanDatacube` below, not a
variant of `TwoThetaScanDatacube` itself. So :py:class:`TwoThetaScanDatacube`'s
one :py:class:`Dimension <ts_ids_core.schema.dimension.Dimension>` is
2-theta itself (``name="two_theta"``, ``unit="DegreeAngle"``, real angle
values), not a synthetic index. Every other per-point column --
``Theta``/``Omega`` (which covary 1:1 with 2-theta rather than varying
independently of it) and ancillary fields like ``Weight`` or
``effective_time_per_step`` -- becomes one named :py:class:`XrdMeasure`
sharing that one 2-theta dimension: no `DataCube` subclass anywhere in this
repo or the wider platform models a quantity that covaries with the primary
axis as a second `Dimension` -- that's reserved for genuinely independent
grid axes (e.g. chromatography's wavelength x time PDA cube), which this
data isn't.

``intensity`` is a required measure on every :py:class:`TwoThetaScanDatacube`,
and (as ``counts``) a mandatory, non-optional element in Empyrean's own XRDML
schema regardless of scan mode.

``ts_ids_core.schema.DataCube``'s ``measures``/``dimensions`` ``fixed_length``
is a whole-class constant, not additive across inheritance: a vendor
subclass can't "add more measures" to an inherited count, it must restate its
own total. So :py:class:`TwoThetaScanDatacube` fixes ``dimensions`` at
exactly 1 (identical for every vendor, never restated by subclasses) and
provides ``fk_method``/``fk_run``/``fk_system``/``fk_sample``/``name``/
``description`` once for everyone, but each vendor's own subclass still
restates ``measures`` with
its own total ``fixed_length`` (e.g. 4 for Bruker's remaining ``Datum``
columns once 2-theta becomes the dimension) -- the ``two_theta`` dimension
name and ``intensity`` measure presence checks run on every subclass
regardless, since they're a validator, not a structural constraint::

    class VendorTwoThetaScanDatacube(TwoThetaScanDatacube):
        # one XrdMeasure per raw per-point column for this vendor, other
        # than 2-theta itself (the dimension), e.g. for Bruker: theta,
        # intensity, effective_time_per_step, scan_point_index
        measures: Required[Annotated[List[XrdMeasure], fixed_length(4)]]

Every other axis is still trivially found by looking up ``measures`` by
``name``.

See :py:mod:`ts_ids_components.xrd.datacube_raw` for
:py:class:`RawTwoThetaScanDatacube <ts_ids_components.xrd.datacube_raw.RawTwoThetaScanDatacube>`,
which preserves each datacube's untouched raw text alongside it.
"""

from typing import List

from pydantic import model_validator
from ts_ids_core.annotations import (
    Nullable,
    NullableString,
    Required,
    UUIDForeignKey,
    UUIDPrimaryKey,
    fixed_length,
)
from ts_ids_core.base.ids_field import IdsField
from ts_ids_core.schema import DataCube, Dimension
from ts_ids_core.schema.measure import MeasureBase
from typing_extensions import Annotated

TWO_THETA_SCAN_DIMENSION_NAME = "two theta"
"""Standard name for a :py:class:`TwoThetaScanDatacube`'s one required
dimension. See the module docstring above for why 2-theta -- not a
synthetic index -- is the dimension every vendor's datacube shares."""


def _require_two_theta_dimension_and_intensity_measure(
    dimension_name: NullableString,
    measure_names: List[NullableString],
    class_name: str,
) -> None:
    """Shared by :py:class:`TwoThetaScanDatacube` and `RawTwoThetaScanDatacube
    <ts_ids_components.xrd.datacube_raw.RawTwoThetaScanDatacube>`'s own
    validators of the same name, so the two "exact structural replica"
    classes can't drift apart on this rule."""
    if dimension_name != TWO_THETA_SCAN_DIMENSION_NAME:
        raise ValueError(
            f"{class_name}'s dimension must be named "
            f"'{TWO_THETA_SCAN_DIMENSION_NAME}', got '{dimension_name}'"
        )
    if "intensity" not in measure_names:
        raise ValueError(f"{class_name} is missing required measure: 'intensity'")


[docs] class XrdMeasure(MeasureBase): """ A single named per-point quantity in a :py:class:`TwoThetaScanDatacube` (e.g. ``"theta"``, ``"omega"``, ``"intensity"``, ``"weight"``, ``"effective_time_per_step"``), sharing the datacube's one :py:data:`TWO_THETA_SCAN_DIMENSION_NAME` dimension. See the module docstring above for why any axis that covaries with 2-theta -- rather than varying independently of it -- is modeled as a measure rather than a second dimension. """ value: Required[List[Nullable[float]]]
[docs] class TwoThetaScanDatacube(DataCube): """ Base for 2-Theta Scan datacubes. See the module docstring above for the raw file evidence behind this shape. Carries a `pk` so its raw counterpart (`RawTwoThetaScanDatacube <ts_ids_components.xrd.datacube_raw.RawTwoThetaScanDatacube>`) can link directly back to it -- see :py:mod:`ts_ids_components.xrd.datacube_raw`. Also carries four foreign keys, linking each datacube to the scan (in `methods`, see :py:mod:`ts_ids_components.xrd.method`) via `fk_method`, the run (in `runs`, see :py:mod:`ts_ids_components.xrd.run`) via `fk_run`, the system (in `systems`, see :py:mod:`ts_ids_components.xrd.system`) via `fk_system`, and the sample (in `samples`, see :py:mod:`ts_ids_components.xrd.sample`) via `fk_sample`. """ pk: UUIDPrimaryKey = IdsField( description=( "Primary key for this datacube, referenced by the raw datacube " "(in `datacubes_raw`) preserving its untouched source text." ) ) fk_method: UUIDForeignKey = IdsField( primary_key="/properties/methods/items/properties/pk", description=( "Foreign key to the scan (in `methods`) that produced this datacube. " "A single raw file can contain multiple independent scans, e.g. " "repeat measurements sharing one autosampler batch submission." ), ) fk_run: UUIDForeignKey = IdsField( primary_key="/properties/runs/items/properties/pk", description="Foreign key to the run (in `runs`) that produced this datacube.", ) fk_system: UUIDForeignKey = IdsField( primary_key="/properties/systems/items/properties/pk", description="Foreign key to the system (in `systems`) that produced this datacube.", ) fk_sample: UUIDForeignKey = IdsField( primary_key="/properties/samples/items/properties/pk", description="Foreign key to the sample (in `samples`) this datacube was measured on.", ) name: Required[str] description: NullableString = IdsField(default=None) dimensions: Required[Annotated[List[Dimension], fixed_length(1)]] measures: Required[Annotated[List[XrdMeasure], fixed_length(1)]]
[docs] @model_validator(mode="after") def two_theta_dimension_and_intensity_measure_present( self, ) -> "TwoThetaScanDatacube": """Every `TwoThetaScanDatacube`'s one dimension must be `two_theta`, and `intensity` must be present as a measure. This is the precise criterion for what this component supports: any 2-Theta Scan, i.e. any scan with 2-theta as its dimension and intensity as a measure, not specifically pharma powder XRD. See the module docstring above for the scan modes (rocking curves, pole figures, reciprocal space maps -- primary dimension is omega, phi, or a reciprocal-space grid, not 2-theta) this intentionally doesn't support.""" _require_two_theta_dimension_and_intensity_measure( self.dimensions[0].name, [measure.name for measure in self.measures], type(self).__name__, ) return self