diff --git a/src/scippnexus/__init__.py b/src/scippnexus/__init__.py index 9a0bd33e..f0ea5fa1 100644 --- a/src/scippnexus/__init__.py +++ b/src/scippnexus/__init__.py @@ -18,8 +18,26 @@ create_class, create_field, ) -from .field import Attrs, Field +from .field import Attrs, DependsOn, Field from .file import File from ._load import load from .nexus_classes import * -from .nxtransformations import compute_positions, zip_pixel_offsets +from .nxtransformations import compute_positions, zip_pixel_offsets, TransformationChain + +__all__ = [ + 'Attrs', + 'DependsOn', + 'Field', + 'File', + 'Group', + 'NXobject', + 'NexusStructureError', + 'TransformationChain', + 'base_definitions', + 'compute_positions', + 'create_class', + 'create_field', + 'load', + 'typing', + 'zip_pixel_offsets', +] diff --git a/src/scippnexus/base.py b/src/scippnexus/base.py index 717dfe1f..b3dd218a 100644 --- a/src/scippnexus/base.py +++ b/src/scippnexus/base.py @@ -409,16 +409,13 @@ def isclass(x): # For a time-dependent transformation in NXtransformations, an NXlog may # take the place of the `value` field. In this case, we need to read the # properties of the NXlog group to make the actual transformation. - from .nxtransformations import maybe_resolve, maybe_transformation + from .nxtransformations import maybe_transformation, parse_depends_on_chain - if ( - isinstance(dg, sc.DataGroup) - and (depends_on := dg.get('depends_on')) is not None - ): - if (resolved := maybe_resolve(self['depends_on'], depends_on)) is not None: - dg['resolved_depends_on'] = resolved + if isinstance(dg, sc.DataGroup) and 'depends_on' in dg: + if (chain := parse_depends_on_chain(self, dg['depends_on'])) is not None: + dg['depends_on'] = chain - return maybe_transformation(self, value=dg, sel=sel) + return maybe_transformation(self, value=dg) def _warn_fallback(self, e: Exception) -> None: msg = ( diff --git a/src/scippnexus/field.py b/src/scippnexus/field.py index 601ff475..395d2280 100644 --- a/src/scippnexus/field.py +++ b/src/scippnexus/field.py @@ -24,14 +24,22 @@ from .base import Group -def depends_on_to_relative_path(depends_on: str, parent_path: str) -> str: - """Replace depends_on paths with relative paths. +@dataclass +class DependsOn: + """ + Represents a depends_on reference in a NeXus file. + + The parent (the full path within the NeXus file) is stored, as the value may be + relative or absolute, so having the path available after loading is essential. + """ + + parent: str + value: str - After loading we will generally not have the same root so absolute paths - cannot be resolved after loading.""" - if depends_on.startswith('/'): - return posixpath.relpath(depends_on, parent_path) - return depends_on + def absolute_path(self) -> str | None: + if self.value == '.': + return None + return posixpath.normpath(posixpath.join(self.parent, self.value)) def _is_time(obj): @@ -161,7 +169,7 @@ def __getitem__(self, select: ScippIndex) -> Any | sc.Variable: # If the variable is empty, return early if np.prod(shape) == 0: variable = self._maybe_datetime(variable) - return maybe_transformation(self, value=variable, sel=select) + return maybe_transformation(self, value=variable) if self.dtype == sc.DType.string: try: @@ -170,10 +178,8 @@ def __getitem__(self, select: ScippIndex) -> Any | sc.Variable: strings = self.dataset.asstr(encoding='latin-1')[index] _warn_latin1_decode(self.dataset, strings, str(e)) variable.values = np.asarray(strings).flatten() - if self.dataset.name.endswith('depends_on') and variable.ndim == 0: - variable.value = depends_on_to_relative_path( - variable.value, self.dataset.parent.name - ) + if self.dataset.name.endswith('/depends_on') and variable.ndim == 0: + return DependsOn(parent=self.dataset.parent.name, value=variable.value) elif variable.values.flags["C_CONTIGUOUS"]: # On versions of h5py prior to 3.2, a TypeError occurs in some cases # where h5py cannot broadcast data with e.g. shape (20, 1) to a buffer @@ -199,7 +205,7 @@ def __getitem__(self, select: ScippIndex) -> Any | sc.Variable: else: return variable.value variable = self._maybe_datetime(variable) - return maybe_transformation(self, value=variable, sel=select) + return maybe_transformation(self, value=variable) def _maybe_datetime(self, variable: sc.Variable) -> sc.Variable: if _is_time(variable): diff --git a/src/scippnexus/nxtransformations.py b/src/scippnexus/nxtransformations.py index c8382cbf..5a5b53d6 100644 --- a/src/scippnexus/nxtransformations.py +++ b/src/scippnexus/nxtransformations.py @@ -1,133 +1,137 @@ # SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2023 Scipp contributors (https://github.com/scipp) +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) # @author Simon Heybrock +""" +Utilities for loading and working with NeXus transformations. + +Transformation chains in NeXus files can be non-local and can thus be challenging to +work with. Additionally, values of transformations can be time-dependent, with each +chain link potentially having a different time-dependent value. In practice the user is +interested in the position and orientation of a component at a specific time or time +range. This may involve evaluating the transformation chain at a specific time, or +applying some heuristic to determine if the changes in the transformation value are +significant or just noise. In combination, the above means that we need to remain +flexible in how we handle transformations, preserving all necessary information from +the source files. Therefore: + +1. :py:class:`Transform` is a dataclass representing a transformation. The raw `value` + dataset is preserved (instead of directly converting to, e.g., a rotation matrix) to + facilitate further processing such as computing the mean or variance. +2. Loading a :py:class:`Group` will follow depends_on chains and store them as an + attribute of thr depends_on field. This is done by :py:func:`parse_depends_on_chain`. +3. :py:func:`compute_positions` computes component positions (and transformations). By + making this an explicit separate step, transformations can be applied to the + transformations stored by thr depends_on field before doing so. We imagine that this + can be used to + + - Evaluate the transformation at a specific time. + - Apply filters to remove noise, to avoid having to deal with very small time + intervals when processing data. + +By keeping the loaded transformations in a simple and modifiable format, we can +furthermore manually update the transformations with information from other sources, +such as streamed NXlog values received from a data acquisition system. +""" + from __future__ import annotations import warnings -from dataclasses import dataclass +from dataclasses import dataclass, field, replace +from typing import Literal import numpy as np import scipp as sc from scipp.scipy import interpolate -from .base import Group, NexusStructureError, NXobject, ScippIndex -from .field import Field, depends_on_to_relative_path - - -class TransformationError(NexusStructureError): - pass +from .base import Group, NexusStructureError, NXobject, base_definitions_dict +from .field import DependsOn, Field class NXtransformations(NXobject): - """Group of transformations.""" - + """ + Group of transformations. -class Transformation: - def __init__(self, obj: Field | Group): # could be an NXlog - self._obj = obj + Currently all transformations in the group are loaded. This may lead to redundant + loads as transformations are also loaded by following depends_on chains. + """ - @property - def sizes(self) -> dict: - return self._obj.sizes - @property - def dims(self) -> tuple[str, ...]: - return self._obj.dims +class TransformationError(NexusStructureError): + pass - @property - def shape(self) -> tuple[int, ...]: - return self._obj.shape - @property - def attrs(self): - return self._obj.attrs +@dataclass +class Transform: + """In-memory component translation or rotation as described by NXtransformations.""" - @property - def name(self): - return self._obj.name + name: str + transformation_type: Literal['translation', 'rotation'] + value: sc.Variable | sc.DataArray | sc.DataGroup + vector: sc.Variable + depends_on: DependsOn + offset: sc.Variable | None - @property - def offset(self): - if (offset := self.attrs.get('offset')) is None: - return None - if (offset_units := self.attrs.get('offset_units')) is None: + def __post_init__(self): + if self.transformation_type not in ['translation', 'rotation']: raise TransformationError( - f"Found {offset=} but no corresponding 'offset_units' " - f"attribute at {self.name}" + f"{self.transformation_type=} attribute at {self.name}," + " expected 'translation' or 'rotation'." ) - return sc.spatial.translation(value=offset, unit=offset_units) - - @property - def vector(self) -> sc.Variable: - if self.attrs.get('vector') is None: - raise TransformationError('A transformation needs a vector attribute.') - return sc.vector(value=self.attrs.get('vector')) - - def __getitem__(self, select: ScippIndex): - transformation_type = self.attrs.get('transformation_type') - # According to private communication with Tobias Richter, NeXus allows 0-D or - # shape=[1] for single values. It is unclear how and if this could be - # distinguished from a scan of length 1. - value = self._obj[select] - return self.make_transformation( - value, transformation_type=transformation_type, select=select + + @staticmethod + def from_object( + obj: Field | Group, value: sc.Variable | sc.DataArray | sc.DataGroup + ) -> Transform: + depends_on = DependsOn(parent=obj.parent.name, value=obj.attrs['depends_on']) + return Transform( + name=obj.name, + transformation_type=obj.attrs.get('transformation_type'), + value=_parse_value(value), + vector=sc.vector(value=obj.attrs['vector']), + depends_on=depends_on, + offset=_parse_offset(obj), + ) + + def build(self) -> sc.Variable | sc.DataArray: + """Convert the raw transform into a rotation or translation matrix.""" + t = self.value * self.vector + v = t if isinstance(t, sc.Variable) else t.data + if self.transformation_type == 'translation': + v = sc.spatial.translations(dims=v.dims, values=v.values, unit=v.unit) + elif self.transformation_type == 'rotation': + v = sc.spatial.rotations_from_rotvecs(v) + if isinstance(t, sc.Variable): + t = v + else: + t.data = v + if self.offset is None: + return t + if self.transformation_type == 'translation': + return t * self.offset.to(unit=t.unit, copy=False) + return t * self.offset + + +def _parse_offset(obj: Field | Group) -> sc.Variable | None: + if (offset := obj.attrs.get('offset')) is None: + return None + if (offset_units := obj.attrs.get('offset_units')) is None: + raise TransformationError( + f"Found {offset=} but no corresponding 'offset_units' " + f"attribute at {obj.name}" ) + return sc.spatial.translation(value=offset, unit=offset_units) - def make_transformation( - self, - value: sc.Variable | sc.DataArray, - *, - transformation_type: str, - select: ScippIndex, + +def _parse_value( + value: sc.Variable | sc.DataArray | sc.DataGroup, +) -> sc.Variable | sc.DataArray | sc.DataGroup: + if isinstance(value, sc.DataGroup) and ( + isinstance(value.get('value'), sc.DataArray) ): - try: - if isinstance(value, sc.DataGroup) and ( - isinstance(value.get('value'), sc.DataArray) - ): - # Some NXlog groups are split into value, alarm, and connection_status - # sublogs. We only care about the value. - value = value['value'] - if isinstance(value, sc.DataGroup): - return value - t = value * self.vector - v = t if isinstance(t, sc.Variable) else t.data - if transformation_type == 'translation': - v = sc.spatial.translations(dims=v.dims, values=v.values, unit=v.unit) - elif transformation_type == 'rotation': - v = sc.spatial.rotations_from_rotvecs(v) - else: - raise TransformationError( - f"{transformation_type=} attribute at {self.name}," - " expected 'translation' or 'rotation'." - ) - if isinstance(t, sc.Variable): - t = v - else: - t.data = v - if (offset := self.offset) is None: - transform = t - else: - offset = sc.vector(value=offset.values, unit=offset.unit) - offset = sc.spatial.translation(value=offset.value, unit=offset.unit) - if transformation_type == 'translation': - offset = offset.to(unit=t.unit, copy=False) - transform = t * offset - if (depends_on := self.attrs.get('depends_on')) is not None: - if not isinstance(transform, sc.DataArray): - transform = sc.DataArray(transform) - transform.coords['depends_on'] = sc.scalar( - depends_on_to_relative_path(depends_on, self._obj.parent.name) - ) - return transform - except (sc.DimensionError, sc.UnitError, TransformationError) as e: - msg = ( - f"Failed to convert {self.name} into a transformation: {e} " - "Falling back to returning underlying value." - ) - warnings.warn(msg, stacklevel=2) - # TODO We should probably try to return some other data structure and - # also insert offset and other attributes. - return value + # Some NXlog groups are split into value, alarm, and connection_status + # sublogs. We only care about the value. + value = value['value'] + return value def _interpolate_transform(transform, xnew): @@ -171,6 +175,8 @@ def combine_transformations( ) total_transform = None for transform in chain: + if transform.dtype in (sc.DType.translation3, sc.DType.affine_transform3): + transform = transform.to(unit='m', copy=False) if total_transform is None: total_transform = transform elif isinstance(total_transform, sc.DataArray) and isinstance( @@ -203,9 +209,7 @@ def combine_transformations( def maybe_transformation( - obj: Field | Group, - value: sc.Variable | sc.DataArray | sc.DataGroup, - sel: ScippIndex, + obj: Field | Group, value: sc.Variable | sc.DataArray | sc.DataGroup ) -> sc.Variable | sc.DataArray | sc.DataGroup: """ Return a loaded field, possibly modified if it is a transformation. @@ -217,171 +221,64 @@ def maybe_transformation( Instead we use the presence of the attribute 'transformation_type' to identify transformation fields. """ - if (transformation_type := obj.attrs.get('transformation_type')) is None: + if obj.attrs.get('transformation_type') is None: + return value + try: + return Transform.from_object(obj, value) + except KeyError as e: + warnings.warn( + UserWarning(f'Invalid transformation, missing attribute {e}'), stacklevel=2 + ) return value - transform = Transformation(obj).make_transformation( - value, transformation_type=transformation_type, select=sel - ) - # When loading a subgroup of a file there can be transformation chains - # that lead outside the loaded group. In this case we cannot resolve the - # chain after loading, so we try to resolve it directly. - return assign_resolved(obj, transform) - - -def assign_resolved( - obj: Field | Group, - transform: sc.DataArray | sc.Variable, - force_resolve: bool = False, -) -> sc.DataArray | sc.Variable: - """Add resolved_depends_on coord to a transformation if resolve is performed.""" - if ( - isinstance(transform, sc.DataArray) - and (depends_on := transform.coords.get('depends_on')) is not None - ): - if ( - resolved := maybe_resolve( - obj, depends_on.value, force_resolve=force_resolve - ) - ) is not None: - transform.coords["resolved_depends_on"] = sc.scalar(resolved) - return transform - - -def maybe_resolve( - obj: Field | Group, depends_on: str, force_resolve: bool = False -) -> sc.DataArray | sc.Variable | None: - """Conditionally resolve a depend_on attribute.""" - relative = depends_on_to_relative_path(depends_on, obj.parent.name) - if (force_resolve or relative.startswith('..')) and depends_on != '.': - try: - target = obj.parent[depends_on] - resolved = target[()] - except Exception: # noqa: S110 - # Catchall since resolving not strictly necessary, we should not - # fail the rest of the loading process. - pass - else: - return assign_resolved(target, resolved, force_resolve=True) -class TransformationChainResolver: +@dataclass +class TransformationChain(DependsOn): """ - Resolve a chain of transformations, given depends_on attributes with absolute or - relative paths. + Represents a chain of transformations references by a depends_on field. - A `depends_on` field serves as an entry point into a chain of transformations. - It points to another entry, based on an absolute or relative path. The target - entry may have a `depends_on` attribute pointing to the next transform. This - class follows the paths and resolves the chain of transformations. + Loading a group with a depends_on field will try to follow the chain and store the + transformations as an additional attribute of the in-memory representation of the + depends_on field. """ - class ChainError(KeyError): - """Raised when a transformation chain cannot be resolved.""" + transformations: sc.DataGroup = field(default_factory=sc.DataGroup) - pass - - @dataclass - class Entry: - name: str - value: sc.DataGroup + def compute(self) -> sc.Variable | sc.DataArray: + depends_on = self + try: + chain = [] + while (path := depends_on.absolute_path()) is not None: + chain.append(self.transformations[path]) + depends_on = chain[-1].depends_on + transform = combine_transformations([t.build() for t in chain]) + except KeyError as e: + warnings.warn( + UserWarning(f'depends_on chain references missing node:\n{e}'), + stacklevel=2, + ) + else: + return transform - def __init__(self, stack: list[TransformationChainResolver.Entry]): - self._stack = stack - @staticmethod - def from_root(dg: sc.DataGroup) -> TransformationChainResolver: - return TransformationChainResolver( - [TransformationChainResolver.Entry(name='', value=dg)] +def parse_depends_on_chain( + parent: Field | Group, depends_on: DependsOn +) -> TransformationChain | None: + """Follow a depends_on chain and return the transformations.""" + chain = TransformationChain(depends_on.parent, depends_on.value) + depends_on = depends_on.value + try: + while depends_on != '.': + transform = parent[depends_on] + parent = transform.parent + depends_on = transform.attrs['depends_on'] + chain.transformations[transform.name] = transform[()] + except KeyError as e: + warnings.warn( + UserWarning(f'depends_on chain references missing node {e}'), stacklevel=2 ) - - @property - def name(self) -> str: - return '/'.join([e.name for e in self._stack]) - - @property - def root(self) -> TransformationChainResolver: - return TransformationChainResolver(self._stack[0:1]) - - @property - def parent(self) -> TransformationChainResolver: - if len(self._stack) == 1: - raise TransformationChainResolver.ChainError( - "Transformation depends on node beyond root" - ) - return TransformationChainResolver(self._stack[:-1]) - - @property - def value(self) -> sc.DataGroup: - return self._stack[-1].value - - def __getitem__(self, path: str) -> TransformationChainResolver: - base, *remainder = path.split('/', maxsplit=1) - if base == '': - node = self.root - elif base == '.': - node = self - elif base == '..': - node = self.parent - else: - try: - child = self._stack[-1].value[base] - except KeyError: - raise TransformationChainResolver.ChainError( - f"{base} not found in {self.name}" - ) from None - node = TransformationChainResolver( - [ - *self._stack, - TransformationChainResolver.Entry(name=base, value=child), - ] - ) - return node if len(remainder) == 0 else node[remainder[0]] - - def resolve_depends_on(self) -> sc.DataArray | sc.Variable | None: - """ - Resolve the depends_on attribute of a transformation chain. - - Returns - ------- - : - The resolved position in meter, or None if no depends_on was found. - """ - if 'resolved_depends_on' in self.value: - depends_on = self.value['resolved_depends_on'] - else: - depends_on = self.value.get('depends_on') - if depends_on is None: - return None - # Note that transformations have to be applied in "reverse" order, i.e., - # simply taking math.prod(chain) would be wrong, even if we could - # ignore potential time-dependence. - return combine_transformations(self.get_chain(depends_on)) - - def get_chain( - self, depends_on: str | sc.DataArray | sc.Variable - ) -> list[sc.DataArray | sc.Variable]: - if depends_on == '.': - return [] - if isinstance(depends_on, str): - node = self[depends_on] - transform = node.value.copy(deep=False) - node = node.parent - else: - # Fake node, resolved_depends_on is recursive so this is actually ignored. - node = self - transform = depends_on - depends_on = '.' - if transform.dtype in (sc.DType.translation3, sc.DType.affine_transform3): - transform = transform.to(unit='m', copy=False) - if isinstance(transform, sc.DataArray): - if (attr := transform.coords.pop('resolved_depends_on', None)) is not None: - depends_on = attr.value - elif (attr := transform.coords.pop('depends_on', None)) is not None: - depends_on = attr.value - # If transform is time-dependent then we keep it is a DataArray, otherwise - # we convert it to a Variable. - transform = transform if 'time' in transform.coords else transform.data - return [transform, *node.get_chain(depends_on)] + return None + return chain def compute_positions( @@ -389,6 +286,7 @@ def compute_positions( *, store_position: str = 'position', store_transform: str | None = None, + transformations: sc.DataGroup | None = None, ) -> sc.DataGroup: """ Recursively compute positions from depends_on attributes as well as the @@ -422,20 +320,20 @@ def compute_positions( Name used to store result of resolving each depends_on chain. store_transform: If not None, store the resolved transformation chain in this field. + transformations: + Optional data group containing transformation chains. If not provided, the + transformations are looked up in the chains stored within the depends_on field. Returns ------- : New data group with added positions. """ - # Create resolver at root level, since any depends_on chain may lead to a parent, - # i.e., we cannot use a resolver at the level of each chain's entry point. - resolver = TransformationChainResolver.from_root(dg) return _with_positions( dg, store_position=store_position, store_transform=store_transform, - resolver=resolver, + transformations=transformations, ) @@ -474,19 +372,15 @@ def _with_positions( *, store_position: str, store_transform: str | None = None, - resolver: TransformationChainResolver, + transformations: sc.DataGroup | None = None, ) -> sc.DataGroup: out = sc.DataGroup() - transform = None - if 'depends_on' in dg: - try: - transform = resolver.resolve_depends_on() - except TransformationChainResolver.ChainError as e: - warnings.warn( - UserWarning(f'depends_on chain references missing node:\n{e}'), - stacklevel=2, - ) - else: + if (chain := dg.get('depends_on')) is not None: + if not isinstance(chain, TransformationChain): + chain = TransformationChain(chain.parent, chain.value) + if transformations is not None: + chain = replace(chain, transformations=transformations) + if (transform := chain.compute()) is not None: out[store_position] = transform * sc.vector([0, 0, 0], unit='m') if store_transform is not None: out[store_transform] = transform @@ -496,7 +390,7 @@ def _with_positions( value, store_position=store_position, store_transform=store_transform, - resolver=resolver[name], + transformations=transformations, ) elif ( isinstance(value, sc.DataArray) @@ -510,3 +404,6 @@ def _with_positions( value = value.assign_coords({store_position: transform * offset}) out[name] = value return out + + +base_definitions_dict['NXtransformations'] = NXtransformations diff --git a/tests/nxtransformations_test.py b/tests/nxtransformations_test.py index 5a36d2dd..db2d6b35 100644 --- a/tests/nxtransformations_test.py +++ b/tests/nxtransformations_test.py @@ -5,7 +5,7 @@ from scipp.testing import assert_identical import scippnexus as snx -from scippnexus.nxtransformations import NXtransformations, TransformationChainResolver +from scippnexus.nxtransformations import NXtransformations def make_group(group: h5py.Group) -> snx.Group: @@ -32,9 +32,7 @@ def create_detector(group): def test_Transformation_with_single_value(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) value = sc.scalar(6.5, unit='mm') offset = sc.spatial.translation(value=[1, 2, 3], unit='mm') @@ -49,19 +47,16 @@ def test_Transformation_with_single_value(h5root): value.attrs['offset_units'] = str(offset.unit) value.attrs['vector'] = vector.value - expected = sc.DataArray(data=expected, coords={'depends_on': sc.scalar('.')}) detector = make_group(detector) - depends_on = detector['depends_on'][()] + depends_on = detector['depends_on'][()].value assert depends_on == 'transformations/t1' - t = detector[depends_on][()] + t = detector[depends_on][()].build() assert_identical(t, expected) def test_time_independent_Transformation_with_length_0(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) value = sc.array(dims=['dim_0'], values=[], unit='mm') offset = sc.spatial.translation(value=[1, 2, 3], unit='mm') @@ -76,20 +71,25 @@ def test_time_independent_Transformation_with_length_0(h5root): value.attrs['offset_units'] = str(offset.unit) value.attrs['vector'] = vector.value - expected = sc.DataArray(data=expected, coords={'depends_on': sc.scalar('.')}) detector = make_group(detector) - depends_on = detector['depends_on'][()] + depends_on = detector['depends_on'][()].value assert depends_on == 'transformations/t1' - t = detector[depends_on][()] + t = detector[depends_on][()].build() assert_identical(t, expected) -def test_depends_on_absolute_path_to_sibling_group_resolved_to_relative_path(h5root): +def test_depends_on_absolute_path_to_sibling_group_resolved_correctly(h5root): det1 = snx.create_class(h5root, 'det1', NXtransformations) snx.create_field(det1, 'depends_on', sc.scalar('/det2/transformations/t1')) + depends_on = make_group(det1)['depends_on'][()] + assert depends_on.absolute_path() == '/det2/transformations/t1' + +def test_depends_on_relative_path_to_sibling_group_resolved_correctly(h5root): + det1 = snx.create_class(h5root, 'det1', NXtransformations) + snx.create_field(det1, 'depends_on', sc.scalar('../det2/transformations/t1')) depends_on = make_group(det1)['depends_on'][()] - assert depends_on == '../det2/transformations/t1' + assert depends_on.absolute_path() == '/det2/transformations/t1' def test_depends_on_relative_path_unchanged(h5root): @@ -97,12 +97,10 @@ def test_depends_on_relative_path_unchanged(h5root): snx.create_field(det1, 'depends_on', sc.scalar('transformations/t1')) depends_on = make_group(det1)['depends_on'][()] - assert depends_on == 'transformations/t1' + assert depends_on.value == 'transformations/t1' -def test_depends_on_attr_absolute_path_to_sibling_group_resolved_to_relative_path( - h5root, -): +def test_depends_on_attr_absolute_path_to_sibling_group_preserved(h5root): det1 = snx.create_class(h5root, 'det1', NXtransformations) transformations = snx.create_class(det1, 'transformations', NXtransformations) t1 = snx.create_field(transformations, 't1', sc.scalar(0.1, unit='cm')) @@ -111,7 +109,7 @@ def test_depends_on_attr_absolute_path_to_sibling_group_resolved_to_relative_pat t1.attrs['vector'] = [0, 0, 1] loaded = make_group(det1)['transformations/t1'][()] - assert loaded.coords['depends_on'].value == '../../det2/transformations/t2' + assert loaded.depends_on.value == '/det2/transformations/t2' def test_depends_on_attr_relative_path_unchanged(h5root): @@ -123,17 +121,15 @@ def test_depends_on_attr_relative_path_unchanged(h5root): t1.attrs['vector'] = [0, 0, 1] loaded = make_group(det)['transformations/t1'][()] - assert loaded.coords['depends_on'].value == '.' + assert loaded.depends_on.value == '.' t1.attrs['depends_on'] = 't2' loaded = make_group(det)['transformations/t1'][()] - assert loaded.coords['depends_on'].value == 't2' + assert loaded.depends_on.value == 't2' def test_chain_with_single_values_and_different_unit(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) value = sc.scalar(6.5, unit='mm') offset = sc.spatial.translation(value=[1, 2, 3], unit='mm') @@ -157,19 +153,17 @@ def test_chain_with_single_values_and_different_unit(h5root): detector = make_group(h5root['detector_0']) loaded = detector[()] depends_on = loaded['depends_on'] - assert depends_on == 'transformations/t1' + assert depends_on.value == 'transformations/t1' transforms = loaded['transformations'] - assert_identical(transforms['t1'].data, t1) - assert transforms['t1'].coords['depends_on'].value == 't2' - assert_identical(transforms['t2'].data, t2) - assert transforms['t2'].coords['depends_on'].value == '.' + assert_identical(transforms['t1'].build(), t1) + assert transforms['t1'].depends_on.value == 't2' + assert_identical(transforms['t2'].build(), t2) + assert transforms['t2'].depends_on.value == '.' def test_Transformation_with_multiple_values(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) log = sc.DataArray( sc.array(dims=['time'], values=[1.1, 2.2], unit='m'), @@ -190,18 +184,15 @@ def test_Transformation_with_multiple_values(h5root): value.attrs['vector'] = vector.value expected = t * offset - expected.coords['depends_on'] = sc.scalar('.') detector = make_group(detector) depends_on = detector['depends_on'][()] - assert depends_on == 'transformations/t1' - assert_identical(detector[depends_on][()], expected) + assert depends_on.value == 'transformations/t1' + assert_identical(detector[depends_on.absolute_path()][()].build(), expected) def test_time_dependent_transform_uses_value_sublog(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) log = sc.DataArray( sc.array(dims=['time'], values=[1.1, 2.2], unit='m'), @@ -229,18 +220,15 @@ def test_time_dependent_transform_uses_value_sublog(h5root): value.attrs['vector'] = vector.value expected = t * offset - expected.coords['depends_on'] = sc.scalar('.') detector = make_group(detector) depends_on = detector['depends_on'][()] - assert depends_on == 'transformations/t1' - assert_identical(detector[depends_on][()], expected) + assert depends_on.value == 'transformations/t1' + assert_identical(detector[depends_on.absolute_path()][()].build(), expected) def test_chain_with_multiple_values(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) log = sc.DataArray( sc.array(dims=['time'], values=[1.1, 2.2], unit='m'), @@ -267,21 +255,17 @@ def test_chain_with_multiple_values(h5root): value2.attrs['vector'] = vector.value expected1 = t * offset - expected1.coords['depends_on'] = sc.scalar('t2') expected2 = t - expected2.coords['depends_on'] = sc.scalar('.') detector = make_group(detector)[()] depends_on = detector['depends_on'] - assert depends_on == 'transformations/t1' - assert_identical(detector['transformations']['t1'], expected1) - assert_identical(detector['transformations']['t2'], expected2) + assert depends_on.value == 'transformations/t1' + assert_identical(detector['transformations']['t1'].build(), expected1) + assert_identical(detector['transformations']['t2'].build(), expected2) def test_chain_with_multiple_values_and_different_time_unit(h5root): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) # Making sure to not use nanoseconds since that is used internally and may thus # mask bugs. @@ -312,19 +296,17 @@ def test_chain_with_multiple_values_and_different_time_unit(h5root): value2.attrs['vector'] = vector.value expected1 = t * offset - expected1.coords['depends_on'] = sc.scalar('t2') t2 = t.copy() t2.coords['time'] = t2.coords['time'].to(unit='ms') expected2 = t2 - expected2.coords['depends_on'] = sc.scalar('.') detector = make_group(detector) loaded = detector[...] depends_on = loaded['depends_on'] - assert depends_on == 'transformations/t1' - assert_identical(loaded['transformations']['t1'], expected1) - assert_identical(loaded['transformations']['t2'], expected2) + assert depends_on.value == 'transformations/t1' + assert_identical(loaded['transformations']['t1'].build(), expected1) + assert_identical(loaded['transformations']['t2'].build(), expected2) @pytest.mark.filterwarnings( @@ -334,9 +316,7 @@ def test_broken_time_dependent_transformation_returns_datagroup_but_sets_up_depe h5root, ): detector = create_detector(h5root) - snx.create_field( - detector, 'depends_on', sc.scalar('/detector_0/transformations/t1') - ) + snx.create_field(detector, 'depends_on', sc.scalar('transformations/t1')) transformations = snx.create_class(detector, 'transformations', NXtransformations) log = sc.DataArray( sc.array(dims=['time'], values=[1.1, 2.2], unit='m'), @@ -364,11 +344,11 @@ def test_broken_time_dependent_transformation_returns_datagroup_but_sets_up_depe # Due to the way NXtransformations works, vital information is stored in the # attributes. DataGroup does currently not support attributes, so this information # is mostly useless until that is addressed. - t1 = t['t1'] + t1 = t['t1'].value assert isinstance(t1, sc.DataGroup) assert t1.keys() == {'time', 'value'} - assert loaded['depends_on'] == 'transformations/t1' - assert_identical(loaded['transformations']['t1'], t1) + assert loaded['depends_on'].value == 'transformations/t1' + assert_identical(loaded['transformations']['t1'].value, t1) def write_translation( @@ -392,10 +372,11 @@ def test_nxtransformations_group_single_item(h5root): transformations = snx.create_class(h5root, 'transformations', NXtransformations) write_translation(transformations, 't1', value, offset, vector) + transformations['t1'].attrs['depends_on'] = '.' loaded = make_group(h5root)['transformations'][()] assert set(loaded.keys()) == {'t1'} - assert sc.identical(loaded['t1'], expected) + assert sc.identical(loaded['t1'].build(), expected) def test_nxtransformations_group_two_independent_items(h5root): @@ -406,6 +387,7 @@ def test_nxtransformations_group_two_independent_items(h5root): vector = sc.vector(value=[0, 1, 1]) t = value * vector write_translation(transformations, 't1', value, offset, vector) + transformations['t1'].attrs['depends_on'] = '.' expected1 = ( sc.spatial.translations(dims=t.dims, values=t.values, unit=t.unit) * offset ) @@ -413,14 +395,15 @@ def test_nxtransformations_group_two_independent_items(h5root): value = value * 0.1 t = value * vector write_translation(transformations, 't2', value, offset, vector) + transformations['t2'].attrs['depends_on'] = '.' expected2 = ( sc.spatial.translations(dims=t.dims, values=t.values, unit=t.unit) * offset ) loaded = make_group(h5root)['transformations'][()] assert set(loaded.keys()) == {'t1', 't2'} - assert sc.identical(loaded['t1'], expected1) - assert sc.identical(loaded['t2'], expected2) + assert sc.identical(loaded['t1'].build(), expected1) + assert sc.identical(loaded['t2'].build(), expected2) def test_nxtransformations_group_single_chain(h5root): @@ -431,6 +414,7 @@ def test_nxtransformations_group_single_chain(h5root): vector = sc.vector(value=[0, 1, 1]) t = value * vector write_translation(transformations, 't1', value, offset, vector) + transformations['t1'].attrs['depends_on'] = '.' expected1 = ( sc.spatial.translations(dims=t.dims, values=t.values, unit=t.unit) * offset ) @@ -445,9 +429,9 @@ def test_nxtransformations_group_single_chain(h5root): loaded = make_group(h5root)['transformations'][()] assert set(loaded.keys()) == {'t1', 't2'} - assert_identical(loaded['t1'], expected1) - assert_identical(loaded['t2'].data, expected2) - assert loaded['t2'].coords['depends_on'].value == 't1' + assert_identical(loaded['t1'].build(), expected1) + assert_identical(loaded['t2'].build(), expected2) + assert loaded['t2'].depends_on.value == 't1' def test_slice_transformations(h5root): @@ -468,11 +452,13 @@ def test_slice_transformations(h5root): value1.attrs['offset'] = offset.values value1.attrs['offset_units'] = str(offset.unit) value1.attrs['vector'] = vector.value + value1.attrs['depends_on'] = '.' expected = t * offset assert sc.identical( - make_group(h5root)['transformations']['time', 1:3]['t1'], expected['time', 1:3] + make_group(h5root)['transformations']['time', 1:3]['t1'].build(), + expected['time', 1:3], ) @@ -494,6 +480,7 @@ def test_label_slice_transformations(h5root): value1.attrs['offset'] = offset.values value1.attrs['offset_units'] = str(offset.unit) value1.attrs['vector'] = vector.value + value1.attrs['depends_on'] = '.' expected = t * offset @@ -503,7 +490,7 @@ def test_label_slice_transformations(h5root): sc.scalar(22, unit='s').to(unit='ns') : sc.scalar(44, unit='s').to( unit='ns' ), - ]['t1'], + ]['t1'].build(), expected[ 'time', sc.datetime('1970-01-01T00:00:22', unit='ns') : sc.datetime( @@ -513,108 +500,11 @@ def test_label_slice_transformations(h5root): ) -def test_TransformationChainResolver_path_handling(): - tree = TransformationChainResolver.from_root({'a': {'b': {'c': 1}}}) - assert tree['a']['b']['c'].value == 1 - assert tree['a/b/c'].value == 1 - assert tree['/a/b/c'].value == 1 - assert tree['a']['../a/b/c'].value == 1 - assert tree['a/b']['../../a/b/c'].value == 1 - assert tree['a/b']['./c'].value == 1 - - -def test_TransformationChainResolver_name(): - tree = TransformationChainResolver.from_root({'a': {'b': {'c': 1}}}) - assert tree['a']['b']['c'].name == '/a/b/c' - assert tree['a/b/c'].name == '/a/b/c' - assert tree['/a/b/c'].name == '/a/b/c' - assert tree['a']['../a/b/c'].name == '/a/b/c' - assert tree['a/b']['../../a/b/c'].name == '/a/b/c' - assert tree['a/b']['./c'].name == '/a/b/c' - - -def test_TransformationChainResolver_raises_ChainError_if_child_does_not_exists(): - tree = TransformationChainResolver.from_root({'a': {'b': {'c': 1}}}) - with pytest.raises(TransformationChainResolver.ChainError): - tree['a']['b']['d'] - - -def test_TransformationChainResolver_raises_ChainError_if_path_leads_beyond_root(): - tree = TransformationChainResolver.from_root({'a': {'b': {'c': 1}}}) - with pytest.raises(TransformationChainResolver.ChainError): - tree['..'] - with pytest.raises(TransformationChainResolver.ChainError): - tree['a']['../..'] - with pytest.raises(TransformationChainResolver.ChainError): - tree['../a'] - - origin = sc.vector([0, 0, 0], unit='m') shiftX = sc.spatial.translation(value=[1, 0, 0], unit='m') rotZ = sc.spatial.rotations_from_rotvecs(sc.vector([0, 0, 90], unit='deg')) -def test_resolve_depends_on_dot(): - tree = TransformationChainResolver.from_root({'depends_on': '.'}) - assert sc.identical(tree.resolve_depends_on() * origin, origin) - - -def test_resolve_depends_on_child(): - transform = sc.DataArray(shiftX, coords={'depends_on': sc.scalar('.')}) - tree = TransformationChainResolver.from_root( - {'depends_on': 'child', 'child': transform} - ) - expected = sc.vector([1, 0, 0], unit='m') - assert sc.identical(tree.resolve_depends_on() * origin, expected) - - -def test_resolve_depends_on_grandchild(): - transform = sc.DataArray(shiftX, coords={'depends_on': sc.scalar('.')}) - tree = TransformationChainResolver.from_root( - {'depends_on': 'child/grandchild', 'child': {'grandchild': transform}} - ) - expected = sc.vector([1, 0, 0], unit='m') - assert sc.identical(tree.resolve_depends_on() * origin, expected) - - -def test_resolve_depends_on_child1_depends_on_child2(): - transform1 = sc.DataArray(shiftX, coords={'depends_on': sc.scalar('child2')}) - transform2 = sc.DataArray(rotZ, coords={'depends_on': sc.scalar('.')}) - tree = TransformationChainResolver.from_root( - {'depends_on': 'child1', 'child1': transform1, 'child2': transform2} - ) - # Note order - expected = transform2.data * transform1.data - assert sc.identical(tree.resolve_depends_on(), expected) - - -def test_resolve_depends_on_grandchild1_depends_on_grandchild2(): - transform1 = sc.DataArray(shiftX, coords={'depends_on': sc.scalar('grandchild2')}) - transform2 = sc.DataArray(rotZ, coords={'depends_on': sc.scalar('.')}) - tree = TransformationChainResolver.from_root( - { - 'depends_on': 'child/grandchild1', - 'child': {'grandchild1': transform1, 'grandchild2': transform2}, - } - ) - expected = transform2.data * transform1.data - assert sc.identical(tree.resolve_depends_on(), expected) - - -def test_resolve_depends_on_grandchild1_depends_on_child2(): - transform1 = sc.DataArray(shiftX, coords={'depends_on': sc.scalar('../child2')}) - transform2 = sc.DataArray(rotZ, coords={'depends_on': sc.scalar('.')}) - tree = TransformationChainResolver.from_root( - { - 'depends_on': 'child1/grandchild1', - 'child1': {'grandchild1': transform1}, - 'child2': transform2, - } - ) - expected = transform2.data * transform1.data - assert sc.identical(tree.resolve_depends_on(), expected) - - def test_compute_positions(h5root): instrument = snx.create_class(h5root, 'instrument', snx.NXinstrument) detector = create_detector(instrument) @@ -839,7 +729,8 @@ def test_compute_positions_warns_if_depends_on_is_dead_link(h5root): detector = create_detector(instrument) snx.create_field(detector, 'depends_on', sc.scalar('transform')) root = make_group(h5root) - loaded = root[()] + with pytest.warns(UserWarning, match='depends_on chain references missing node'): + loaded = root[()] with pytest.warns(UserWarning, match='depends_on chain references missing node'): snx.compute_positions(loaded) @@ -927,11 +818,13 @@ def test_compute_transformation_warns_if_transformation_missing_vector_attr( value1 = snx.create_class(transformations, 't1', snx.NXlog) snx.create_field(value1, 'time', _log.coords['time'] - sc.epoch(unit='s')) snx.create_field(value1, 'value', _log.data) - value1.attrs['depends_on'] = 't2' + value1.attrs['depends_on'] = '.' value1.attrs['transformation_type'] = 'rotation' value1.attrs['offset'] = offset1.values value1.attrs['offset_units'] = str(offset1.unit) root = make_group(h5root) - with pytest.warns(UserWarning, match='transformation needs a vector attribute'): + with pytest.warns( + UserWarning, match="Invalid transformation, missing attribute 'vector'" + ): root[()]