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.

Hints from issue comments:
@SimonHeybrock: For components without pixels, processing such as averaging can be performed on the resulting positions instead of the transform. This is much easier and well defined. What remains is probably just the NXdetector case, where applying a time-dependent transform to all pixel offsets may not be feasible.

Solution patch:
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..5a4c9e23 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 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[()]