Skip to content

niess.nexus

ESS NeXus Structure JSON, read off the niess object tree. See Produce NeXus Structure JSON for the task-oriented guide and Write NeXus translators for extending it.

Converting an instrument niess did not build is not supported: every target reads the tree, and a .instr file has none.

nexus

ESS NeXus Structure JSON from a niess instrument.

from niess.instrument import Instrument, Mount
from niess.nexus import to_nexus_structure

structure = to_nexus_structure(instrument)

Reads the tree: a component's position and orientation are on the component, a declared Frame is a node, and a depends_on chain is the chain of frames a thing hangs from.

Writing a translator needs the node constructors, which are re-exported here so that takes one import. They are shared with the other route, being format rather than route.

One thing this module deliberately does not import: niess.nexus.bifrost. Instrument-specific translators live in registries of their own, and generic NeXus has no business loading niess.bifrost to publish them.

Converting an instrument niess did not build, by reading emitted McStas back, was a second route of some 1500 lines. It is gone: niess converts niess instruments.

Modules:

  • bifrost –

    BIFROST's own NeXus translators, from the tree.

  • bindings –

    Where a driven axis's numbers come from: a simulation knob, or a real motor.

  • nodes –

    NeXus Structure JSON node constructors.

  • registry –

    Translator lookup for the NeXus target.

  • streams –

    Filewriter stream and link module directives.

  • structure –

    ESS NeXus Structure JSON, from the tree.

Classes:

  • AxisBinding –

    One driven axis, resolved: what to write in an f144 config for it.

  • Binding –

    One entry of a facility bindings file: which stream fills one log.

  • BoundStreams –

    Topics and sources read from a facility bindings file, one log at a time.

  • LogBinding –

    One log, resolved: the stream that fills it.

  • RealStreams –

    Sources are EPICS PV roots, declared on the components that own the axes.

  • SimulatedStreams –

    Sources are the simulation's own parameter names.

  • UndeclaredAxis –

    A real conversion reached an axis with no PV declared for it.

  • UnitMismatch –

    A knob declares a different unit from the one its binding publishes.

  • NiessNexusRegistry –

    Translator lookup for the NeXus target.

  • NexusContext –

Functions:

  • node_name –

    The name of a group, dataset, or named stream node.

  • stream –

    A filewriter module directive -- ev44, da00, f144, link, ...

  • load_bindings –

    Read a facility bindings file, such as ECDC's bindings.yaml.

  • resolve_stream –

    The stream group for a component, or None if it publishes nothing.

  • component_body –

    What a translator returns: the class and contents of one component's group.

  • emit –

    Put one component's group into the instrument.

  • to_nexus_structure –

    Convert instrument to ESS NeXus Structure JSON.

  • translator –

    Register a function returning a component body for one or more niess classes.

AxisBinding dataclass

AxisBinding(topic: str, units: str = '', dtype: str = 'double', default: Any = None, source: Optional[str] = None, pv_root: Optional[str] = None, parameter: Optional[str] = None, logs: Optional[tuple[LogBinding, ...]] = None)

One driven axis, resolved: what to write in an f144 config for it.

pv_root set means this axis is a real EPICS positioner and the three canonical logs follow from it. source set instead means a simulation parameter, and there is one log to write.

Methods:

  • sources –

    The f144 source for each canonical log name this axis has.

Attributes:

  • canonical (bool) –

    Whether this axis can be written as an ESS-canonical NXpositioner.

canonical property

canonical: bool

Whether this axis can be written as an ESS-canonical NXpositioner.

sources

sources() -> dict[str, str]

The f144 source for each canonical log name this axis has.

Source code in src/niess/nexus/bindings.py
def sources(self) -> dict[str, str]:
    """The f144 source for each canonical log name this axis has."""
    if not self.canonical:
        return {'value': self.source}
    return {'value': f'{self.pv_root}.RBV',
            'target_value': f'{self.pv_root}.VAL',
            'idle_flag': f'{self.pv_root}.DMOV'}

Binding dataclass

Binding(key: str, schema: str, source: str, topic: str, source_type: str = 'forwarder', dtype: Optional[str] = None, value_units: Optional[str] = None, extra: Mapping[str, Any] = dict())

One entry of a facility bindings file: which stream fills one log.

key is the log's NeXus path below NXinstrument, dot-separated: pulse_shaping_chopper_1.rotation_speed or sample_jaws.left.value. source_type says who produces the stream. A forwarder source is an EPICS PV, and an efu source is a name chosen by an event formation unit.

Attributes:

  • group (str) –

    The key of the group this log belongs to.

  • log (str) –

    The log's own name within its group.

group property

group: str

The key of the group this log belongs to.

log property

log: str

The log's own name within its group.

BoundStreams dataclass

BoundStreams(bindings: Mapping[str, Binding], simulated: bool = True, prefix: str = SIMULATION_PREFIX, topic: str = DEFAULT_STREAM_TOPIC, chopper_topic: str = DEFAULT_CHOPPER_TOPIC, pulse_source: Optional[str] = None, strict: bool = True)

Topics and sources read from a facility bindings file, one log at a time.

RealStreams builds each log's source from a PV root and a fixed suffix. This binder looks the whole answer up instead, keyed by where the log sits in the file. That covers everything a template cannot, such as a per-disc TDC channel or a piezo motor's PzMtr record.

Two kinds of file come from one bindings file:

simulated=True (the default) is a simulation made to look like the real instrument. Only logs the simulation has a value for are written: a knob's value, a disc's speed, delay and park angle, and the TDC times, which data reduction needs. A setpoint, a done flag, or a delay the electronics add has no counterpart in a simulation, so leaving it out keeps the file from claiming one. Every source the simulation serves over EPICS (source_type: forwarder) is prefixed with prefix. The file then names the real instrument's topics, but a simulated PV can never be mistaken for the real one, even on the same network. Sources an event formation unit chooses (source_type: efu) are unchanged, because the EFU fixes them. Each simulated log carries a simulation_parameter attribute naming the instrument parameter that feeds it.

simulated=False writes every bound log, with the sources exactly as bound: a file for the real instrument.

A knob must be declared in the unit its binding publishes. If it is not, the conversion raises UnitMismatch, so no conversion is ever hidden between the parameter and the stream. A knob with no binding gets a simulated source, prefix plus its own name, on topic (chopper_topic for a disc), whatever topic the knob itself names: the binder, not the knob, decides where a facility's streams go. For a real file an unbound knob raises UndeclaredAxis unless strict=False.

pulse_source defaults to prefix + pulse.

Methods:

  • source –

    The source a log is written with, in this kind of file.

  • log –

    One bound log, checked against the knob that feeds it, if there is one.

  • chopper –

    A disc's logs, from the bindings for its group.

  • pulse –

    The per-pulse reference sample, if source.pulse_charge is bound.

  • stream –

    A detector's or monitor's stream selection, or None if it is not bound.

source

source(binding: Binding) -> str

The source a log is written with, in this kind of file.

Source code in src/niess/nexus/bindings.py
def source(self, binding: Binding) -> str:
    """The source a log is written with, in this kind of file."""
    if self.simulated and binding.source_type == 'forwarder':
        return f'{self.prefix}{binding.source}'
    return binding.source

log

log(binding: Binding, knob=None, name: Optional[str] = None) -> LogBinding

One bound log, checked against the knob that feeds it, if there is one.

Source code in src/niess/nexus/bindings.py
def log(self, binding: Binding, knob=None, name: Optional[str] = None) -> LogBinding:
    """One bound log, checked against the knob that feeds it, if there is one."""
    units = binding.value_units or ''
    if knob is not None and binding.value_units is not None:
        declared = _unit(knob)
        if declared != binding.value_units:
            raise UnitMismatch(
                f'{getattr(knob, "name", knob)!r} is declared in {declared!r} but '
                f'{binding.key!r} publishes {binding.value_units!r}. Declare the '
                'knob in the published unit and convert where the simulation '
                'uses it.')
    return LogBinding(
        name=name or binding.log, module=binding.schema,
        source=self.source(binding), topic=binding.topic, units=units,
        dtype=binding.dtype or 'double',
        parameter=getattr(knob, 'name', None) if self.simulated else None)

chopper

chopper(disc) -> ChopperBinding

A disc's logs, from the bindings for its group.

Source code in src/niess/nexus/bindings.py
def chopper(self, disc) -> ChopperBinding:
    """A disc's logs, from the bindings for its group."""
    from .streams import CHOPPER_LOGS
    experiment, mechanical, pulse = disc.timing_parameters()
    # `delay` is the controller's total, which no knob feeds: see `chopper_logs`
    knobs = {'rotation_speed': disc.speed_parameter(),
             'delay': None,
             'experiment_delay': experiment,
             'mechanical_delay': mechanical,
             'pulse_delay': pulse,
             'park_angle': disc.park_parameter()}
    bound = {log: self.bindings.get(f'{disc.name}.{log}')
             for log, _, _ in CHOPPER_LOGS}
    if not any(bound.values()):
        self._unbound(f'chopper {disc.name!r}',
                      f'Add {disc.name}.<log> entries to the bindings')
        logs = [self._simulated_log(log, knob, self.chopper_topic)
                if knob is not None else
                LogBinding(name=log, module='f144',
                           source=f'{self.prefix}{disc.name}_{log}',
                           topic=self.chopper_topic, units='ns', dtype='double')
                for log, knob in knobs.items()]
        logs.insert(1, LogBinding(name='top_dead_center', module='tdct',
                                  source=f'{self.prefix}{disc.name}_tdc',
                                  topic=self.chopper_topic, dtype=''))
        return ChopperBinding(topic=self.chopper_topic, logs=tuple(logs))
    logs = []
    for log, _, _ in CHOPPER_LOGS:
        binding, knob = bound[log], knobs.get(log)
        if binding is None:
            continue
        if (self.simulated and knob is None and binding.schema != 'tdct'
                and log != 'delay'):
            continue
        logs.append(self.log(binding, knob))
    return ChopperBinding(topic=self.chopper_topic, logs=tuple(logs))

pulse

pulse() -> Optional[LogBinding]

The per-pulse reference sample, if source.pulse_charge is bound.

No simulation parameter feeds it. Whatever serves the simulation's pulse clock publishes it, one sample per pulse.

Source code in src/niess/nexus/bindings.py
def pulse(self) -> Optional[LogBinding]:
    """The per-pulse reference sample, if ``source.pulse_charge`` is bound.

    No simulation parameter feeds it. Whatever serves the simulation's pulse clock
    publishes it, one sample per pulse.
    """
    binding = self.bindings.get('source.pulse_charge')
    return None if binding is None else self.log(binding)

stream

stream(name: str, log: str = 'data') -> Optional[dict]

A detector's or monitor's stream selection, or None if it is not bound.

Source code in src/niess/nexus/bindings.py
def stream(self, name: str, log: str = 'data') -> Optional[dict]:
    """A detector's or monitor's stream selection, or ``None`` if it is not bound."""
    binding = self.bindings.get(f'{name}.{log}')
    if binding is None:
        return None
    selection = {'module': binding.schema, 'topic': binding.topic,
                 'source': self.source(binding)}
    if binding.schema == 'da00':
        selection['config'] = {'topic': binding.topic,
                               'source': self.source(binding), **binding.extra}
    return selection

LogBinding dataclass

LogBinding(name: str, module: str, source: str, topic: str, units: str = '', dtype: str = 'double', parameter: Optional[str] = None)

One log, resolved: the stream that fills it.

parameter names the instrument parameter a simulation publishes the log from. It is written on the NXlog, so whatever serves the simulation's PVs can tell which parameter each source carries. None means nothing in the simulation feeds it: either the file is for a real run, or the value is computed elsewhere, as a chopper's top-dead-centre times are.

RealStreams dataclass

RealStreams(topic: str = DEFAULT_STREAM_TOPIC, chopper_topic: str = DEFAULT_CHOPPER_TOPIC, pulse_source: str = 'pulse', strict: bool = True)

Sources are EPICS PV roots, declared on the components that own the axes.

strict is the default because the failure it prevents is silent: a mistyped pv_roots key would otherwise produce a plausible-looking simulated axis inside a file that says it is real, and nothing downstream would ever say so. An instrument that really is only half wired up can pass strict=False and get the simulated source for the axes nobody has connected yet.

Methods:

  • chopper –

    A disc written against its real controller, if it declares one.

chopper

chopper(disc) -> ChopperBinding

A disc written against its real controller, if it declares one.

Source code in src/niess/nexus/bindings.py
def chopper(self, disc) -> ChopperBinding:
    """A disc written against its real controller, if it declares one."""
    root = getattr(disc, 'pv_root', None)
    if root is None:
        if self.strict:
            raise UndeclaredAxis(
                f'no pv_root declared for chopper {getattr(disc, "name", disc)!r}. '
                'Declare one on the disc, or pass streams=RealStreams(strict=False) '
                'to simulate the choppers that are not wired up yet.')
        return SimulatedStreams(chopper_topic=self.chopper_topic).chopper(disc)
    return ChopperBinding(topic=self.chopper_topic, pv_root=root,
                          tdc_channel=getattr(disc, 'tdc_channel', '00-TS-I'))

SimulatedStreams dataclass

SimulatedStreams(topic: str = DEFAULT_STREAM_TOPIC, chopper_topic: str = DEFAULT_CHOPPER_TOPIC, pulse_source: str = 'pulse')

Sources are the simulation's own parameter names.

Methods:

  • chopper –

    A disc written from what the simulation knows, whatever it declares.

chopper

chopper(disc) -> ChopperBinding

A disc written from what the simulation knows, whatever it declares.

A declared pv_root is ignored rather than honoured: a simulated file that named real PVs would be claiming values nothing published.

Source code in src/niess/nexus/bindings.py
def chopper(self, disc) -> ChopperBinding:
    """A disc written from what the simulation knows, whatever it declares.

    A declared `pv_root` is ignored rather than honoured: a simulated file that
    named real PVs would be claiming values nothing published.
    """
    return ChopperBinding(topic=self.chopper_topic)

UndeclaredAxis

Bases: ValueError

A real conversion reached an axis with no PV declared for it.

UnitMismatch

Bases: ValueError

A knob declares a different unit from the one its binding publishes.

NiessNexusRegistry

NiessNexusRegistry(parent=None)

Bases: NiessRegistry

Translator lookup for the NeXus target.

Created with hooks='nexus', so a class carrying __nexus_leaf__ is its own translator. Registering wins, which is what an instrument-specific conversion needs -- BIFROST's detectors must not give another instrument's their pixel numbering merely because a module was imported.

Methods:

  • resolve_for_object –

    As the base class, but a class's own hooks get their body emitted.

Source code in src/niess/nexus/registry.py
def __init__(self, parent=None):
    super().__init__(parent=parent, hooks='nexus')

resolve_for_object

resolve_for_object(obj)

As the base class, but a class's own hooks get their body emitted.

Source code in src/niess/nexus/registry.py
def resolve_for_object(self, obj):
    """As the base class, but a class's own hooks get their body emitted."""
    builder = super().resolve_for_object(obj)
    return _EmittedHooks(builder) if isinstance(builder, ClassHooks) else builder

NexusContext dataclass

NexusContext(instrument: Any, registry: Any = None, nodes: dict = dict(), nxlog_root: str = DEFAULT_NXLOG_ROOT, instrument_group: dict = None, paths: dict = dict(), _flow: Any = None, pending: list = list(), emitted_names: dict = dict(), streams: Any = None, placed: set = set(), efu_monitors: bool = False)

Bases: Context

Methods:

  • source_contents –

    What the NXsource holds: which facility, and where the pulse reference is.

  • pulse_log –

    The per-pulse reference sample, as the binder says it is published.

  • neutron_production –

    The accelerator, as an NXsource: the facility and the pulse reference.

  • pulse_source_topic –

    Where the per-pulse reference sample is published, for an unbound binder.

  • linked_log –

    A value a run sets, as an NXlog whose datasets link to an existing one.

  • axis –

    Where one driven axis's numbers come from.

  • as_motor –

    A knob as a Motor, whichever spelling it arrived in.

  • place –

    Put a node directly under NXinstrument, and say where it went.

  • positioner_name –

    What to call the positioner a motorised frame hangs from.

  • motor_log –

    An NXlog for a named run-time value that is not a positioner axis.

  • flow –

    The particle flow through the instrument, built once and kept.

  • neighbours –

    What feeds this node and what it feeds, under their emitted names.

  • stream_group –

    One monitor's or detector's data stream, as the instrument chose it.

  • depends_on –

    What a thing in frame hangs from, as a NeXus path.

source_contents

source_contents() -> list[dict]

What the NXsource holds: which facility, and where the pulse reference is.

Without the reference a top-dead-centre time cannot be used: a TDC timestamp is only meaningful against the pulse it is measured from, and every other timestamp in the file -- detector events, motor readbacks, chopper crossings -- has to share the same reference or none of them can be compared.

So one sample per pulse, of the proton charge on target in that pulse, timestamped with the reference time itself. It is an NXlog named pulse_charge, in uC, which is what ECDC calls it and what a reduction normalizes by; a binder that has a binding for source.pulse_charge supplies its stream.

Source code in src/niess/nexus/structure.py
def source_contents(self) -> list[dict]:
    """What the NXsource holds: which facility, and where the pulse reference is.

    Without the reference a top-dead-centre time cannot be used: a TDC timestamp is
    only meaningful against the pulse it is measured from, and every other timestamp
    in the file -- detector events, motor readbacks, chopper crossings -- has to
    share the same reference or none of them can be compared.

    So one sample per pulse, of the proton charge on target in that pulse, timestamped
    with the reference time itself. It is an NXlog named `pulse_charge`, in uC, which
    is what ECDC calls it and what a reduction normalizes by; a binder that has a
    binding for `source.pulse_charge` supplies its stream.
    """
    return [dataset('name', 'ESS'), dataset('probe', 'neutron'), self.pulse_log()]

pulse_log

pulse_log() -> dict

The per-pulse reference sample, as the binder says it is published.

Source code in src/niess/nexus/structure.py
def pulse_log(self) -> dict:
    """The per-pulse reference sample, as the binder says it is published."""
    from .streams import bound_log, f144_log
    bound = getattr(self.streams, 'pulse', lambda: None)()
    if bound is not None:
        return bound_log(bound)
    source, topic = self.pulse_source_topic()
    return f144_log('pulse_charge', source, topic, 'uC', 'double')

neutron_production

neutron_production() -> dict

The accelerator, as an NXsource: the facility and the pulse reference.

Under NXinstrument, which is the only place NXsource is allowed; a group of this class directly under NXentry does not validate. NXsource extends NXcomponent, so it is placed like one -- at the origin, since the accelerator is not somewhere the instrument's frames reach.

Source code in src/niess/nexus/structure.py
def neutron_production(self) -> dict:
    """The accelerator, as an NXsource: the facility and the pulse reference.

    Under `NXinstrument`, which is the only place `NXsource` is allowed; a group of
    this class directly under `NXentry` does not validate. NXsource extends
    NXcomponent, so it is placed like one -- at the origin, since the accelerator is
    not somewhere the instrument's frames reach.
    """
    return group(self.SOURCE_NAME, nx_class='NXsource', children=[
        *self.source_contents(), dataset('depends_on', '.')])

pulse_source_topic

pulse_source_topic() -> tuple[str, str]

Where the per-pulse reference sample is published, for an unbound binder.

Source code in src/niess/nexus/structure.py
def pulse_source_topic(self) -> tuple[str, str]:
    """Where the per-pulse reference sample is published, for an unbound binder."""
    binder = self.streams
    return (getattr(binder, 'pulse_source', None) or 'pulse',
            getattr(binder, 'chopper_topic', None) or 'choppers')

linked_log

linked_log(name: str, parameter: str, attrs: dict | None = None) -> dict

A value a run sets, as an NXlog whose datasets link to an existing one.

A chopper's speed is not a number an instrument has; it is a knob, and what the file should say is where to read it. niess.nexus decides this by folding a McCode expression and seeing whether an instrument parameter survives. Here the component names the knob it declared.

This builds an NXlog group whose datasets are link modules pointing into an NXlog published elsewhere in the file -- deep links to the value and time of one that already exists, rather than a link to the group itself. That is what lets the group carry attributes of its own, which it must when it is part of an NXtransformations chain and needs a transformation_type and a vector the original has no reason to have.

Not to be confused with the filewriter's link module, which is what the datasets inside it are; :func:niess.nexus.streams.link_specifier writes those.

.. note::

This assumes there is an NXlog elsewhere to point at -- somewhere an f144 module is publishing the parameter. Where that is not true the group has to be written directly instead, carrying its own value and time rather than links to someone else's. Nothing in niess needs that yet, and when something does this is where the choice belongs.

Source code in src/niess/nexus/structure.py
def linked_log(self, name: str, parameter: str,
               attrs: dict | None = None) -> dict:
    """A value a run sets, as an NXlog whose datasets link to an existing one.

    A chopper's speed is not a number an instrument has; it is a knob, and what the
    file should say is where to read it. `niess.nexus` decides this by folding a
    McCode expression and seeing whether an instrument parameter survives. Here the
    component names the knob it declared.

    This builds an ``NXlog`` group whose *datasets* are `link` modules pointing into
    an ``NXlog`` published elsewhere in the file -- deep links to the value and time
    of one that already exists, rather than a link to the group itself. That is what
    lets the group carry attributes of its own, which it must when it is part of an
    ``NXtransformations`` chain and needs a ``transformation_type`` and a ``vector``
    the original has no reason to have.

    Not to be confused with the filewriter's ``link`` module, which is what the
    datasets inside it are; :func:`niess.nexus.streams.link_specifier` writes those.

    .. note::

       This assumes there *is* an ``NXlog`` elsewhere to point at -- somewhere an
       f144 module is publishing the parameter. Where that is not true the group has
       to be written directly instead, carrying its own value and time rather than
       links to someone else's. Nothing in niess needs that yet, and when something
       does this is where the choice belongs.
    """
    from ..nexus.streams import linked_nxlog
    return linked_nxlog(name, f'{self.nxlog_root}/{parameter}', attrs=attrs)

axis

axis(owner, key: str, knob)

Where one driven axis's numbers come from.

Every translator asks this and no translator answers it. A jaw's edge and the tank's a4 are the same question, and they used to be answered in two places -- _transformations read Motor.source directly while the aperture went through streams -- so a mode switch could only ever have covered one of them.

Source code in src/niess/nexus/structure.py
def axis(self, owner, key: str, knob):
    """Where one driven axis's numbers come from.

    Every translator asks this and no translator answers it. A jaw's edge and the
    tank's a4 are the same question, and they used to be answered in two places --
    `_transformations` read `Motor.source` directly while the aperture went through
    `streams` -- so a mode switch could only ever have covered one of them.
    """
    return self.streams.bind(owner, key, self.as_motor(knob))

as_motor

as_motor(knob)

A knob as a Motor, whichever spelling it arrived in.

Source code in src/niess/nexus/structure.py
def as_motor(self, knob):
    """A knob as a Motor, whichever spelling it arrived in."""
    from ..components.motor import Motor
    if isinstance(knob, Motor):
        return knob
    return self.instrument_parameter_to_motor(knob)

place

place(node: dict) -> str

Put a node directly under NXinstrument, and say where it went.

For the things that are neither components nor children of one: an NXpositioner for a motorised frame has to sit here, because NXcomponent does not accept one and the frame it drives is an NXcomponent. NXinstrument does accept one, which is the whole reason this can be this simple.

Deliberately not emit: that registers a name in the flow graph, and a positioner is not in the beam.

Source code in src/niess/nexus/structure.py
def place(self, node: dict) -> str:
    """Put a node directly under NXinstrument, and say where it went.

    For the things that are neither components nor children of one: an
    `NXpositioner` for a motorised frame has to sit here, because `NXcomponent`
    does not accept one and the frame it drives is an `NXcomponent`. `NXinstrument`
    does accept one, which is the whole reason this can be this simple.

    Deliberately not `emit`: that registers a name in the flow graph, and a
    positioner is not in the beam.
    """
    name = node_name(node)
    if name in self.placed:
        raise ValueError(f'{name!r} is already directly under {INSTRUMENT_PATH}')
    self.placed.add(name)
    add_child(self.instrument_group, node)
    return f'{INSTRUMENT_PATH}/{name}'

positioner_name

positioner_name(emitted: str, axis: str, knob) -> str

What to call the positioner a motorised frame hangs from.

The knob's name -- detector_tank_angle -- because that is the name a facility gives the positioner and binds its streams to. Two frames may be turned by one knob, and they cannot share a positioner because the chain each hangs from differs, so the second one is named for its frame as well: tank_mounting_detector_tank_angle.

Source code in src/niess/nexus/structure.py
def positioner_name(self, emitted: str, axis: str, knob) -> str:
    """What to call the positioner a motorised frame hangs from.

    The knob's name -- `detector_tank_angle` -- because that is the name a facility
    gives the positioner and binds its streams to. Two frames may be turned by one
    knob, and they cannot share a positioner because the chain each hangs from
    differs, so the second one is named for its frame as well:
    `tank_mounting_detector_tank_angle`.
    """
    name = getattr(knob, 'name', None) or axis
    taken = self.placed | set(self.emitted_names.values())
    return name if name not in taken else f'{emitted}_{name}'

motor_log

motor_log(name: str, parameter: str, attrs: dict | None = None) -> dict

An NXlog for a named run-time value that is not a positioner axis.

Chopper speed and delay come through here. They are knobs, but not motor records: ESS spells a chopper as eight logs off a {root}:Spd_R-style root, which is a different canonical shape from a positioner's three and is not yet written. So these keep the simulation's own names in both modes.

Source code in src/niess/nexus/structure.py
def motor_log(self, name: str, parameter: str, attrs: dict | None = None) -> dict:
    """An NXlog for a named run-time value that is not a positioner axis.

    Chopper speed and delay come through here. They are knobs, but not motor
    records: ESS spells a chopper as eight logs off a `{root}:Spd_R`-style root,
    which is a different canonical shape from a positioner's three and is not yet
    written. So these keep the simulation's own names in both modes.
    """
    from ..nexus.streams import motor_group
    return motor_group(name=name, source=parameter,
                       topic=DEFAULT_STREAM_TOPIC, attrs=attrs)

flow

flow()

The particle flow through the instrument, built once and kept.

McCode has no way to say a beam branches, which is why the route this replaced had to be handed the real flow. A niess instrument states it -- BIFROST's tank declares ten paths leaving the sample -- so it is read off the tree here.

Source code in src/niess/nexus/structure.py
def flow(self):
    """The particle flow through the instrument, built once and kept.

    McCode has no way to say a beam branches, which is why the route this replaced
    had to be *handed* the real flow. A niess instrument states it -- BIFROST's tank
    declares ten paths leaving the sample -- so it is read off the tree here.
    """
    if self._flow is None:
        self._flow = self.instrument.to_graph()
    return self._flow

neighbours

neighbours(visit) -> tuple[list, list]

What feeds this node and what it feeds, under their emitted names.

The graph is keyed on tree paths and the file is written in emitted names, so a node the conversion did not write cannot be named. Stepping over it rather than dropping it is what keeps the chain intact: a composite that only contains things, or a component that is a simulation device rather than part of the instrument, sits in the flow without appearing in the file, and what it joins stays joined.

Source code in src/niess/nexus/structure.py
def neighbours(self, visit) -> tuple[list, list]:
    """What feeds this node and what it feeds, under their emitted names.

    The graph is keyed on tree paths and the file is written in emitted names, so a
    node the conversion did not write cannot be named. Stepping *over* it rather than
    dropping it is what keeps the chain intact: a composite that only contains
    things, or a component that is a simulation device rather than part of the
    instrument, sits in the flow without appearing in the file, and what it joins
    stays joined.
    """
    graph = self.flow()
    if visit.id not in graph:
        return [], []
    return (self._nearest_written(graph, visit.id, graph.predecessors),
            self._nearest_written(graph, visit.id, graph.successors))

stream_group

stream_group(selection: dict, name: str = 'data') -> dict

One monitor's or detector's data stream, as the instrument chose it.

Source code in src/niess/nexus/structure.py
def stream_group(self, selection: dict, name: str = 'data') -> dict:
    """One monitor's or detector's data stream, as the instrument chose it."""
    from ..nexus.streams import stream_group_from_selection
    return stream_group_from_selection(name, selection)

depends_on

depends_on(frame) -> str

What a thing in frame hangs from, as a NeXus path.

The chain is the chain of frames the tree declares. Nothing here reads an emitted instrument to work out what depends on what.

Source code in src/niess/nexus/structure.py
def depends_on(self, frame) -> str:
    """What a thing in ``frame`` hangs from, as a NeXus path.

    The chain is the chain of frames the tree declares. Nothing here reads an
    emitted instrument to work out what depends on what.
    """
    if frame is None:
        return '.'
    if frame in self.paths:
        return self.paths[frame]
    # A reference that is not a tree path is a component name -- what a Mount's
    # `relative_to` gives, as `McCodeContext.reference` reads it too. Falling
    # back to '.' left BIFROST's sample and tank mountings, which hang off the
    # primary's `sample_origin`, at the origin of the instrument.
    for key, emitted in self.emitted_names.items():
        if emitted == frame and key in self.paths:
            return self.paths[key]
    raise ValueError(f'Nothing written is called {frame!r}, which a frame hangs from')

node_name

node_name(node) -> str | None

The name of a group, dataset, or named stream node.

Source code in src/niess/nexus/nodes.py
def node_name(node) -> str | None:
    """The name of a group, dataset, or named stream node."""
    if not isinstance(node, dict):
        return None
    if 'name' in node:
        return node['name']
    return (node.get('config') or {}).get('name')

stream

stream(module: str, config: dict, attrs: dict[str, Any] | None = None) -> dict

A filewriter module directive -- ev44, da00, f144, link, ...

Source code in src/niess/nexus/nodes.py
def stream(module: str, config: dict, attrs: dict[str, Any] | None = None) -> dict:
    """A filewriter module directive -- ``ev44``, ``da00``, ``f144``, ``link``, ..."""
    node = {'module': module, 'config': config}
    attributes = _attributes(None, attrs)
    if attributes:
        node['attributes'] = attributes
    return node

load_bindings

load_bindings(path) -> dict[str, Binding]

Read a facility bindings file, such as ECDC's bindings.yaml.

This is the file ECDC compiles, together with an instrument.yaml, into the NeXus template the real instrument's filewriter uses. Each entry says which topic and source fill one log. Reading it here lets a niess file name the same streams without keeping a second copy of the answers.

Source code in src/niess/nexus/bindings.py
def load_bindings(path) -> dict[str, Binding]:
    """Read a facility bindings file, such as ECDC's ``bindings.yaml``.

    This is the file ECDC compiles, together with an ``instrument.yaml``, into the NeXus
    template the real instrument's filewriter uses. Each entry says which topic and
    source fill one log. Reading it here lets a niess file name the same streams
    without keeping a second copy of the answers.
    """
    from pathlib import Path
    import yaml
    with Path(path).open('r', encoding='utf-8') as file:
        return parse_bindings(yaml.safe_load(file) or {})

resolve_stream

resolve_stream(translation, default: dict | None = None, name: str = 'data') -> dict | None

The stream group for a component, or None if it publishes nothing.

The protocol is never chosen here. Some monitors belong on da00 histograms and some on ev44 events; which one is a property of the instrument setup, so it is read from the instrument in priority order:

  1. a METADATA "nexus_structure_stream_data" block on the component -- the escape hatch for instruments authored outside niess, emitted verbatim;
  2. a nexus_stream entry in the component's niess provenance extra, which is how a niess component records the choice made when the instrument was built;
  3. default -- the component type's established behaviour, used only when the instrument expressed no preference at all.

A component with no selection and no default gets no stream group rather than a guessed one.

Source code in src/niess/nexus/streams.py
def resolve_stream(translation, default: dict | None = None, name: str = 'data') -> dict | None:
    """The stream group for a component, or ``None`` if it publishes nothing.

    The protocol is never chosen here. Some monitors belong on ``da00`` histograms
    and some on ``ev44`` events; which one is a property of the instrument setup, so
    it is read from the instrument in priority order:

    1. a ``METADATA "nexus_structure_stream_data"`` block on the component -- the
       escape hatch for instruments authored outside niess, emitted verbatim;
    2. a ``nexus_stream`` entry in the component's niess provenance ``extra``,
       which is how a niess component records the choice made when the instrument
       was built;
    3. ``default`` -- the component type's established behaviour, used only when
       the instrument expressed no preference at all.

    A component with no selection and no default gets no stream group rather than a
    guessed one.
    """
    from json import JSONDecodeError, loads

    for metadata in translation.instance.metadata:
        if metadata.mimetype != 'application/json':
            continue
        if metadata.name != 'nexus_structure_stream_data':
            continue
        try:
            return stream_group_from_config(name, loads(metadata.value))
        except JSONDecodeError:
            continue

    selection = None
    if translation.provenance is not None:
        selection = translation.provenance.extra.get('nexus_stream')

    if selection is None:
        selection = default
    if selection is None:
        return None

    return stream_group_from_selection(name, selection)

component_body

component_body(nx_class: str, children=None, attrs=None, name=None, position=None, rotation=None) -> dict

What a translator returns: the class and contents of one component's group.

position and rotation state where the thing sits in the frame it hangs from, for the composites that have no placement of their own -- an analyzer is at its frame's origin, turned by the Bragg angle; a detector is a distance along its. Anything with a position and an orientation of its own is placed by those.

Source code in src/niess/nexus/structure.py
def component_body(nx_class: str, children=None, attrs=None, name=None,
                   position=None, rotation=None) -> dict:
    """What a translator returns: the class and contents of one component's group.

    ``position`` and ``rotation`` state where the thing sits in the frame it hangs from,
    for the composites that have no placement of their own -- an analyzer is at its
    frame's origin, turned by the Bragg angle; a detector is a distance along its.
    Anything with a position and an orientation of its own is placed by those.
    """
    return {'nx_class': nx_class, 'children': list(children or []),
            'attrs': dict(attrs or {}), 'name': name,
            'position': position, 'rotation': rotation}

emit

emit(visit: Visit, body: dict) -> None

Put one component's group into the instrument.

Refuses a name already used directly under NXinstrument. Two groups with one name would be one group in the file, silently: the second replaces or merges into the first. The accelerator's NXsource is source, so a component of that name would collide with it.

Source code in src/niess/nexus/structure.py
def emit(visit: Visit, body: dict) -> None:
    """Put one component's group into the instrument.

    Refuses a name already used directly under NXinstrument. Two groups with one name
    would be one group in the file, silently: the second replaces or merges into the
    first. The accelerator's NXsource is `source`, so a component of that name would
    collide with it.
    """
    context = visit.context
    node = _placed(visit, body)
    name = node_name(node)
    if any(node_name(c) == name for c in context.instrument_group.get('children', [])):
        raise ValueError(f'{name!r} is already directly under {INSTRUMENT_PATH}; '
                         'rename the component')
    context.emitted_names[visit.id] = node_name(node)
    context.pending.append((visit, node))
    add_child(context.instrument_group, node)

to_nexus_structure

to_nexus_structure(instrument, registry=None, nxlog_root: str | None = None, streams=None, efu_monitors: bool = False) -> dict

Convert instrument to ESS NeXus Structure JSON.

streams says where a driven axis's numbers come from: SIMULATED (the default) writes the simulation's own parameter names, REAL writes the EPICS positioners the components declare. One tree, two files; see :mod:niess.nexus.bindings.

efu_monitors describes every monitor that has a cbm channel by the stream its EFU publishes -- for a simulation whose monitor rays are collected and replayed to that EFU. Otherwise a monitor describes the histogram McStas produces.

Source code in src/niess/nexus/structure.py
def to_nexus_structure(instrument, registry=None, nxlog_root: str | None = None,
                       streams=None, efu_monitors: bool = False) -> dict:
    """Convert ``instrument`` to ESS NeXus Structure JSON.

    ``streams`` says where a driven axis's numbers come from: ``SIMULATED`` (the
    default) writes the simulation's own parameter names, ``REAL`` writes the EPICS
    positioners the components declare. One tree, two files; see
    :mod:`niess.nexus.bindings`.

    ``efu_monitors`` describes every monitor that has a cbm channel by the stream its
    EFU publishes -- for a simulation whose monitor rays are collected and replayed to
    that EFU. Otherwise a monitor describes the histogram McStas produces.
    """
    from .bindings import as_streams
    context = NexusContext(
        instrument=instrument,
        nxlog_root=DEFAULT_NXLOG_ROOT if nxlog_root is None else nxlog_root,
        streams=as_streams(streams), efu_monitors=efu_monitors)
    walk(instrument, NEXUS_REGISTRY if registry is None else registry, context=context)
    # once, at the end: what feeds what is not known until everything has a name
    for visit, node in context.pending:
        for direction, names in zip(('inputs', 'outputs'), context.neighbours(visit)):
            if names:
                # Datasets, not attributes. NXcomponent declares `inputs` and `outputs`
                # as fields, so every class extending it inherits them as fields, and a
                # validator reading the NXDL rejects them written any other way.
                # One name is written as a string rather than a list of one, which is
                # what the standard and every reader of these files expect.
                add_child(node, dataset(direction,
                                        names[0] if len(names) == 1 else names))
    if getattr(instrument, 'origin', None):
        _rebase_on_origin(context.instrument_group, instrument.origin)
    entry = group('entry', nx_class='NXentry',
                  children=[context.instrument_group])
    return {'children': [entry]}

translator

translator(*classes)

Register a function returning a component body for one or more niess classes.

Source code in src/niess/nexus/structure.py
def translator(*classes):
    """Register a function returning a component body for one or more niess classes."""
    def decorate(func):
        def run(visit: Visit):
            body = func(visit)
            if body is not None:
                emit(visit, body)
            return body

        holder = type(func.__name__, (), {'leaf': staticmethod(run),
                                          '__doc__': func.__doc__})
        for klass in classes:
            NEXUS_REGISTRY.register(klass)(holder)
        return func

    return decorate

bifrost

BIFROST's own NeXus translators, from the tree.

Instrument-specific, and kept off the shared registry: Triplet and Analyzer are niess classes another instrument could reuse, and importing the NeXus target must not give another instrument's detectors BIFROST's pixel numbering. Ask for BIFROST_REGISTRY:

from niess.nexus import to_nexus_structure
from niess.nexus.bifrost import BIFROST_REGISTRY

structure = to_nexus_structure(instrument, registry=BIFROST_REGISTRY)

Importing this module is what registers them, which is why the package __init__ does not do it for you.

Functions:

  • bifrost_detector_source –

    Which triplet an ev44 message came from, as the source string ICD 01 uses.

  • icd_pixel –

    Pixel id per ICD 01 v6; position runs from 0 to resolution - 1.

  • arc_and_triplet –

    Which arc and which triplet, from where the thing sits in the instrument.

  • register_bifrost –

    BIFROST's analyzers and detectors, read off the objects.

bifrost_detector_source

bifrost_detector_source(arc, triplet) -> str

Which triplet an ev44 message came from, as the source string ICD 01 uses.

Source code in src/niess/nexus/bifrost.py
def bifrost_detector_source(arc, triplet) -> str:
    """Which triplet an ev44 message came from, as the source string ICD 01 uses."""
    return f'arc={arc};triplet={triplet}'

icd_pixel

icd_pixel(resolution, arc, triplet, tube, position)

Pixel id per ICD 01 v6; position runs from 0 to resolution - 1.

Source code in src/niess/nexus/bifrost.py
def icd_pixel(resolution, arc, triplet, tube, position):
    """Pixel id per ICD 01 v6; ``position`` runs from 0 to ``resolution - 1``."""
    return 27 * resolution * arc + 9 * resolution * tube + resolution * triplet + position + 1

arc_and_triplet

arc_and_triplet(visit: Visit) -> tuple[int, int]

Which arc and which triplet, from where the thing sits in the instrument.

niess.nexus matches a regex against a generated WHEN clause for this, because an emitted McStas instrument is all it has. The tree knows: a triplet is the arm it belongs to, in the channel that arm belongs to.

Source code in src/niess/nexus/bifrost.py
def arc_and_triplet(visit: Visit) -> tuple[int, int]:
    """Which arc and which triplet, from where the thing sits in the instrument.

    `niess.nexus` matches a regex against a generated ``WHEN`` clause for this, because
    an emitted McStas instrument is all it has. The tree knows: a triplet is the arm it
    belongs to, in the channel that arm belongs to.
    """
    from ..bifrost.arm import Arm
    from ..bifrost.channel import Channel
    arm, channel = visit.ancestor(Arm), visit.ancestor(Channel)
    return (0 if arm is None else arm.index, 0 if channel is None else channel.index)

register_bifrost

register_bifrost() -> None

BIFROST's analyzers and detectors, read off the objects.

Source code in src/niess/nexus/bifrost.py
def register_bifrost() -> None:
    """BIFROST's analyzers and detectors, read off the objects."""
    from ..bifrost.analyzer import Analyzer
    from ..bifrost.triplet import Triplet
    from ..components.filter import RadialFilterCollimator
    from ..components.geometry import Off, Cylinder

    def register(klass, func):
        def run(visit):
            body = func(visit)
            if body is not None:
                emit(visit, body)
            return SKIP

        BIFROST_REGISTRY.register(klass)(
            type(func.__name__, (), {'enter': staticmethod(run),
                                     'leaf': staticmethod(run),
                                     '__doc__': func.__doc__}))

    def analyzer(visit):
        """A Rowland-geometry analyzer, as a segmented NXcrystal.

        The blade count, shape and mosaic are the analyzer's own fields. The other way
        round they are McStas component parameters -- NH, zwidth, yheight, mosaic --
        read back out of a component call. Each blade is one face of the OFF geometry,
        at its own place on the Rowland circle and turned to focus, as the arm says.
        """
        import numpy as np
        obj = visit.obj
        blade = obj.central_blade
        mosaic = float(blade.mosaic.to(unit='arcminute').value)
        centres, normals, perp_q, perp_plane, _ = visit.ancestor(_arm()).obj.local_blades()
        count = len(centres)

        up = np.array([0., 1., 0.])      # the Rowland cylinder's axis
        half_width, half_height = perp_q / 2, perp_plane / 2
        vertices, faces = [], []
        for i, (centre, normal) in enumerate(zip(centres, normals)):
            along = np.cross(normal, up)  # across the blade, in the Rowland plane
            for h, w in ((-1, -1), (-1, 1), (1, 1), (1, -1)):
                # to a nanometre: the digits beyond are the arithmetic's, and differ
                # between numpy builds -- enough to change a written structure
                corner = centre + h * half_height * up + w * half_width * along
                vertices.append([round(float(v), 9) for v in corner])
            faces.append([4 * i, 4 * i + 1, 4 * i + 2, 4 * i + 3])

        return component_body('NXcrystal', [
            dataset('usage', 'Bragg'),
            dataset('d_spacing', float(obj.central_blade.plane_spacing.to(unit='angstrom').value),
                    attrs={'units': 'angstrom'}),
            dataset('segment_width', float(perp_q), attrs={'units': 'm'}),
            dataset('segment_height', float(perp_plane), attrs={'units': 'm'}),
            dataset('segment_columns', count),
            dataset('segment_rows', 1),
            dataset('mosaic_horizontal', mosaic, attrs={'units': 'arcminutes'}),
            dataset('mosaic_vertical', mosaic, attrs={'units': 'arcminutes'}),
            Off(vertices, faces).to_nexus('geometry'),
        ], name=visit.emit_name('monochromator'),
           rotation=(0.0, float(visit.ancestor(_arm()).obj.analyzer_theta.value), 0.0))

    def detector(visit):
        """A triplet of He3 tubes: one shared cylinder, repositioned per pixel."""
        import numpy as np
        from scipp import vector

        obj = visit.obj
        arc, triplet = arc_and_triplet(visit)
        tubes = obj.tubes
        nj = int(tubes[0].elements)
        offsets, height, radius = obj.local_tubes()
        ni = len(offsets)
        di = np.asarray(offsets)
        half_pixel = height / nj / 2
        dj = -np.linspace(-height / 2 + half_pixel, height / 2 - half_pixel, nj)
        grid_j, grid_i = np.meshgrid(dj, di)

        numbers = [[icd_pixel(nj, arc, triplet, tube, position)
                    for position in range(nj)] for tube in range(ni)]

        geometry = Cylinder.single({
            'length': vector([0, 2*half_pixel, 0], unit='m'),
            'radius': vector([radius, 0, 0], unit='m'),
        }).to_nexus('geometry')

        # Event streaming is what these tubes do: without an NXevent_data group the
        # filewriter has nothing to fill the detector with. Which topic and which source
        # is the facility's business rather than the tubes', so the triplet may say, and
        # then a bindings file may; unset, these have always been BIFROST's own topic
        # and an arc/triplet source.
        name = visit.emit_name('triplet')
        selection = obj.stream
        if selection is None and hasattr(visit.context.streams, 'stream'):
            selection = visit.context.streams.stream(name)
        if selection is None:
            selection = {'module': 'ev44',
                         'source': bifrost_detector_source(arc, triplet),
                         'topic': BIFROST_DETECTOR_TOPIC}
        stream = visit.context.stream_group(selection)

        return component_body('NXdetector', [
            dataset('detector_number',
                    np.array(numbers).astype('int32').tolist(), dtype='int32'),
            # straight after detector_number, which is where the other route and
            # moreniius before it put it
            stream,
            dataset('x_pixel_offset', grid_i.tolist(), dtype='double',
                    attrs={'units': 'm'}),
            dataset('y_pixel_offset', grid_j.tolist(), dtype='double',
                    attrs={'units': 'm'}),
            dataset('x_pixel_size', 2 * radius, attrs={'units': 'm'}),
            dataset('y_pixel_size', height / nj, attrs={'units': 'm'}),
            dataset('diameter', 2 * radius, attrs={'units': 'm'}),
            dataset('type', f'{ni} He3 tubes in series'),
            geometry,
        ], name=name,
           position=vector([0., 0., 1.]) * visit.ancestor(_arm()).obj.analyzer_detector_distance)

    def collimator(visit):
        obj = visit.obj
        return component_body('NXcollimator', [
            dataset('type', 'radial'),
            dataset('divergence_x',
                    float(obj.collimation_angle.to(unit='degree').value),
                    attrs={'units': 'degrees'}),
        ])

    register(Analyzer, analyzer)
    register(Triplet, detector)
    BIFROST_REGISTRY.register(RadialFilterCollimator)(
        type('collimator', (), {'leaf': staticmethod(
            lambda visit: emit(visit, collimator(visit)))}))