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    /// The beam per µs at flight time `u_us > 0`.
102    #[must_use]
103    pub fn per_us(&self, u_us: f64) -> f64 {
104        self.basis(u_us)
105            .iter()
106            .map(|&(i, w)| w * self.coefficients[i])
107            .sum::<f64>()
108            .exp()
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn wavy() -> BeamSpline {
117        let spline = BeamSpline::constant(280.0, 470.0, 1.0).refined().refined();
118        let c: Vec<f64> = (0..spline.coefficients().len())
119            .map(|i| (i as f64 * 0.7).sin())
120            .collect();
121        spline.with_coefficients(&c)
122    }
123
124    fn ln_beam(spline: &BeamSpline, u: f64) -> f64 {
125        spline.per_us(u).ln()
126    }
127
128    #[test]
129    fn the_coefficients_of_a_cubic_in_ln_u_reproduce_it() {
130        let (u_low, u_high) = (280.0, 470.0);
131        let [a, b, c, d] = [3.0, -0.7, 1.9, -2.3];
132        let coefficients = [-1.0, 0.0, 1.0, 2.0]
133            .map(|k: f64| a + b * k + c * (k * k - 1.0 / 3.0) + d * (k.powi(3) - k));
134        let beam = BeamSpline::constant(u_low, u_high, 1.0).with_coefficients(&coefficients);
135        for step in 0..=40 {
136            let u = u_low * (u_high / u_low).powf(f64::from(step) / 40.0);
137            let t = (u / u_low).ln() / (u_high / u_low).ln();
138            let expected = a + b * t + c * t * t + d * t.powi(3);
139            assert!((ln_beam(&beam, u) - expected).abs() < 1e-12, "{u}");
140        }
141    }
142
143    #[test]
144    fn refining_keeps_the_curve_inside_and_below_the_knots() {
145        let spline = wavy();
146        let refined = spline.refined();
147        assert_eq!(refined.intervals(), 2 * spline.intervals());
148        for k in 0..400 {
149            let u = 150.0 * (600.0_f64 / 150.0).powf(f64::from(k) / 399.0);
150            let (a, b) = (ln_beam(&spline, u), ln_beam(&refined, u));
151            assert!((a - b).abs() < 1e-12, "at {u} µs: {a} became {b}");
152        }
153    }
154
155    fn slope_at_first_node(f: &dyn Fn(f64) -> f64, nodes: [f64; 4]) -> f64 {
156        let mut d = nodes.map(f);
157        for k in 1..4 {
158            for i in (k..4).rev() {
159                d[i] = (d[i] - d[i - 1]) / (nodes[i] - nodes[i - k]);
160            }
161        }
162        d[1] + (nodes[0] - nodes[1]) * (d[2] + d[3] * (nodes[0] - nodes[2]))
163    }
164
165    #[test]
166    fn below_the_knots_the_curve_continues_with_the_mean_curvature() {
167        let (x_low, x_high) = (280.0_f64.ln(), 470.0_f64.ln());
168        let mut spline = BeamSpline::constant(280.0, 470.0, 1.0);
169        for _ in 0..3 {
170            let c: Vec<f64> = (0..spline.coefficients().len())
171                .map(|i| (i as f64 * 0.7).sin())
172                .collect();
173            let wavy = spline.with_coefficients(&c);
174            let f = |x: f64| ln_beam(&wavy, x.exp());
175            let h = (x_high - x_low) / wavy.intervals() as f64;
176            let within = |end: f64, toward: f64| {
177                [0.0, 1.0 / 3.0, 2.0 / 3.0, 1.0].map(|k| end + toward * k * h)
178            };
179            let slope_low = slope_at_first_node(&f, within(x_low, 1.0));
180            let slope_high = slope_at_first_node(&f, within(x_high, -1.0));
181            let curvature = (slope_high - slope_low) / (x_high - x_low);
182            for dx in [-0.05, -0.2, -0.4] {
183                let expected = f(x_low) + slope_low * dx + curvature * dx * dx / 2.0;
184                assert!(
185                    (f(x_low + dx) - expected).abs() < 1e-9 * (1.0 + expected.abs()),
186                    "{} intervals, {dx}: {} vs {expected}",
187                    wavy.intervals(),
188                    f(x_low + dx)
189                );
190            }
191            spline = spline.refined();
192        }
193    }
194}