1use std::fmt;
26
27const 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];
34const 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];
41const HALVINGS: i32 = 16;
43
44#[derive(Debug, PartialEq)]
46pub enum ForwardYieldError {
47 Shape { energies: usize, depths: usize },
49 Energies,
51 Depth(f64),
53 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#[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#[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
115pub struct ForwardYield {
117 pub share: Vec<f64>,
119 pub per_entry: Vec<f64>,
121 pub per_exit: Vec<Vec<(usize, f64)>>,
123}
124
125pub 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}