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, two theta, 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``.

The datacube's own ``name`` and its dimension and measure names are a
cross-vendor standard, defined once below as module-level constants
(:py:data:`TWO_THETA_DATACUBE_NAME`, :py:data:`TWO_THETA_SCAN_DIMENSION_NAME`,
:py:data:`INTENSITY_MEASURE_NAME`, :py:data:`THETA_MEASURE_NAME`,
:py:data:`OMEGA_MEASURE_NAME`, :py:data:`PHI_MEASURE_NAME`,
:py:data:`WEIGHT_MEASURE_NAME`, :py:data:`DIVERGENCE_CORRECTION_MEASURE_NAME`,
:py:data:`EFFECTIVE_TIME_PER_STEP_MEASURE_NAME`,
:py:data:`SCAN_POINT_INDEX_MEASURE_NAME`). Task scripts should import these
rather than hand-typing the strings, so every XRD IDS emits identical names
for the same quantity. Following the platform-wide convention, none of these
names appears in the exported ``schema.json`` -- not as a ``const``/``enum``
and not in a field description -- so a future naming change is a task-script
update rather than an IDS version bump. Using the constants is a documented
convention, not a validation rule.

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

# Standard XRD datacube, dimension, and measure names.
#
# These names are a cross-IDS standard: every XRD IDS (e.g. Bruker D8 Advance,
# Malvern Panalytical Empyrean, Rigaku) must emit the exact same name for the
# same quantity, so data from different vendors can be queried together. Task
# scripts should import these constants rather than typing the strings out.
# Do NOT rename a value here (or replace a constant with a hand-typed string
# in a task script) without coordinating the change across every XRD IDS that
# uses it -- a rename is a breaking change for any downstream query or
# already-processed data relying on the old name.

TWO_THETA_DATACUBE_NAME = "intensity vs two theta"
"""Standard ``name`` of every :py:class:`TwoThetaScanDatacube` (and its raw
mirror, `RawTwoThetaScanDatacube
<ts_ids_components.xrd.datacube_raw.RawTwoThetaScanDatacube>`): what the
datacube holds, intensity as a function of 2-theta. Task scripts should
pass this constant as ``name`` rather than inventing their own. Multiple
datacubes in one file (one per scan) share this name and are told apart by
their ``fk_method``."""

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

INTENSITY_MEASURE_NAME = "intensity"
"""Standard name for the diffracted X-ray intensity measure, required on
every :py:class:`TwoThetaScanDatacube`."""

THETA_MEASURE_NAME = "theta"
"""Standard name for the theta (sample/tube) angle measure, e.g. Bruker D8
Advance's ``Theta`` column, which moves in lockstep with 2-theta."""

OMEGA_MEASURE_NAME = "omega"
"""Standard name for the omega (incidence) angle measure, e.g. Malvern
Panalytical Empyrean's ``Omega`` axis."""

PHI_MEASURE_NAME = "phi"
"""Standard name for the phi (in-plane sample rotation) angle measure, e.g.
Malvern Panalytical Empyrean's ``Phi`` axis."""

WEIGHT_MEASURE_NAME = "weight"
"""Standard name for the per-point attenuation/correction factor measure,
e.g. Rigaku's ``Weight`` column or Malvern Panalytical Empyrean's
``beamAttenuationFactors``."""

DIVERGENCE_CORRECTION_MEASURE_NAME = "divergence correction"
"""Standard name for the per-point divergence slit correction factor
measure, e.g. Malvern Panalytical Empyrean's ``divergenceCorrection``
(XRDML v2.1+)."""

EFFECTIVE_TIME_PER_STEP_MEASURE_NAME = "effective time per step"
"""Standard name for the per-point effective counting time measure, e.g.
Bruker D8 Advance's first ``Datum`` column or Malvern Panalytical Empyrean's
per-point ``countingTimes``. Distinct from
:py:attr:`XrdScan.time_per_step <ts_ids_components.xrd.method.XrdScan>`,
which is the scan's single nominal counting time."""

SCAN_POINT_INDEX_MEASURE_NAME = "scan point index"
"""Standard name for the per-point scan point index measure, e.g. Bruker D8
Advance's second ``Datum`` column."""


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_MEASURE_NAME not in measure_names:
        raise ValueError(
            f"{class_name} is missing required measure: '{INTENSITY_MEASURE_NAME}'"
        )


[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