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_guides — assembler.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'), whichESSourceturns intoDEFINE INSTRUMENTarguments.chopperspeed/chopperdelay—DiscChopperdeclares these itself.jaw_l/jaw_r—Jawdeclares 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:
- Typed fields for its calibrated properties, as scipp
Variables. from_calibration(cls, cal)pulling each field out of the dictionary, with defaults and any accepted aliases.__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, pluszero_angle/beam_anglefor a component whose centre is off the beam axis.niess/components/guide.py::EllipticGuide— emits its per-segment m-values asDECLAREd 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:@mainis 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. Useinstrument_name(assembler), which walks to the root. This is a real bug that shipped: monitors inside sections published tobifrost_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¶
- Serialisation —
to_dict/from_dictandniess.io.json, with scipp-aware equality. Register a top-level type inMODEL_ENCODE(niess/io/utils.py) to make it round-trip. - NeXus — conversion with no extra work, and custom translators when the defaults are not enough.
- CAD — a STEP assembly via
niess.brep.
Checklist¶
-
parameters.pyholds every number, as scippVariables, chained withat_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), neverassembler.name - Every
to_mccodecall passesrotate= - Top-level types registered in
MODEL_ENCODEfor JSON round-trip - Tests mirroring
tests/test_bifrost_primary.pyandtests/test_provenance_coverage.py