Skip to content

Build a new instrument submodule

This guide builds niess.teaching, a complete but deliberately small instrument: a moderator, two guide units in their own section, a chopper, a jaw, a monitor and a sample position. It ships with niess, so you can read every file referenced here, and it is assembled and converted by the test suite, so none of it can quietly stop working.

niess.bifrost is the real-world example. It is the same patterns at 358 components, which is why this guide does not start there.

What you are writing

src/niess/teaching/
├── __init__.py       what the submodule exports
├── parameters.py     the numbers: one calibration dictionary per section
└── primary.py        the structure: which components, in what order

The split matters. parameters.py holds measured quantities and nothing else; primary.py holds structure and nothing else. A recalibration touches only the first, and a design change touches only the second.

1. Declare the structure

A Section is an ordered list of typed fields. There is no method to write:

class Guides(Section):
    """Two straight guide units.

    Being its own ``Section`` puts these into a nested instrument: ``to_mccode``
    wraps them in ``assembler.included('teaching_guides')``, so the emitted McStas is a
    ``%include`` rather than two more lines in the top-level TRACE.
    """
    unit_1: StraightGuide
    unit_2: StraightGuide


class Primary(Section):
    """The whole teaching instrument, from moderator to sample position."""
    source: ESSource
    guides: Guides
    chopper: DiscChopper
    jaw: Jaw
    monitor: FissionChamber
    sample_origin: Component

    # Emit into the caller's assembler rather than nesting the whole instrument inside
    # itself. Underscored fields are Section extras rather than components, so they are
    # invisible to parts()/types()/items(); this one is declared last only because it
    # has a default, and msgspec requires those after the fields that do not.
    _flat: bool = True

Two rules that are easy to get wrong and quiet when you do:

Declaration order is beam order

Section.from_calibration constructs its fields positionally, in the order they are declared here, so the declaration order must match the physical beamline.

The calibration dictionary's key order does not matter: each field is looked up by name. The dictionaries in this package happen to be written in beam order too, which makes them easier to read against the class, but nothing enforces it.

Underscored fields are extras, not components

A field whose name starts with _ is a per-class setting rather than a component, and is invisible to parts(), types(), items() and field_types(). Add as many as your section needs. They carry defaults, so msgspec requires them after the fields that do not — which is why _flat is declared last.

_flat = True means "emit into the assembler I was given". Without it a section nests itself: Guides has no _flat, so it becomes an included sub-instrument called teaching_guidesassembler.included(f'{assembler.name}_guides').

2. Write the calibration

Every dimensional quantity is a scipp Variable with the unit the drawing uses. Nothing converts to metres here; that happens once, in __mccode__.

A component's position and orientation are its placement in the instrument coordinate system. How you arrive at them is up to you.

niess.bifrost chains them, because that is how its geometry is specified: each element so far past the one before it. Each section builder takes the position and orientation of what came before, places its own components relative to that with niess.spatial.at_relative, and returns the reference for what comes next — the direct analogue of AT (0, 0, d) RELATIVE previous:

def teaching_parameters():
    """Every number the teaching instrument needs.

    The keys must match ``Primary``'s field names; their order is irrelevant, since
    each field is looked up by name. They are written in beam order anyway, so this
    reads alongside the class declaration.
    """
    source = source_parameters()
    ref_p, ref_r = source['position'], source['orientation']

    guides, ref_p, ref_r = guide_parameters(ref_p, ref_r)
    chopper, ref_p, ref_r = chopper_parameters(ref_p, ref_r)
    jaw, ref_p, ref_r = jaw_parameters(ref_p, ref_r)
    monitor, ref_p, ref_r = monitor_parameters(ref_p, ref_r)

    return {
        'source': source,
        'guides': guides,
        'chopper': chopper,
        'jaw': jaw,
        'monitor': monitor,
        'sample_origin': {
            'name': 'sample_origin',
            'position': at_relative(ref_p, ref_r, 1000 * mm * z),
            'orientation': ref_r,
        },
    }

Because the chain is computed rather than typed, moving the guide moves everything downstream of it and no number is written twice.

Chaining is a convenience, not a requirement

If you already know where things are — from a survey, a CAD model, or an existing instrument definition — put those coordinates in the calibration directly and skip at_relative entirely:

from scipp import vector
from scipp.spatial import rotations_from_rotvecs

upright = rotations_from_rotvecs(vector([0, 0, 0.0], unit='deg'))

# Positions as surveyed, in the instrument coordinate system -- no chaining
calibration = teaching_parameters()
for name, z in (('source', 0.0), ('chopper', 6.76), ('jaw', 7.26),
                ('monitor', 7.46), ('sample_origin', 8.46)):
    calibration[name]['position'] = vector([0, 0, z], unit='m')
    calibration[name]['orientation'] = upright
# A disc chopper's position is its spindle, not the point the beam crosses it. The
# same function the chopper uses to find the beam from the spindle finds the spindle
# from the beam, negated -- so the survey stays a survey and the geometry stays in
# one place.
chopper = calibration['chopper']
calibration['chopper']['position'] -= disc_beam_offset(
    chopper['radius'], chopper['height'], beam_angle=chopper['beam_angle'])
for name, z in (('unit_1', 1.5), ('unit_2', 3.51)):
    calibration['guides'][name]['position'] = vector([0, 0, z], unit='m')
    calibration['guides'][name]['orientation'] = upright

assembler = Assembler('teaching', flavor=Flavor.MCSTAS)
Primary.from_calibration(calibration).to_mccode(assembler)

That produces the same instrument as the chained calibration, to the last bit. Chain when the geometry is genuinely specified as a chain of offsets, so that moving one element carries the rest with it; write coordinates out when they are known independently and a chain would only obscure them. The two styles can be mixed within one instrument.

Three quantities in the teaching instrument are run-time values rather than constants, and each gets there differently:

  • source_lambda_min / source_lambda_max — passed in the calibration as McCode parameter specification strings ('source_lambda_min/"angstrom" = 0.75'), which ESSource turns into DEFINE INSTRUMENT arguments.
  • chopperspeed / chopperdelayDiscChopper declares these itself.
  • jaw_l / jaw_rJaw declares these itself.

You only need ensure_runtime_line(assembler, 'name/"unit" = default') when writing your own component that needs a knob no existing class provides.

3. Assemble it

from mccode_antlr import Flavor
from mccode_antlr.assembler import Assembler
from niess.teaching import Primary

assembler = Assembler('teaching', flavor=Flavor.MCSTAS)
Primary.from_calibration().to_mccode(assembler)
instrument = assembler.instrument

That produces exactly seven McStas components and six instrument parameters. The example asserts both, so this page cannot drift from what the code does.

Writing a component

If nothing in the component reference fits, a new Component subclass is three things:

  1. Typed fields for its calibrated properties, as scipp Variables.
  2. from_calibration(cls, cal) pulling each field out of the dictionary, with defaults and any accepted aliases.
  3. __mccode__(self) returning (component_type_name, parameters) — and this is the only place units are converted, with .to(unit='m').value.

Optionally __mccode_role__() and __mccode_extra__(), which record what the thing is in the provenance metadata that the CAD and NeXus adapters dispatch on.

Contributing to the instrument around it

__mccode__ says what the component is — one COMPONENT line and its parameters. Real components often need more than that from the instrument they sit in: a knob the operator can turn, a value worth computing once at start-up, a lookup table, a flag that later components read.

Overriding to_mccode is the supported way to add those. Do the work, then delegate:

from scipp import Variable
from niess.components import Component
from niess.mccode import ensure_runtime_line, ensure_user_var

class ChoppedMonitor(Component):
    """A monitor gated on a chopper window, with a run-time delay."""
    width: Variable
    height: Variable
    frequency: Variable

    @classmethod
    def from_calibration(cls, cal: dict):
        return cls(name=cal['name'], position=cal['position'],
                   orientation=cal['orientation'], width=cal['width'],
                   height=cal['height'], frequency=cal['frequency'])

    def __mccode__(self) -> tuple[str, dict]:
        # What the component *is*: its TRACE line and parameters
        return 'TOF_monitor', {
            'xwidth': self.width.to(unit='m').value,
            'yheight': self.height.to(unit='m').value,
            'nt': 512,
            'tmin': 0,
            'tmax': f'{self.name}_window * 1e6',
            'restore_neutron': 1,
        }

    def to_mccode(self, assembler, at=None, rotate=None,
                  insert_provenance_metadata=True):
        # ...and what it needs the instrument to provide.

        # A knob the operator can turn, without editing the instrument
        ensure_runtime_line(assembler, f'{self.name}_delay/"s" = 0.0')

        # A value worth computing once at start-up rather than per neutron.
        # DECLARE gives it instrument scope; INITIALIZE fills it in.
        period = 1.0 / self.frequency.to(unit='Hz').value
        assembler.declare(f'double {self.name}_window;')
        assembler.initialize(
            f'{self.name}_window = {period} - {self.name}_delay;'
        )

        # A per-particle flag, so later components can see what this one did
        ensure_user_var(assembler, 'int', 'chopped',
                        'Set when a neutron reached the chopped monitor')

        return super().to_mccode(
            assembler, at, rotate,
            insert_provenance_metadata=insert_provenance_metadata,
        )

The helpers, and what each one writes into the generated instrument:

call lands in use it for
ensure_runtime_line(a, 'name/"unit" = default') DEFINE INSTRUMENT(...) a knob settable at run time
ensure_runtime_parameter(a, parameter) DEFINE INSTRUMENT(...) the same, from an InstrumentParameter you already hold
a.declare('double name;') DECLARE an internal variable with instrument scope
a.initialize('name = ...;') INITIALIZE computing that variable once, before the first neutron
a.declare_array('double', name, values) DECLARE a lookup table too large to inline
ensure_user_var(a, 'int', name, description) USERVARS a per-particle flag later components read
ensure_registry(a, 'owner/repo@version') the component search path a .comp McStas does not ship
a.metadata(name, mimetype, value) a METADATA block information for a downstream consumer, such as a stream configuration

All of the ensure_* helpers are idempotent by name: two instances of the same class can each ask for the same user variable or registry, and the second is a no-op. An inconsistent repeat — the same parameter name with a different default or unit — raises rather than silently picking one.

Anything derived from the component's own calibration should be computed in Python and emitted as a literal. Reserve DECLARE/INITIALIZE for values that depend on a run-time parameter, as gate_window does above, since those genuinely cannot be known until the instrument runs.

Three worked patterns in the shipped library, in increasing order of involvement:

  • niess/components/aperture.py::Jaw — declares two run-time parameters, then delegates.
  • niess/components/chopper.py::DiscChopper — the same, plus zero_angle/beam_angle for a component whose centre is off the beam axis.
  • niess/components/guide.py::EllipticGuide — emits its per-segment m-values as DECLAREd arrays when the guide is segmented.
  • niess/components/monitors.py::FrameMonitor — pulls in an external component registry and attaches stream metadata.

Composites: when one object is several components

A detector bank is one niess object and many McStas instances. So is a chopper disc whose openings are unevenly spaced: McStas' DiskChopper describes nslit identical, evenly spaced openings, so an irregular disc has to be emitted as one DiskChopper per opening.

niess ships that case as DiscChopper with several windows, and it is the shortest complete example of a composite -- a disc with one opening emits a single component, and the same class emits a group when it has more. Its calibration is the NXdisk_chopper description, so the same numbers serve McStas and NeXus:

zero_angle where the disc's reference mark sits, as an angle from the local +y axis (top_dead_center in NXdisk_chopper, and accepted under that name)
beam_angle where the beam crosses the disc, as an angle from that mark — 180 for a disc that hangs above the beam (beam_position in NXdisk_chopper)
windows the angular edges of the openings, measured from the mark

Every angle is positive counter-clockwise viewed facing +z — looking downstream — and the edges are positive and increasing, two per opening. An opening that straddles the mark at zero delay closes beyond 360 rather than wrapping round to a smaller number, so the pairs stay ordered and each width is just the difference:

from mccode_antlr import Flavor
from mccode_antlr.assembler import Assembler
from scipp import array, scalar, vector
from scipp.spatial import rotations_from_rotvecs
from niess.components import DiscChopper

disc = DiscChopper.from_calibration({
    'name': 'pack',
    'position': vector([0, 0, 5.0], unit='m'),
    'orientation': rotations_from_rotvecs(vector([0, 0, 0.0], unit='deg')),
    'radius': scalar(0.35, unit='m'),
    'height': scalar(0.06, unit='m'),
    'frequency': scalar(14.0, unit='Hz'),
    # NXdisk_chopper geometry: where the disc's reference mark sits relative to
    # +y, where the beam crosses relative to that mark, and the angular edges of
    # each opening measured from the mark -- all positive, counter-clockwise
    # facing +z. The last opening straddles the mark, so it closes beyond 360.
    'top_dead_center': scalar(15.0, unit='deg'),
    'beam_position': scalar(90.0, unit='deg'),
    'windows': array(values=[10.0, 30.0, 100.0, 140.0, 350.0, 370.0],
                     dims=['edges'], unit='deg'),
})

assembler = Assembler('chopped', flavor=Flavor.MCSTAS)
assembler.component('origin', 'Arm', at=((0, 0, 0), 'ABSOLUTE'))
disc.to_mccode(assembler, at='origin', rotate='origin')
assembler.component('sample', 'Arm', at=((0, 0, 8), 'origin'))

Three McStas components come out, sharing one packspeed and one packdelay — one physical disc, so one pair of run-time knobs — and all three in a single McStas GROUP, named after the disc.

Alternatives, not a series

The GROUP is what makes the emission correct. A DiskChopper absorbs whatever misses its slit, so three ungrouped choppers in a row would demand a neutron be inside all three openings at once and transmit essentially nothing. Grouped, they are tried in turn and the neutron passes if it clears any one of them.

Any composite that emits alternatives rather than a sequence needs the same: instance.GROUP(name), with a name derived from the object's own — instance names are unique within an instrument, so a name-derived group is unique too. Each carries its own width, and its own

delay offset: a McStas delay is when an opening's centre is at the beam, so an opening centred at 20 degrees from the mark, with the beam at 90, arrives 70 degrees of rotation later. The opening straddling the mark is centred at 360 and arrives after 90.

How long 70 degrees takes depends on the speed, and a disc that turns the other way covers the other 290 degrees to get there — neither is known until the simulation runs. So the angles are computed when the instrument is built and the arithmetic is left to the generated C, one start-up variable per opening:

double pack_slit_0_delay;
pack_slit_0_delay = packdelay + (packspeed < 0 ? 290.0 : 70.0) / (360.0 * fabs(packspeed));

An opening already at the beam skips the variable: it is there at packdelay whichever way the disc spins.

Making the group recoverable

Splitting the disc is a McStas implementation detail. A NeXus file wants one NXdisk_chopper and a CAD model wants one solid, so each instance is tagged with what an adapter needs to reverse the split:

from niess.provenance import NiessProvenance

for instance in assembler.instrument.components:
    provenance = NiessProvenance.from_instance(instance)
    if provenance is None or 'nexus_group_id' not in provenance.extra:
        continue
    print(f'{instance.name:14s} role={provenance.role:22s} '
          f'group={provenance.extra["nexus_group_id"]} '
          f'index={provenance.extra["nexus_group_index"]} '
          f'edges={provenance.extra["slit_edges"]}')
pack_slit_0    role=nexus-group-primary    group=pack index=0 edges=[10.0, 30.0]
pack_slit_1    role=nexus-group-member     group=pack index=1 edges=[100.0, 140.0]
pack_slit_2    role=nexus-group-member     group=pack index=2 edges=[350.0, 370.0]
what why
nexus_group_id which instances belong to the same physical object
nexus_group_index their order within it
slit_edges this instance's own contribution to the whole
role which instance stands for the group, and which are folded into it

Tag by explicit role rather than by position: an adapter reads the instrument as a flat list, and nothing guarantees the primary comes first.

niess.nexus ships the matching translator, registered against the component's niess source type — the first dispatch tier — so no configuration is needed. The primary instance rebuilds the disc from its siblings; the members return None, which means emit nothing. The rebuilt group takes the disc's own name, since pack_slit_0 describes how the instrument was built rather than what it contains:

from niess.nexus import find_child, get_attribute, to_nexus_structure

structure = to_nexus_structure(assembler.instrument, origin='sample')
instrument = structure['children'][0]['children'][0]

# named for the disc itself: "_slit_0" is a McStas artefact, not something a
# reader of the NeXus file should have to know about
disc_group = find_child(instrument, 'pack')
assert get_attribute(disc_group, 'NX_class') == 'NXdisk_chopper'
assert find_child(disc_group, 'slits')['config']['values'] == 3
assert find_child(disc_group, 'slit_edges')['config']['values'] == [
    10.0, 30.0, 100.0, 140.0, 350.0, 370.0,
]
assert find_child(disc_group, 'top_dead_center')['config']['values'] == 15.0
assert find_child(disc_group, 'beam_position')['config']['values'] == 90.0

# ...and the three components it was split across are gone
for index in range(3):
    assert find_child(instrument, f'pack_slit_{index}') is None

Without that translator the same instrument still converts, as the three separate discs the McStas file literally describes. Grouping is an enrichment, not a prerequisite.

niess.brep dispatches through the same three tiers, so one role builder there rebuilds the disc as a single solid for CAD from the same tags. The mechanism in full, including how to write these for your own composite, is in Write NeXus translators.

Writing your own

The shape to copy is niess/components/chopper.py::DiscChopper.to_mccode: it calls assembler.component(...) once per instance and tags each one.

Tag every instance you build by hand

Component.to_mccode tags what it emits; hand-built instances are yours to tag. Forget it and nothing fails. The instance simply becomes invisible to every adapter — missing from the STEP assembly, missing from the NeXus file — with no error to notice. tests/test_provenance_coverage.py enforces this for niess's own composites.

Two more rules for composites:

  • ensure_registry(assembler, 'owner/repo@version') for components McStas does not ship. Prefer a tag over @main: @main is not reproducible for anyone who builds your instrument later.
  • Never derive a name from assembler.name. Inside a nested section that is the section's name. Use instrument_name(assembler), which walks to the root. This is a real bug that shipped: monitors inside sections published to bifrost_curved_beam_monitor, a topic nothing subscribes to.

Always pass rotate=

to_mccode(assembler, at, rotate) defaults an omitted rotate to ABSOLUTE. A component positioned in a rotating frame but left rotated absolutely looks correct until the frame turns, and then quietly points the wrong way. This shipped too — see tests/test_bifrost_tank.py::test_elastic_monitor_is_placed_and_rotated_in_the_tank_frame.

What you get for free

  • Serialisationto_dict/from_dict and niess.io.json, with scipp-aware equality. Register a top-level type in MODEL_ENCODE (niess/io/utils.py) to make it round-trip.
  • NeXusconversion with no extra work, and custom translators when the defaults are not enough.
  • CAD — a STEP assembly via niess.brep.

Checklist

  • parameters.py holds every number, as scipp Variables, chained with at_relative
  • Section fields are in beam order and match the calibration key order
  • Underscored extras come after the component fields (msgspec requires it)
  • Hand-built instances call add_niess_metadata
  • Non-standard components call ensure_registry
  • Names derive from instrument_name(assembler), never assembler.name
  • Every to_mccode call passes rotate=
  • Top-level types registered in MODEL_ENCODE for JSON round-trip
  • Tests mirroring tests/test_bifrost_primary.py and tests/test_provenance_coverage.py