"""
Raw two-theta scan datacube preservation for XRD IDSs.
:py:class:`TwoThetaScanDatacube <ts_ids_components.xrd.datacube.TwoThetaScanDatacube>`
stores parsed, structured per-point data -- but converting a raw string to a
float is lossy by construction: ``float("40.0000") == 40.0``, silently
dropping trailing zeros that reflect the instrument's actual reported
precision, not incidental formatting. :py:class:`RawTwoThetaScanDatacube` is
an exact structural replica of `TwoThetaScanDatacube` -- the same one
``two_theta`` dimension plus per-vendor named measures -- built from
:py:class:`RawXrdDimension`/:py:class:`RawXrdMeasure` instead of
:py:class:`Dimension <ts_ids_core.schema.dimension.Dimension>`/
:py:class:`XrdMeasure <ts_ids_components.xrd.datacube.XrdMeasure>`: every
``scale``/``value`` entry is the exact original raw string (not a parsed
float), and every ``unit`` is the exact original raw unit string (not the
canonicalized unit name used on the parsed side), so the original can always
be recovered independent of parser correctness, point-for-point and
axis-for-axis.
Like `TwoThetaScanDatacube`, each entry also carries its own ``fk_method``/
``fk_run``/``fk_system``/``fk_sample`` rather than relying on matching list
order with ``datacubes``, since matching by list order is fragile the
moment a file has more than one scan. On top of that,
:py:class:`RawTwoThetaScanDatacube` carries an ``fk_datacube`` pointing
directly at the `TwoThetaScanDatacube` it was parsed from, so translating
between a parsed datacube and its raw counterpart is a direct lookup rather
than a join on the shared ``(fk_method, fk_run)`` pair -- the same reasoning
that motivates bundling a raw string directly alongside its parsed value on
:py:class:`~ts_ids_core.schema.value_unit.RawValueUnit`. Consuming IDSs
declare ``datacubes_raw: List[RawTwoThetaScanDatacube]`` as a top-level
array; each vendor's own subclass restates ``measures`` with the same total
``fixed_length`` as its corresponding `TwoThetaScanDatacube` subclass.
"""
from typing import List
from pydantic import model_validator
from ts_ids_core.annotations import (
NullableString,
Required,
UUIDForeignKey,
fixed_length,
)
from ts_ids_core.base.ids_element import IdsElement
from ts_ids_core.base.ids_field import IdsField
from typing_extensions import Annotated
from ts_ids_components.xrd.datacube import (
_require_two_theta_dimension_and_intensity_measure,
)
[docs]
class RawXrdDimension(IdsElement):
"""
A dimension of a :py:class:`RawTwoThetaScanDatacube`. Structurally identical to
:py:class:`Dimension <ts_ids_core.schema.dimension.Dimension>` (name,
unit, scale), except `scale` holds the exact original raw strings a
datacube's dimension was parsed from (not parsed floats), and `unit`
holds the exact original raw unit string (not the canonicalized unit
name used on `TwoThetaScanDatacube <ts_ids_components.xrd.datacube.TwoThetaScanDatacube>`'s
own dimension).
"""
name: Required[NullableString] = IdsField(
description=(
"Name of the dimension, matching the corresponding `TwoThetaScanDatacube` "
"dimension by name (e.g. 'two_theta')."
)
)
unit: Required[NullableString] = IdsField(
description="Unit exactly as it appears in the raw file, unconverted."
)
scale: Required[List[NullableString]] = IdsField(
description=(
"Dimension scale values exactly as they appear in the raw file, "
"unconverted."
)
)
[docs]
class RawXrdMeasure(IdsElement):
"""
A measure of a :py:class:`RawTwoThetaScanDatacube`. Structurally identical to
:py:class:`XrdMeasure <ts_ids_components.xrd.datacube.XrdMeasure>` (name,
unit, value), except `value` holds the exact original raw strings a
measure was parsed from (not parsed floats), and `unit` holds the exact
original raw unit string (not the canonicalized unit name used on
`XrdMeasure`).
"""
name: Required[NullableString] = IdsField(
description=(
"Name of the measure, matching the corresponding `XrdMeasure` by "
"name (e.g. 'intensity')."
)
)
unit: Required[NullableString] = IdsField(
description="Unit exactly as it appears in the raw file, unconverted."
)
value: Required[List[NullableString]] = IdsField(
description=(
"Per-point measure values exactly as they appear in the raw "
"file, unconverted."
)
)
[docs]
class RawTwoThetaScanDatacube(IdsElement):
"""
An exact structural replica of `TwoThetaScanDatacube
<ts_ids_components.xrd.datacube.TwoThetaScanDatacube>` -- the same one
`two_theta` dimension plus per-vendor named measures -- built from
:py:class:`RawXrdDimension`/:py:class:`RawXrdMeasure` instead of
:py:class:`Dimension <ts_ids_core.schema.dimension.Dimension>`/
`XrdMeasure`, so every `scale`/`value` entry is the exact original raw
string (not a parsed float) and every `unit` is the exact original raw
unit string (not the canonicalized unit name). See the module docstring
above for why this exists. Linked to its source scan/run/system/sample
by `fk_method`/`fk_run`/`fk_system`/`fk_sample` (rather than by matching
list order with `datacubes`), and directly to its parsed counterpart by
`fk_datacube`.
"""
fk_method: UUIDForeignKey = IdsField(
primary_key="/properties/methods/items/properties/pk",
description="Foreign key to the scan (in `methods`) this raw data was parsed from.",
)
fk_run: UUIDForeignKey = IdsField(
primary_key="/properties/runs/items/properties/pk",
description="Foreign key to the run (in `runs`) this raw data was parsed from.",
)
fk_system: UUIDForeignKey = IdsField(
primary_key="/properties/systems/items/properties/pk",
description="Foreign key to the system (in `systems`) this raw data was parsed from.",
)
fk_sample: UUIDForeignKey = IdsField(
primary_key="/properties/samples/items/properties/pk",
description="Foreign key to the sample (in `samples`) this raw data was measured on.",
)
fk_datacube: UUIDForeignKey = IdsField(
primary_key="/properties/datacubes/items/properties/pk",
description="Foreign key to the parsed datacube (in `datacubes`) this raw data was parsed from.",
)
name: Required[str]
description: NullableString = IdsField(default=None)
dimensions: Required[Annotated[List[RawXrdDimension], fixed_length(1)]]
measures: Required[Annotated[List[RawXrdMeasure], fixed_length(1)]]
[docs]
@model_validator(mode="after")
def two_theta_dimension_and_intensity_measure_present(
self,
) -> "RawTwoThetaScanDatacube":
"""Mirrors `TwoThetaScanDatacube`'s own validator of the same name:
the one dimension must be `two_theta`, and `intensity` must be
present as a measure, by name."""
_require_two_theta_dimension_and_intensity_measure(
self.dimensions[0].name,
[measure.name for measure in self.measures],
type(self).__name__,
)
return self
[docs]
@model_validator(mode="after")
def consistent_scale_and_value_lengths(self) -> "RawTwoThetaScanDatacube":
"""Mirrors `ts_ids_core.schema.DataCube.consistent_number_of_dimensions`,
which `RawTwoThetaScanDatacube` doesn't inherit (it isn't a
`DataCube` subclass, since its fields are string-typed rather than
float-typed): every measure's `value` must have as many entries as
the dimension's `scale`, so a raw parser bug producing a truncated
or misaligned per-point array is caught at validation time rather
than silently stored as valid IDS data."""
expected_length = len(self.dimensions[0].scale)
for measure in self.measures:
if len(measure.value) != expected_length:
raise ValueError(
f"{type(self).__name__} measure '{measure.name}' has "
f"{len(measure.value)} value(s), expected {expected_length} "
"to match dimensions[0].scale"
)
return self