Repository: scipp/scippnexus

Issue:
Representation of loaded transformations?
Relating to the larger context of #232, #233, and scipp/essreduce#96, there is one further problem. We may have to deal with noisy motion logs that we want to treat as a static value (potentially after some processing). However, ScippNexus directly converts rotations (defined as axis angle angle) into a rotation matrix (probably quaternions?):
https://github.com/scipp/scippnexus/blob/e33f2f4bb2a7843a5ed7fd31fc1761a6408c262e/src/scippnexus/nxtransformations.py#L94-L95. Performing any kind of processing on this (e.g., determining if the time-dependence is just noise) is much harder and more error prone than in the angle-axis representation (where only the angle, a single number, will be time-dependent).

I am not sure what to do. Should the representation of the loaded transforms be changed? Should the loading be able to perform operations such as taking a mean before this happens? As `snx.compute_positions` currently tries to compute a time-dependent `transform` and `position` this will also have implications there, so this is a tricky decision.

As in other cases, I am feeling more and more that a better and cleaner separation of loading from transformations would be beneficial, but that would be a much larger effort.

Discussion:

Patch:
diff --git a/src/scippnexus/__init__.py b/src/scippnexus/__init__.py
index 9a0bd33..f0ea5fa 100644
--- a/src/scippnexus/__init__.py
+++ b/src/scippnexus/__init__.py
@@ -18,8 +18,26 @@ from .base import (
     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 717dfe1..b3dd218 100644
--- a/src/scippnexus/base.py
+++ b/src/scippnexus/base.py
@@ -409,16 +409,13 @@ class Group(Mapping):
         # 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 601ff47..395d228 100644
--- a/src/scippnexus/field.py
+++ b/src/scippnexus/field.py
@@ -24,14 +24,22 @@ if TYPE_CHECKING:
     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 @@ class Field:
         # 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 @@ class Field:
                 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 @@ class Field:
             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 c8382cb..5a4c9e2 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 the 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 the 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


Test patch:
diff --git a/tests/nxtransformations_test.py b/tests/nxtransformations_test.py
index 5a36d2d..db2d6b3 100644
--- a/tests/nxtransformations_test.py
+++ b/tests/nxtransformations_test.py
@@ -5,7 +5,7 @@ import scipp as sc
 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[()]