Narrow the wavelength band to what the choppers pass¶
A source samples uniformly across the band it is given, and a chopper train throws most of that away. Every neutron absorbed at the first chopper cost something to create, so telling the source to only sample wavelengths that can reach the sample is free simulation speed.
niess.chopcalc works that band out from the instrument itself.
from mccode_antlr import Flavor
from mccode_antlr.assembler import Assembler
from niess.chopcalc import narrow_source_wavelengths
from niess.chopcalc import train_from_instrument
from niess.instrument import Instrument, Mount
from niess.mccode import to_mccode
from niess.teaching import Primary
teaching = Instrument(name='teaching', origin='sample_origin', parts=(
Mount(name='primary', content=Primary.from_calibration()),
))
# The chopper train is read off the discs. Narrowing writes C into the instrument,
# so it needs the assembler the McStas conversion built into -- it is a McStas-side
# pass, and stays one.
assembler = Assembler('teaching', flavor=Flavor.MCSTAS)
to_mccode(teaching, assembler=assembler)
train = narrow_source_wavelengths(
assembler, chopper_train=train_from_instrument(teaching))
print(f'narrowing {train.source.lambda_min}/{train.source.lambda_max} '
f'of {train.source.name!r}')
for chopper in train.choppers:
edges = chopper.edges
openings = ', '.join(f'{low} to {high}' for low, high in zip(edges[::2], edges[1::2]))
print(f' {chopper.name:10s} {chopper.path:>8.8s} m from the source, '
f'opening at {openings} deg, beam crossing at {chopper.beam} deg')
Call it once the instrument is complete — after every section and every hand-added
component, and before writing it out. It needs the top-level Assembler: a child from
assembler.included(...) merges into its parent only when the block exits, so a section's
own choppers are not visible from inside it.
Why this is C rather than Python¶
The band depends on chopper speeds and delays, and those are run-time parameters — the
whole point of emitting chopper_rotation_speed and chopper_experiment_delay rather than numbers. So the
calculation cannot happen when the instrument is built. chopcalc emits a call to
chopper-lib into the instrument's
INITIALIZE instead, which McCode runs before every component's own initialisation.
The source therefore reads the narrowed values, and
recomputes the band without rebuilding anything. The instrument says what it did:
What it needs from the source¶
The wavelength limits have to be instrument parameters, because the generated C writes through their addresses. In a niess calibration that means giving them as parameter specifications rather than values:
'wavelength_minimum': 'source_lambda_min/"angstrom" = 0.75',
'wavelength_maximum': 'source_lambda_max/"angstrom" = 30.0',
A literal is refused, with that fix in the message.
The source is found from the beam path — it is the one component neutrons enter at, so it
is a root of the particle flow graph. That matters because a wavelength monitor is not a
source but looks like one: L_monitor, TOFLambda_monitor, DivLambda_monitor,
PolLambda_monitor and MeanPolLambda_monitor all take Lmin and Lmax. Pass
source='...' when an instrument has more than one entry point.
What it leaves out, and why that is safe¶
Leaving a chopper out of the calculation can only make the band wider, never narrower, so it never discards a neutron that would have passed. That is what makes the following conservative rather than wrong:
| left out | because |
|---|---|
a disc in a McStas GROUP with no niess provenance |
grouped discs are alternatives, not a series, and there are no slit edges to describe them with |
| a Fermi chopper | chopper-lib has no row shape that describes one |
| a disc whose speed, opening or delay is unset | there is no row to write |
One case stops the calculation altogether rather than approximating it: a chopper with
isfirst=1 re-times neutrons instead of absorbing them, so every downstream delay is
measured from a different zero and the chopper-train model does not apply.
Handing the train to a component¶
The narrowing builds its array inside a braced block in INITIALIZE. That is what keeps
chopcalc_* from colliding with anything else there, and it also means the array — and
the window arrays its rows point at — are gone before any component initialises.
A component that takes the train as a parameter needs it to last longer than that. Ask for it by name:
which adds to DECLARE
hands the train over at the end of the narrowing, and frees it in FINALLY. The count name
defaults to f'{export_choppers}_count'; pass export_chopper_count= to choose it. Both
are reported back on train.export.
The train is built on the heap whether or not anything else will read it, which is what makes the handover a pointer assignment rather than a copy. It costs one allocation per disc, and it means there is one construction path and one release — the same few lines, emitted at the end of INITIALIZE when nobody else wants the train, and in FINALLY when somebody does. Each row's slit edges go back before the row array either way, since freeing the array alone would lose every edge array with it.
Pass (double *) bifrost_choppers to a component whose own parameter is declared that
way, which is the usual shape for handing a struct array through McStas.
strict=True is worth pairing with this. The default when a band cannot be worked out is
to warn and emit nothing, which for the narrowing alone is safe — the instrument just
samples the band it was given. A component that reads the train instead gets a NULL
pointer and a count of zero, so you want the exception.
The polygon moderator¶
chopper-lib's Polygon_ESS_butterfly is the component this was made for. It works out,
at run time, the exact region of inverse velocity and emission time the train transmits,
and only emits rays inside it — so a ray the discs would absorb is never made. In niess
it is PolygonESSModerator, an ESSModerator that says which names it reads the train
under. Narrowing finds them, so nothing has to be passed:
from niess.components import PolygonESSModerator
parameters = teaching_parameters()
parameters['moderator'] = PolygonESSModerator.from_calibration(
parameters['moderator'] | {'resample': True})
teaching = Instrument(name='teaching', origin='sample_origin', parts=(
Mount(name='primary', content=Primary.from_calibration(parameters)),))
to_mccode(teaching, assembler=assembler)
narrow_source_wavelengths(assembler, train_from_instrument(teaching))
The train goes out as moderator_choppers and moderator_choppers_count, from the
moderator's name; set chopper_train to choose another. Passing a different
export_choppers is refused. Failing to publish the train raises whatever strict
says, because the component would read a pointer nobody declared.
By default a ray outside the region is absorbed, which gives exactly the result an
ESSModerator would — the same rays reach the sample. resample=True draws it again
from inside the region instead and carries the region's share of the plane in its weight,
so the whole ncount is spent on rays that can arrive. See the component's own
documentation for path_spread_fraction, for a guide that is not a straight line, and
for why resample is refused under time focusing.
Discs with several openings¶
Every disc is described to chopper-lib by its slit edges, as a chopper_parameters
row pointing at a flat, increasing array of angles in degrees, two per opening — the same
array chopper-lib's NXdisk_chopper component and the NeXus NXdisk_chopper standard use.
There is no conversion. The angles are the disc's own, measured from its top-dead-centre
mark in the order slits() gives them, and where the beam crosses the disc travels
alongside them as the row's beam field. chopper-lib 4.0.0 puts an edge at angle a on
the beam at
so {disc}delay is when beam_position is on the beam, exactly as niess means it.
Up to chopper-lib 3.0.0 the row carried beam_position - e for each edge e, with the
pair reversed, because the library had no field for the crossing and expected the caller
to fold it in. A three-opening BIFROST disc went across as (60, 80) (-50, -10)
(-280, -260) — negative, out of order, and unrecognisable as the disc the component was
given. It now goes across as the disc's own edges.
The speed keeps its sign, and chopper-lib uses it signed — reversing a disc reflects its
openings about the delay. That is invisible for a single opening centred on the beam
crossing and matters for every other one. chopper-lib 4.0.0 is the minimum and the
generated C #errors against anything older, which matters because the sign of the angle
term reversed with the rename: an older library given the new numbers would place every
opening on the wrong side of delay.
Before chopper-lib could take several openings, a multi-opening disc was approximated by its angular envelope — the span from its first opening's edge to its last — which admitted the gaps between openings too. That is gone. A disc whose openings reach right round the disc used to be dropped outright, because its envelope covered a full revolution and so constrained nothing; described by its openings it constrains properly. For the three-slit disc in the test suite, 20, 40 and 20 degrees wide, that is the difference between narrowing 0.75–30 Å to nothing at all and narrowing it to 2.20–13.08 Å.
Flight paths¶
Path lengths are walked along the beam — Instr.build_flow_graph(), then the summed
segments from the source — rather than measured straight, so a curved guide is followed
instead of cut across. A chopper's emitted AT is already the point where the beam crosses
its disc, which is what a niess offset converts to, so no further correction is needed.
If the beam has to turn sharply to arrive at a component, chopcalc says so: that usually
means the component is placed at its spindle rather than on the beam, which would also make
it absorb every neutron at run time. path_lengths={'chopper': 15.02} overrides a measured
path when the real one is known by other means.