Weight the region by a source¶
Region says which neutrons a train passes, not how many: whether a neutron gets
through depends only on where it is in (inverse velocity, emission time), not on how
intense the source is there. So the transmitted intensity is the source intensity
integrated over the region, and the region only has to be built once.
A gridded source¶
Give the source as a histogram: the amount in each (time, inverse velocity) cell, spread evenly over it — what a Monte Carlo source or a tabulated moderator spectrum gives you. Shape it as the masks are, (time, inverse velocity), with bin edges in s and s/m.
# bin edges over the source rectangle: inverse velocity in s/m, emission time in s
corners = [u for u, _ in source.polygons[0].vertices]
inverse_velocities = np.linspace(min(corners), max(corners), 401)
times = np.linspace(0.0, 3e-3, 61)
# the amount in each (time, inverse velocity) cell -- here a stand-in that peaks early
centres = 0.5 * (times[1:] + times[:-1])
signal = np.exp(-centres / 1e-3)[:, None] * np.ones(len(inverse_velocities) - 1)
coverage = region.coverage(inverse_velocities, times)
print(coverage.shape, f'{coverage.max():.3f}')
print(f'transmitted {region.integrate(signal, inverse_velocities, times):.4f} '
f'of {signal.sum():.1f}')
coverage is the fraction of each cell the region covers, found by clipping each polygon
to each cell it reaches. integrate is the signal times the coverage, summed. Both are
exact for a histogram, where inverse_velocity_time_mask and unmasked_probability count
a partly covered cell whole.
At a monitor or the sample¶
A neutron emitted at inverse velocity a and time t reaches distance L at
t + L*a. That shear keeps areas, so arrivals carries each cell's share of the region
into arrival-time bins exactly: the density times the area between two lines of constant
arrival time.
frame = 1 / 14
arrival_times = np.linspace(0.0, frame, 715) # 0.1 ms bins over one frame
at_sample = region.arrivals(signal, inverse_velocities, times, 162.0, arrival_times,
period=frame)
profile = at_sample.sum(axis=1) # over inverse velocity
peak = arrival_times[np.argmax(profile)]
print(at_sample.shape, f'peak at {peak * 1e3:.1f} ms')
The result is (arrival time, inverse velocity), because the shear leaves inverse
velocity alone: sum the last axis for the time profile, or keep it for the wavelength as
well. period folds arrival times into one frame, as a detector's event_time_offset
does; without it, give arrival bins that reach the slowest neutron. Distance 0 gives the
emission times back.
The mean and spread in each arrival bin¶
A wavelength lookup table stores, for each arrival time at each distance, the mean
wavelength of what arrives and its spread. arrival_moments gives exactly that, one value
per arrival bin:
unfolded = np.linspace(0.10, 0.18, 801) # 0.1 ms bins, no folding
moments = region.arrival_moments(signal, inverse_velocities, times, 162.0, unfolded)
busiest = np.nanargmax(moments.amount)
print(f'busiest bin {unfolded[busiest] * 1e3:.1f} ms: '
f'{moments.wavelength[busiest]:.4f} ± {moments.sigma[busiest]:.4f} Å')
Each piece of the region contributes its own first and second moments, integrated over
the clipped polygon, rather than its cell's centre. So the mean and spread are exact for
the gridded source on any grid, and a coarse grid loses only how well it describes the
source. Without choppers, at 28.7 m from an ESS source, binning the source at 0.2 Å by
100 µs instead of tof's own 0.02 Å by 6 µs moves the mean by at most 0.002 Å.
Every neutron is given one flight path. A guide's spread of paths smears arrival times, and is not included here.
A finite beam¶
With disc apertures, which chopcal.bifrost sets by default, a Region's boundary is
where transmission falls to zero: everything inside counts as passing, including neutrons
that only the edge of the beam carries through. Inside, an edge sweeping a uniformly lit
beam ramps the transmission linearly, and transmit_weighted carries that ramp:
from chopcal import constants as c
from chopcal.lib import Chopper, beam_aperture
# the ramp is set by the angle the beam subtends at its centre, not at its inner corner
narrow = beam_aperture(c.DISK_RADIUS, c.NARROW_SLIT_HEIGHT, c.NARROW_WINDOW_WIDTH,
c.NARROW_WINDOW_HEIGHT, at='centre')
wide = beam_aperture(c.DISK_RADIUS, c.BANDWIDTH_SLIT_HEIGHT, c.BANDWIDTH_WINDOW_WIDTH,
c.BANDWIDTH_WINDOW_HEIGHT, at='centre')
ramped = [Chopper(d.speed, d.delay, d.beam, list(d.edges), d.path,
wide if name.startswith('bw') else narrow)
for name, d in chopcal.bifrost(wavelength_max=3.0).items()]
# the pulse-shaping pair and the bandwidth pair each share a housing
weighted = source.transmit_weighted(ramped, groups=[0, 0, 1, 2, 3, 3])
point = source.transmit(list(chopcal.bifrost(wavelength_max=3.0, apertures=False).values()))
print(f'support {region.area:.4e}, point beam {point.area:.4e}, '
f'weighted {weighted.integral:.4e} s^2/m')
print(f'transmitted {weighted.integrate(signal, inverse_velocities, times):.4f}')
softer = weighted.arrivals(signal, inverse_velocities, times, 162.0, arrival_times,
period=frame)
The result is a WeightedRegion, with the same coverage, integrate and arrivals;
each piece counts by the integral of its transmission rather than its area. Here the
source passes 17.2 rather than the 28.1 the support counts.
The choppers handed to transmit_weighted carry beam_aperture(..., at='centre'). The
region's edge is where the last of the beam is cleared, at the window's inner corner,
which is what chopcal.bifrost and the default at='inner' give. The ramp in between is
set by the beam centre: while an edge spans the window's height, the fraction it has
passed grows linearly at the rate the centre radius sets. Against McStas with the real
window geometry, the centre angle fits a BIFROST train's arrival times to within counting
error; the inner one leaves the tails of the profile 6-18% low.
groups says which discs share a housing. A neutron crosses those at the same place
across the beam, so a group passes the part of the beam open in all of them. For a pair
turning the same way that is the smaller of the two transmissions. For a counter-rotating
pair, like BIFROST's bandwidth discs, equal openings sweeping from opposite sides stay shut
until the edges meet at the half-way time and then open twice as fast. Different groups
multiply, as reflections in a guide scramble where across the beam a neutron is. Without
groups every disc is its own group.
The point beam, apertures=False, has its boundary where each ramp is half open. Its area
is the first-order answer, within 2.8 % of the weighted one here; the difference is where
ramps of different discs overlap.