Skip to main content

nereids_pipeline/
beam.rs

1//! The beam before the sample: neutrons per µs of flight time.
2
3/// `ln φ(u)`, the logarithm of the beam per µs at flight time `u`, as a
4/// uniform cubic B-spline in `x = ln u` over
5/// [`knot_span_us`](Self::knot_span_us).  Faster than that span, `ln φ`
6/// continues from the spline's value and slope at the first knot with the
7/// spline's mean curvature over the span, so a beam whose curvature changes
8/// across the span is continued with its mean; slower, the last interval's
9/// cubic continues.  The curve is the beam only over the flight times a fit
10/// integrates.
11#[derive(Debug, Clone, PartialEq)]
12pub struct BeamSpline {
13    x_low: f64,
14    x_high: f64,
15    coefficients: Vec<f64>,
16}
17
18impl BeamSpline {
19    pub(crate) fn constant(u_low: f64, u_high: f64, per_us: f64) -> Self {
20        Self {
21            x_low: u_low.ln(),
22            x_high: u_high.ln(),
23            coefficients: vec![per_us.ln(); 4],
24        }
25    }
26
27    pub(crate) fn with_coefficients(&self, coefficients: &[f64]) -> Self {
28        Self {
29            coefficients: coefficients.to_vec(),
30            ..self.clone()
31        }
32    }
33
34    pub(crate) fn refined(&self) -> Self {
35        let c = &self.coefficients;
36        let coefficients = (0..2 * c.len() - 3)
37            .map(|p| {
38                let i = p / 2;
39                if p % 2 == 0 {
40                    0.5 * (c[i] + c[i + 1])
41                } else {
42                    (c[i] + 6.0 * c[i + 1] + c[i + 2]) / 8.0
43                }
44            })
45            .collect();
46        Self {
47            coefficients,
48            ..self.clone()
49        }
50    }
51
52    /// Number of spline intervals; there are three more coefficients.
53    #[must_use]
54    pub fn intervals(&self) -> usize {
55        self.coefficients.len() - 3
56    }
57
58    /// The spline's coefficients.
59    #[must_use]
60    pub fn coefficients(&self) -> &[f64] {
61        &self.coefficients
62    }
63
64    /// The flight times, in µs, the knots span.
65    #[must_use]
66    pub fn knot_span_us(&self) -> (f64, f64) {
67        (self.x_low.exp(), self.x_high.exp())
68    }
69
70    /// `(index, weight)` pairs with `ln φ(u) = Σ weight · coefficients[index]`,
71    /// for `u_us > 0`; an index may appear more than once.
72    #[must_use]
73    pub fn basis(&self, u_us: f64) -> [(usize, f64); 5] {
74        let n = self.intervals();
75        let h = (self.x_high - self.x_low) / n as f64;
76        let s = (u_us.ln() - self.x_low) / h;
77        if s < 0.0 {
78            let mean_curvature = s * s / (4.0 * n as f64);
79            return [
80                (0, 1.0 / 6.0 - s / 2.0 + mean_curvature),
81                (1, 2.0 / 3.0),
82                (2, 1.0 / 6.0 + s / 2.0 - mean_curvature),
83                (n, -mean_curvature),
84                (n + 2, mean_curvature),
85            ];
86        }
87        let first = (s.floor() as usize).min(n - 1);
88        let t = s - first as f64;
89        [
90            (first, (1.0 - t).powi(3) / 6.0),
91            (first + 1, (3.0 * t.powi(3) - 6.0 * t * t + 4.0) / 6.0),
92            (
93                first + 2,
94                (-3.0 * t.powi(3) + 3.0 * t * t + 3.0 * t + 1.0) / 6.0,
95            ),
96            (first + 3, t.powi(3) / 6.0),
97            (first, 0.0),
98        ]
99    }
100
101    /// `(index, weight)` pairs with `d ln φ/du = Σ weight · coefficients[index]`
102    /// at `u_us > 0`, in 1/µs; the derivative of [`Self::basis`].
103    #[must_use]
104    pub fn basis_slope(&self, u_us: f64) -> [(usize, f64); 5] {
105        let n = self.intervals();
106        let h = (self.x_high - self.x_low) / n as f64;
107        let ds_du = 1.0 / (h * u_us);
108        let s = (u_us.ln() - self.x_low) / h;
109        if s < 0.0 {
110            let curvature_slope = s / (2.0 * n as f64);
111            return [
112                (0, (-0.5 + curvature_slope) * ds_du),
113                (1, 0.0),
114                (2, (0.5 - curvature_slope) * ds_du),
115                (n, -curvature_slope * ds_du),
116                (n + 2, curvature_slope * ds_du),
117            ];
118        }
119        let first = (s.floor() as usize).min(n - 1);
120        let t = s - first as f64;
121        [
122            (first, -(1.0 - t).powi(2) / 2.0 * ds_du),
123            (first + 1, (3.0 * t * t - 4.0 * t) / 2.0 * ds_du),
124            (first + 2, (-3.0 * t * t + 2.0 * t + 1.0) / 2.0 * ds_du),
125            (first + 3, t * t / 2.0 * ds_du),
126            (first, 0.0),
127        ]
128    }
129
130    /// The beam per µs at flight time `u_us > 0`.
131    #[must_use]
132    pub fn per_us(&self, u_us: f64) -> f64 {
133        self.basis(u_us)
134            .iter()
135            .map(|&(i, w)| w * self.coefficients[i])
136            .sum::<f64>()
137            .exp()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn wavy() -> BeamSpline {
146        let spline = BeamSpline::constant(280.0, 470.0, 1.0).refined().refined();
147        let c: Vec<f64> = (0..spline.coefficients().len())
148            .map(|i| (i as f64 * 0.7).sin())
149            .collect();
150        spline.with_coefficients(&c)
151    }
152
153    fn ln_beam(spline: &BeamSpline, u: f64) -> f64 {
154        spline.per_us(u).ln()
155    }
156
157    #[test]
158    fn the_basis_slope_is_the_derivative_of_ln_beam() {
159        let spline = wavy();
160        for u in [250.0, 279.0, 300.0, 333.3, 400.0, 469.0, 500.0] {
161            let h = 1e-4;
162            let central = (ln_beam(&spline, u + h) - ln_beam(&spline, u - h)) / (2.0 * h);
163            let slope: f64 = spline
164                .basis_slope(u)
165                .iter()
166                .map(|&(i, w)| w * spline.coefficients()[i])
167                .sum();
168            assert!(
169                (slope - central).abs() <= 1e-8 * central.abs().max(1e-3),
170                "{u}: {slope} vs {central}"
171            );
172        }
173    }
174
175    #[test]
176    fn the_coefficients_of_a_cubic_in_ln_u_reproduce_it() {
177        let (u_low, u_high) = (280.0, 470.0);
178        let [a, b, c, d] = [3.0, -0.7, 1.9, -2.3];
179        let coefficients = [-1.0, 0.0, 1.0, 2.0]
180            .map(|k: f64| a + b * k + c * (k * k - 1.0 / 3.0) + d * (k.powi(3) - k));
181        let beam = BeamSpline::constant(u_low, u_high, 1.0).with_coefficients(&coefficients);
182        for step in 0..=40 {
183            let u = u_low * (u_high / u_low).powf(f64::from(step) / 40.0);
184            let t = (u / u_low).ln() / (u_high / u_low).ln();
185            let expected = a + b * t + c * t * t + d * t.powi(3);
186            assert!((ln_beam(&beam, u) - expected).abs() < 1e-12, "{u}");
187        }
188    }
189
190    #[test]
191    fn refining_keeps_the_curve_inside_and_below_the_knots() {
192        let spline = wavy();
193        let refined = spline.refined();
194        assert_eq!(refined.intervals(), 2 * spline.intervals());
195        for k in 0..400 {
196            let u = 150.0 * (600.0_f64 / 150.0).powf(f64::from(k) / 399.0);
197            let (a, b) = (ln_beam(&spline, u), ln_beam(&refined, u));
198            assert!((a - b).abs() < 1e-12, "at {u} µs: {a} became {b}");
199        }
200    }
201
202    fn slope_at_first_node(f: &dyn Fn(f64) -> f64, nodes: [f64; 4]) -> f64 {
203        let mut d = nodes.map(f);
204        for k in 1..4 {
205            for i in (k..4).rev() {
206                d[i] = (d[i] - d[i - 1]) / (nodes[i] - nodes[i - k]);
207            }
208        }
209        d[1] + (nodes[0] - nodes[1]) * (d[2] + d[3] * (nodes[0] - nodes[2]))
210    }
211
212    #[test]
213    fn below_the_knots_the_curve_continues_with_the_mean_curvature() {
214        let (x_low, x_high) = (280.0_f64.ln(), 470.0_f64.ln());
215        let mut spline = BeamSpline::constant(280.0, 470.0, 1.0);
216        for _ in 0..3 {
217            let c: Vec<f64> = (0..spline.coefficients().len())
218                .map(|i| (i as f64 * 0.7).sin())
219                .collect();
220            let wavy = spline.with_coefficients(&c);
221            let f = |x: f64| ln_beam(&wavy, x.exp());
222            let h = (x_high - x_low) / wavy.intervals() as f64;
223            let within = |end: f64, toward: f64| {
224                [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0].map(|k| end + toward * k * h)
225            };
226            let slope_low = slope_at_first_node(&f, within(x_low, 1.0));
227            let slope_high = slope_at_first_node(&f, within(x_high, -1.0));
228            let curvature = (slope_high - slope_low) / (x_high - x_low);
229            for dx in [-0.05, -0.2, -0.4] {
230                let expected = f(x_low) + slope_low * dx + curvature * dx * dx / 2.0;
231                assert!(
232                    (f(x_low + dx) - expected).abs() < 1e-9 * (1.0 + expected.abs()),
233                    "{} intervals, {dx}: {} vs {expected}",
234                    wavy.intervals(),
235                    f(x_low + dx)
236                );
237            }
238            spline = spline.refined();
239        }
240    }
241}