Write NeXus translators¶
Reach for this when the default conversion is not good enough:
- a component came out as a bare
NXcomponentreference 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.