Skip to content

Write NeXus translators

Reach for this when the default conversion is not good enough:

  • a component came out as a bare NXcomponent reference frame when it should be a real class;
  • niess has never heard of the component type;
  • several McStas instances are physically one device and belong in one NeXus group.

A translator is a function. A registry is where you put it.

How a translator is chosen

For each instance, the registry resolves a translator through three tiers, most specific first:

flowchart TD
    A[Instance] --> P{"carries niess<br/>provenance?"}
    P -- no --> D
    P -- yes --> B{"a builder registered<br/>for its source_type?"}
    B -- yes --> T([use that builder])
    B -- no --> C{"a builder registered<br/>for its role?"}
    C -- yes --> T
    C -- no --> D{"a builder registered for its<br/>McStas component type?"}
    D -- yes --> T
    D -- no --> E{"does this registry<br/>have a parent?"}
    E -- "yes: start again,<br/>on the parent" --> P
    E -- no --> F([unhandled: the walk<br/>guesses an NX class])

Each decision asks whether this registry has a builder registered under that key -- not merely whether the instance has such a key. An instance whose source_type no one has registered a builder for simply falls through to the next tier.

The first two tiers read the provenance metadata niess attaches when it builds a component, so they can distinguish two instances of the same McStas type by what niess thinks they are. An instance without provenance -- anything from a .instr niess did not build -- skips both and is matched on its McStas component type alone.

A parent is consulted only once all three of a registry's own tiers have missed, and it then repeats all three itself. That is what "the more specific registry wins outright" means: a child's McStas-type builder beats a parent's source_type builder.

None means two different things

A registry returning no translator means unhandled, and the walk falls back to guessing an NX class. A translator that runs and returns None means suppress — emit nothing at all. That distinction is what makes grouping possible.

Writing one

Register on your own registry, never on the shared default:

from niess.components.aperture import Jaw
from niess.nexus.nodes import dataset
from niess.nexus import (
    NEXUS_REGISTRY, NiessNexusRegistry, component_body, emit,
)

# Extend the default registry rather than modifying it, so these translators apply
# only to conversions that ask for this one.
TEACHING_REGISTRY = NiessNexusRegistry(parent=NEXUS_REGISTRY)

@TEACHING_REGISTRY.register(Jaw)
class DiaphragmTranslator:
    """A Jaw as an NXslit carrying its run-time opening."""

    @staticmethod
    def leaf(visit):
        jaw = visit.obj
        edges = jaw.edge_parameters()
        emit(visit, component_body('NXslit', [
            dataset('description', f'beam-defining diaphragm {visit.name}'),
            # the edges are knobs a run sets, so these are links to their NXlogs
            visit.context.linked_log('x_gap', edges['right'], attrs={'units': 'm'}),
            dataset('y_gap', float(jaw.height.to(unit='m').value),
                    attrs={'units': 'm'}),
        ]))

The translator's leaf receives a Visit — one node of the tree and everything about where it sits — and calls emit with a component body. What a Visit gives you:

visit.obj the niess object itself, with its fields as scipp quantities
visit.name the name it is emitted under, which its ancestors contribute to
visit.emit_name(suffix) a name for one of several things emitted at this node
visit.index its position, when it is one of a sequence
visit.ancestor(kind) the nearest enclosing visit of that class — a detector's arc and triplet are visit.ancestor(Arm).index and visit.ancestor(Channel).index
visit.frame what it is placed against
visit.context the conversion in progress; see below

and on visit.context:

linked_log(name, parameter, attrs) a run-time parameter as a link to its NXlog
stream_group(selection, name) a monitor's or detector's data stream
depends_on(frame) the transformation path a frame resolves to

Prefer linked_log for anything a user can drive at run time; a value read straight off the object is a constant in the file, which is right only if it really is one.

Build children with dataset, group and stream, all importable from niess.nexus.

component_body also takes a name, which overrides the group's name — by default the McStas instance's. Use it where that name is an artefact of how the instrument was built: a composite split across several instances should appear under the name of the thing itself, not thing_slit_0. Placements referring to the instance still resolve; the emitted path follows the name you chose.

Using it

Pass the registry per conversion:

from niess.nexus import to_nexus_structure

structure = to_nexus_structure(teaching, registry=TEACHING_REGISTRY)

Because your registry extends the default rather than mutating it, the same instrument still converts the ordinary way when you do not pass it. That isolation is deliberate: Detector_tubes is not a BIFROST-only component, and BIFROST's translator bakes in ICD pixel numbering and its own Kafka topic. Registering that globally would silently corrupt every other instrument's detectors.

Many instances, one NeXus group

A device built from several McStas components — a multi-opening chopper, a detector bank — collapses using the role tier. When building the instrument, tag each instance:

add_niess_metadata(instance, self, role='disc-opening-primary',
                   extra={'disc_group_id': 'chopper_pack', 'disc_group_index': 0})

giving one instance the primary role and the rest member. Then register both roles:

@MY_REGISTRY.register_role('disc-opening-member')
def suppress(t):
    return None            # emit nothing; this instance is folded into the primary


@MY_REGISTRY.register_role('disc-opening-primary')
def merged(t):
    siblings = t.siblings_in_group()   # ordered by disc_group_index, not by position
    ...
    return component_body('NXdisk_chopper', children)

Tag by explicit role rather than relying on declaration order — siblings_in_group() sorts by disc_group_index, so the merged node is stable even if the instances move.

Give the merged node the object's own name with component_body(..., name=...), so the file describes the device rather than the components it was split across.

Suppressed instances are still recorded internally, so a later component placed relative to one still resolves its transformation chain; the walk warns if that happens, because the emitted depends_on will point at a group that is not written.

Streams

Never hard-code a stream module in a translator. Call resolve_stream(t, default=...), which honours the instrument's own choice first and falls back to your default only if it made none. See choosing how a monitor streams.

Testing

tests/test_nexus_registry.py shows the two useful shapes: a FakeInstance stub for asserting which tier wins, and an end-to-end conversion parametrised over the registry choice so the difference the registry makes is visible in the output.