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