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