An ESS_butterfly source that emits only where a chopper train lets neutrons through,
using the exact transmitted region rather than a grid approximation of it.
Identification
Author: Gregory Tucker
Origin: European Spallation Source ERIC
Date: 2026
Description
What this is, next to Masked_ESS_butterfly
`Masked_ESS_butterfly` asks `chopper_inverse_velocity_time_mask` which (inverse
velocity, emission time) *bins* a neutron could pass through. That answer is quantised
twice over: a channel thinner than a bin is lost, and a bin the region only partly
covers is kept whole, so the accepted area -- and with it the `acceptance` a resampled
ray's weight is corrected by -- comes out high. The error falls only as fast as the cell
count, so halving it costs four times the memory.
This component asks `chopper_polygon_set_transmit_train` for the region itself. A
neutron emitted at inverse velocity `a` and time `t` reaches path `L` at `t + L*a`, so a
disc open on `[lower, upper]` accepts `lower <= t + L*a <= upper`: a slab between two
parallel lines. A train's acceptance is an intersection of unions of such slabs, and
intersection distributes over union, so the exact region is a union of convex polygons --
one per choice of which opening and which turn of each disc a neutron goes through.
Nothing is quantised and nothing is approximated. For a six-disc train it is typically
one polygon of five or six vertices, so the per-ray test is a handful of arithmetic.
What is given up is the pictures. `Masked_ESS_butterfly` accumulates `total`, `emitted`
and `count` on its mask grid; there is no grid here to accumulate them on, and a
triangulation is not a sensible thing to histogram into. Use `Masked_ESS_butterfly` when
those images are what you are after. What remains, and is cheaper, is two scalar
counters: how many rays were drawn and how many landed in the region. Their ratio is the
acceptance measured from the run, which the geometry computes independently, so the two
agreeing is a real check rather than a restatement.
Parameters that mean the same thing carry the same name. `use_mask` is `use_region`,
there being no mask; `inverse_velocity_bin`, `time_bin` and `mask_grow` describe a grid
and are gone; `save_mask`, `save_total`, `save_emitted`, `save_count` and `verify_count`
are replaced by `save_polygons` and `verify_acceptance`.
Describing the train
`choppers` is a `chopper_parameters` array reinterpreted as `double *`, which is the
only pointer a McStas SETTING PARAMETER can carry:
double edges[] = {-1.8, 1.8};
chopper_parameters pars[] = {{14.0, 0.02, 0.0, 2, edges, 10.0, 4.0}};
chopper_ptr = (double *) pars;
chopper_cnt = sizeof(pars) / sizeof(chopper_parameters);
`edges` is a flat, increasing list of angles in degrees, two per opening, in the disc's
own frame; `beam` is the angle on the beam path at `delay`; and `aperture` is how wide
the beam is on the disc, in degrees about its spindle, which widens every window in time
and is what anything but a pencil beam needs. See the chopper-lib README.
A guide that is not a straight line: `path_spread_fraction`
A neutron in a guide travels further than the straight line, and how much further
depends on where it bounced. The deviation is in *path*, so what it does to an arrival
time is `deviation * inverse_velocity` -- larger for a slow neutron, and nothing at all
to the inverse velocity. It therefore does not grow the region evenly; it tilts one of
the two lines bounding each slab, opening it into a wedge. `path_spread_fraction` is
that deviation as a fraction of each disc's own flight path, since it accumulates along
the way: 1e-4 is a realistic figure and 0, the default, is the straight line.
Like `aperture` it gives a *support* rather than a distribution: a ray is passed if some
path in `[path, path * (1 + path_spread_fraction)]` would have got it through, with no
weighting over which. A spread wide enough to reach from one turn of a disc into the
next is refused by chopper-lib rather than silently double-counted.
What the region is spent on: `resample`
By default a ray outside the region is ABSORBed. That is correct -- the discs would have
stopped it -- but a train that passes a percent of the plane then spends ninety-nine
percent of `ncount` on rays that die where they were born, and the run has the statistics
of one a hundredth the size.
`resample = 1` draws the excluded ray again, from inside the region, instead of throwing
it away. This is the same measurement, not an approximation, because the source samples
the two coordinates uniformly and independently: `lambda = Lmin + range * rand01()` with
`1/v = lambda V2K / 2 pi`, and `t = rand01() * tmax_multiplier * ESS_SOURCE_DURATION`.
Restricting a uniform draw to a region and multiplying the weight by that region's share
of the whole -- chopper-lib's `acceptance` -- leaves every downstream estimator unbiased.
Every emitted ray is multiplied by it, the ones that landed in the region unaided
included.
Here that share is exact: both areas are known in closed form rather than counted in
cells, so unlike the mask sampler's it neither over-estimates nor depends on a
resolution.
Two things this factor is not, both easy to reach for: it is not the transmission, which
is the allowed fraction of the *weighted* signal; and it is not recoverable from counting
rejection attempts, since `E[1/k]` is not `1/E[k]`.
The independence the argument rests on fails under time focusing, and INITIALIZE refuses
`resample` there rather than quietly biasing the answer: the emission time is then drawn
in a window centred on `tfocus_time - tfocus_dist / vz`, which moves with the neutron's
own velocity. No single factor corrects that -- but none is needed, because time focusing
already *is* this trick done exactly. The window traces a band of slope `-tfocus_dist` in
the (1/v, t) plane, which is the shape of a chopper band, and `w_tfocus` is already the
compensating weight. Set the three `tfocus_*` parameters from the pulse-shaping disc and
the source restricts itself.
Under MPI
The two counters are summed across the nodes in SAVE and the file is written by master
alone -- every node runs SAVE, and they all share one output directory, so an ungated
write would put one copy per node into the file. The acceptance is not summed and must
not be: it comes from the region's geometry, so every node has the same number already.
Nor is anything divided by the node count, because the counters count draws rather than
carrying weights.
The counters are accumulated atomically, so OpenACC threads are safe too -- but they are
two addresses every thread contends for, which a GPU feels more than a grid's worth of
cells. Set `verify_acceptance=0` if that shows up in a profile; nothing else depends on
them.
What the region has to span
Neither of the two coordinates is read straight off the ray, and getting either wrong
absorbs the beam rather than shaping it.
Under time focusing the emission time is drawn in a `tfocus_width` window whose centre
slides with the neutron's inverse velocity, so the band it sweeps is
`tfocus_dist * inverse_velocity_range` longer than the window; a region sized by
`tfocus_width` alone sits around the slowest neutron's window and nowhere near the rest.
And ESS_butterfly adds the offset of a randomly chosen pulse to `t` before this component
sees it, so with `n_pulses > 1` what arrives is not an emission time at all. The sampled
rectangle is built over the whole swept band clipped to the pulse, and the pulse offset
is taken back off in TRACE -- which needs the emission window to be shorter than the gap
between pulses, so INITIALIZE checks that too.
Output
`save_polygons=1` writes `{filename}.json`: the sampled rectangle, the transmitted area,
the acceptance, the inverse velocity bands, and every polygon's vertices, each number
with enough digits to read back bit-exact. A few hundred bytes rather than the few
megabytes a mask of any useful resolution costs.
Input parameters
Parameters in boldface are required;
the others are optional.
Name
Unit
Description
Default
choppers
chopper_parameters,...
information about the chopper train (see above)
chopper_count
1
the number of choppers described
filename
str
the base name of any output file, replaced by NAME_CURRENT_COMP if missing
0
path_spread_fraction
1
extra flight path available to a ray, as a fraction of each disc's own path
0.0
noise_fraction
1.
accept out-of-region rays with this probability
0.0
use_region
1
use (1) or do not use (0) the transmitted region to limit source emission
1
resample
1
draw (1/v, t) directly from the region, 100% ray emission from source
0
save_polygons
1
output the transmitted region as {filename}.json
1
verify_acceptance
1
count drawn rays and those landing in the region, and report the ratio -- the acceptance that resample=1 corrects every ray weight by, measured from the run rather than from the geometry, so the two agreeing is a real check