Skip to main content

nereids_physics/
forward_scatter.rs

1//! Neutrons scattered once in a slab sample that leave through its back face.
2//!
3//! A neutron of energy `E` enters a slab of optical depth `D(E)` at normal
4//! incidence.  It scatters elastically at depth fraction `s` with probability
5//! `D_s·e^(−D(E)·s)·ds`, `D_s` the elastic optical depth, isotropically in the
6//! centre-of-mass frame of a nucleus of mass ratio `A`.  At centre-of-mass
7//! cosine `c` it leaves with energy `E'` along lab cosine `μ`,
8//!
9//! ```text
10//! E' = E·(A² + 2Ac + 1)/(A + 1)²,   μ = (1 + Ac)/√(A² + 2Ac + 1),
11//! ```
12//!
13//! and crosses the rest of the slab with transmission `e^(−D(E')·(1 − s)/μ)`.
14//! The share of incident neutrons that scatter once and leave through the back
15//! face, `c > c₀ = max(−1/A, −1)`, is `D_s·G(E)`:
16//!
17//! ```text
18//! G(E) = ½ ∫ g(D(E), D(E')/μ) dc  over c₀ < c < 1,
19//! g(a, b) = ∫₀¹ e^(−a·s − b·(1 − s)) ds = (e^(−b) − e^(−a))/(a − b).
20//! ```
21//!
22//! On a grid, `D(E')` is linear in `E'` between grid energies and equal to the
23//! value at the lowest grid energy below it.
24
25use std::fmt;
26
27/// Four-point Gauss–Legendre abscissae on `[−1, 1]`.
28const ABSCISSAE: [f64; 4] = [
29    -0.861_136_311_594_052_6,
30    -0.339_981_043_584_856_3,
31    0.339_981_043_584_856_3,
32    0.861_136_311_594_052_6,
33];
34/// Four-point Gauss–Legendre weights on `[−1, 1]`.
35const WEIGHTS: [f64; 4] = [
36    0.347_854_845_137_453_86,
37    0.652_145_154_862_546_1,
38    0.652_145_154_862_546_1,
39    0.347_854_845_137_453_86,
40];
41/// Halvings of the interval that ends at the lowest cosine `c₀`.
42const HALVINGS: i32 = 16;
43
44/// Why [`forward_yield`] refuses its inputs.
45#[derive(Debug, PartialEq)]
46pub enum ForwardYieldError {
47    /// The grid has no energies, or not one depth per energy.
48    Shape { energies: usize, depths: usize },
49    /// An energy is not finite and positive, or the energies do not descend.
50    Energies,
51    /// A depth is not finite and 0 or more.
52    Depth(f64),
53    /// The mass ratio is not finite and positive.
54    MassRatio(f64),
55}
56
57impl fmt::Display for ForwardYieldError {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            Self::Shape { energies, depths } => write!(
61                f,
62                "the forward yield needs one or more energies and one depth per energy, got \
63                 {energies} energies and {depths} depths"
64            ),
65            Self::Energies => write!(
66                f,
67                "the forward yield needs finite, positive, strictly descending energies"
68            ),
69            Self::Depth(v) => write!(f, "an optical depth must be finite and 0 or more, got {v}"),
70            Self::MassRatio(v) => write!(f, "the mass ratio must be finite and positive, got {v}"),
71        }
72    }
73}
74
75impl std::error::Error for ForwardYieldError {}
76
77/// The lowest `E'/E` of a neutron scattered once that leaves through the back
78/// face of a slab of nuclei of mass ratio `awr`, at `c = c₀`:
79/// `(A − 1)/(A + 1)` for `A` above 1, `((1 − A)/(1 + A))²` otherwise.
80#[must_use]
81pub fn lowest_exit_ratio(awr: f64) -> f64 {
82    let c = (-1.0 / awr).max(-1.0);
83    (awr * awr + 2.0 * awr * c + 1.0) / (awr + 1.0).powi(2)
84}
85
86/// `g(a, b)`, `∂g/∂a` and `∂g/∂b` for optical depths `a`, `b` of 0 or more.
87#[must_use]
88pub fn slab_escape(a: f64, b: f64) -> (f64, f64, f64) {
89    let gap = (a - b).abs();
90    let base = (-a.min(b)).exp();
91    let mean = if gap == 0.0 {
92        1.0
93    } else {
94        -(-gap).exp_m1() / gap
95    };
96    let tilted = if gap < 0.1 {
97        let mut term = 1.0;
98        let mut sum = 0.5;
99        for k in 1..14 {
100            term *= -gap / f64::from(k);
101            sum += term / f64::from(k + 2);
102        }
103        sum
104    } else {
105        (-(-gap).exp_m1() - gap * (-gap).exp()) / (gap * gap)
106    };
107    let (near, far) = (-base * (mean - tilted), -base * tilted);
108    if a <= b {
109        (base * mean, near, far)
110    } else {
111        (base * mean, far, near)
112    }
113}
114
115/// `G` on a grid, with its slopes in the optical depths.
116pub struct ForwardYield {
117    /// `G(E_j)` at each grid energy.
118    pub share: Vec<f64>,
119    /// `∂G(E_j)/∂D(E_j)` through the depth at entry.
120    pub per_entry: Vec<f64>,
121    /// `∂G(E_j)/∂D(E_k)` through the depths at exit, as `(k, slope)`.
122    pub per_exit: Vec<Vec<(usize, f64)>>,
123}
124
125/// [`ForwardYield`] at grid energies `energies_ev`, descending, with optical
126/// depths `depth` and mass ratio `awr`.
127///
128/// # Errors
129/// [`ForwardYieldError`] if there are no energies or not one depth per energy,
130/// an energy is not finite and positive or the energies do not descend
131/// strictly, a depth is not finite and 0 or more, or the mass ratio is not
132/// finite and positive.
133pub fn forward_yield(
134    energies_ev: &[f64],
135    depth: &[f64],
136    awr: f64,
137) -> Result<ForwardYield, ForwardYieldError> {
138    let n = energies_ev.len();
139    if n == 0 || depth.len() != n {
140        return Err(ForwardYieldError::Shape {
141            energies: n,
142            depths: depth.len(),
143        });
144    }
145    if !(energies_ev.iter().all(|e| e.is_finite() && *e > 0.0)
146        && energies_ev.windows(2).all(|pair| pair[1] < pair[0]))
147    {
148        return Err(ForwardYieldError::Energies);
149    }
150    if let Some(&d) = depth.iter().find(|d| !(d.is_finite() && **d >= 0.0)) {
151        return Err(ForwardYieldError::Depth(d));
152    }
153    if !(awr.is_finite() && awr > 0.0) {
154        return Err(ForwardYieldError::MassRatio(awr));
155    }
156    let grazing = (-1.0 / awr).max(-1.0);
157    let lift = (awr + 1.0).powi(2);
158    let mut out = ForwardYield {
159        share: vec![0.0; n],
160        per_entry: vec![0.0; n],
161        per_exit: vec![Vec::new(); n],
162    };
163    for j in 0..n {
164        let energy = energies_ev[j];
165        let cosine = |e: f64| (lift * e / energy - awr * awr - 1.0) / (2.0 * awr);
166        let last = (j + 1..n)
167            .take_while(|&k| cosine(energies_ev[k]) > grazing)
168            .last()
169            .unwrap_or(j);
170        let mut exits = vec![0.0; last + 2 - j];
171        let mut add = |lo: f64, hi: f64, upper: usize| {
172            let lower = (upper + 1).min(n - 1);
173            let span = energies_ev[upper] - energies_ev[lower];
174            let (mid, half) = (0.5 * (hi + lo), 0.5 * (hi - lo));
175            for (x, w) in ABSCISSAE.iter().zip(WEIGHTS) {
176                let c = mid + half * x;
177                let ratio = (awr * awr + 2.0 * awr * c + 1.0) / lift;
178                let mu = (1.0 + awr * c) / ((awr + 1.0) * ratio.sqrt());
179                let t = if span > 0.0 {
180                    (energies_ev[upper] - energy * ratio) / span
181                } else {
182                    0.0
183                };
184                let exit = (1.0 - t) * depth[upper] + t * depth[lower];
185                let (g, along_entry, along_exit) = slab_escape(depth[j], exit / mu);
186                let weight = 0.5 * half * w;
187                out.share[j] += weight * g;
188                out.per_entry[j] += weight * along_entry;
189                exits[upper - j] += weight * along_exit / mu * (1.0 - t);
190                exits[lower - j] += weight * along_exit / mu * t;
191            }
192        };
193        for k in j..last {
194            add(
195                cosine(energies_ev[k + 1]),
196                cosine(energies_ev[k]).min(1.0),
197                k,
198            );
199        }
200        let top = if last == j {
201            1.0
202        } else {
203            cosine(energies_ev[last])
204        };
205        let width = top - grazing;
206        for i in 0..HALVINGS {
207            add(
208                grazing + width * 0.5_f64.powi(i + 1),
209                grazing + width * 0.5_f64.powi(i),
210                last,
211            );
212        }
213        add(grazing, grazing + width * 0.5_f64.powi(HALVINGS), last);
214        out.per_exit[j] = exits
215            .into_iter()
216            .enumerate()
217            .filter(|&(_, s)| s != 0.0)
218            .map(|(i, s)| (j + i, s))
219            .collect();
220    }
221    Ok(out)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn simpson(f: impl Fn(f64) -> f64, lo: f64, hi: f64, steps: usize) -> f64 {
229        let h = (hi - lo) / steps as f64;
230        (0..=steps)
231            .map(|i| {
232                let weight = match i {
233                    0 => 1.0,
234                    i if i == steps => 1.0,
235                    i if i % 2 == 1 => 4.0,
236                    _ => 2.0,
237                };
238                weight * f(lo + h * i as f64)
239            })
240            .sum::<f64>()
241            * h
242            / 3.0
243    }
244
245    #[test]
246    fn the_escape_is_the_depth_integral_and_its_slopes_are_its_derivatives() {
247        for (a, b) in [
248            (0.3, 0.3),
249            (0.3, 0.3 + 1e-9),
250            (0.3, 0.35),
251            (2.0, 0.01),
252            (0.01, 40.0),
253            (25.0, 3.0),
254        ] {
255            let (g, along_a, along_b) = slab_escape(a, b);
256            let oracle = simpson(|s| (-a * s - b * (1.0 - s)).exp(), 0.0, 1.0, 4000);
257            assert!(
258                (g / oracle - 1.0).abs() <= 1e-10,
259                "{a} {b}: {g} vs {oracle}"
260            );
261            let h = 1e-6 * a.max(b);
262            let da = (slab_escape(a + h, b).0 - slab_escape(a - h, b).0) / (2.0 * h);
263            let db = (slab_escape(a, b + h).0 - slab_escape(a, b - h).0) / (2.0 * h);
264            assert!(
265                (along_a - da).abs() <= 1e-7 * g,
266                "{a} {b}: {along_a} vs {da}"
267            );
268            assert!(
269                (along_b - db).abs() <= 1e-7 * g,
270                "{a} {b}: {along_b} vs {db}"
271            );
272        }
273    }
274
275    fn grid() -> (Vec<f64>, Vec<f64>) {
276        let energies: Vec<f64> = (0..400).map(|i| 21.0 - 0.005 * f64::from(i)).collect();
277        let depth = energies
278            .iter()
279            .map(|e| 0.05 + 6.0 / (1.0 + ((e - 20.2) / 0.08).powi(2)))
280            .collect();
281        (energies, depth)
282    }
283
284    #[test]
285    fn a_thin_slab_sends_the_forward_share_of_its_scatters_out_the_back() {
286        let (energies, _) = grid();
287        let depth = vec![1e-12; energies.len()];
288        for (awr, forward) in [(180.0, 0.5 * (1.0 + 1.0 / 180.0)), (0.999_167, 1.0)] {
289            let yield_ = forward_yield(&energies, &depth, awr).expect("yield");
290            for share in yield_.share {
291                assert!((share - forward).abs() <= 1e-8, "{awr}: {share}");
292            }
293        }
294    }
295
296    #[test]
297    fn the_lowest_exit_ratio_is_the_energy_at_the_lowest_cosine() {
298        assert!((lowest_exit_ratio(180.0) - 179.0 / 181.0).abs() <= 1e-15);
299        assert!((lowest_exit_ratio(0.5) - 1.0 / 9.0).abs() <= 1e-15);
300        assert_eq!(lowest_exit_ratio(1.0), 0.0);
301    }
302
303    #[test]
304    fn inputs_the_yield_does_not_describe_are_refused() {
305        let (energies, depth) = grid();
306        assert_eq!(
307            forward_yield(&[20.0], &[], 180.0).err(),
308            Some(ForwardYieldError::Shape {
309                energies: 1,
310                depths: 0
311            })
312        );
313        assert!(matches!(
314            forward_yield(&[], &[], 180.0),
315            Err(ForwardYieldError::Shape { .. })
316        ));
317        for wrong in [[20.0, 20.0], [20.0, 21.0], [20.0, f64::NAN], [20.0, 0.0]] {
318            assert_eq!(
319                forward_yield(&wrong, &[0.1, 0.1], 180.0).err(),
320                Some(ForwardYieldError::Energies)
321            );
322        }
323        let mut negative = depth.clone();
324        negative[7] = -0.1;
325        assert_eq!(
326            forward_yield(&energies, &negative, 180.0).err(),
327            Some(ForwardYieldError::Depth(-0.1))
328        );
329        for awr in [0.0, -1.0, f64::INFINITY] {
330            assert!(matches!(
331                forward_yield(&energies, &depth, awr),
332                Err(ForwardYieldError::MassRatio(_))
333            ));
334        }
335    }
336
337    #[test]
338    fn the_share_is_the_double_integral_over_depth_and_angle() {
339        for awr in [180.0, 0.999_167] {
340            share_against_double_integral(awr);
341        }
342    }
343
344    fn share_against_double_integral(awr: f64) {
345        let (energies, depth) = grid();
346        let yield_ = forward_yield(&energies, &depth, awr).expect("yield");
347        let interpolated = |e: f64| {
348            let k = energies
349                .iter()
350                .position(|&x| x < e)
351                .unwrap_or(energies.len());
352            if k == 0 {
353                return depth[0];
354            }
355            if k == energies.len() {
356                return depth[k - 1];
357            }
358            let t = (energies[k - 1] - e) / (energies[k - 1] - energies[k]);
359            (1.0 - t) * depth[k - 1] + t * depth[k]
360        };
361        for j in [0, 120, 160, 175, 230, 399] {
362            let (e, a) = (energies[j], depth[j]);
363            let inner = |c: f64| {
364                let root = (awr * awr + 2.0 * awr * c + 1.0).sqrt();
365                let mu = (1.0 + awr * c) / root;
366                let b = interpolated(e * root * root / (awr + 1.0).powi(2)) / mu;
367                if (a - b).abs() < 1e-6 {
368                    (-0.5 * (a + b)).exp()
369                } else {
370                    ((-b).exp() - (-a).exp()) / (a - b)
371                }
372            };
373            let oracle = 0.5 * simpson(inner, (-1.0 / awr).max(-1.0), 1.0, 200_000);
374            assert!(
375                (yield_.share[j] / oracle - 1.0).abs() <= 1e-6,
376                "node {j}: {} vs {oracle}",
377                yield_.share[j]
378            );
379        }
380    }
381
382    #[test]
383    fn the_slopes_are_the_derivatives_of_the_share() {
384        let awr = 180.0;
385        let (energies, depth) = grid();
386        let yield_ = forward_yield(&energies, &depth, awr).expect("yield");
387        for j in [100, 158, 170] {
388            let along = |k: usize| {
389                let h = 1e-6 * depth[k];
390                let mut up = depth.clone();
391                let mut down = depth.clone();
392                up[k] += h;
393                down[k] -= h;
394                (forward_yield(&energies, &up, awr).expect("yield").share[j]
395                    - forward_yield(&energies, &down, awr).expect("yield").share[j])
396                    / (2.0 * h)
397            };
398            let exit_at = |k: usize| {
399                yield_.per_exit[j]
400                    .iter()
401                    .find(|&&(i, _)| i == k)
402                    .map_or(0.0, |&(_, s)| s)
403            };
404            for k in j..j + 25 {
405                let analytic = exit_at(k) + if k == j { yield_.per_entry[j] } else { 0.0 };
406                assert!(
407                    (analytic - along(k)).abs() <= 1e-6 * yield_.share[j],
408                    "node {j}, depth {k}: {analytic} vs {}",
409                    along(k)
410                );
411            }
412        }
413    }
414}