nereids_physics/continuous_doppler.rs
1//! Doppler broadening by integrating the free-gas kernel over the resonance
2//! equation.
3//!
4//! ## What is integrated
5//!
6//! SAMMY manual Eq. III B1.6/B1.7, in velocity space:
7//!
8//! ```text
9//! σ_D(E) = (1/(√π·E)) ∫ e^{−x²} w² · s(w) dx w = √E + u·x
10//! s(w) = +σ(w²) w > 0
11//! s(w) = −σ(w²) w < 0
12//! ```
13//!
14//! with `u = √(k_B T / A)` the thermal width in √eV. The `w²` weight is what
15//! removes the `1/v²` prefactor of the lab-frame convolution, so the
16//! integrand is bounded wherever `σ` is.
17//!
18//! `s` is ODD through `w = 0`: the negative-`w` half is the reflected branch,
19//! the target overtaking the neutron. It is not a correction to be dropped at
20//! low energy — it is what makes the integral correct there. SAMMY manual
21//! Sec. III.B.1: "Negative velocities are included as needed, in order to
22//! properly evaluate the integral at low values of E". The share of the
23//! kernel it carries is `erfc(√E/u)/2`, which is 24% at `√E/u = 0.5` and 7.9%
24//! at 1. [`crate::doppler`] builds the same odd extension for a sampled
25//! table.
26//!
27//! Differentiating at fixed source SPEED — the source energies do not move
28//! with `T`, only the weight on them does — turns `d/dT` of `e^{−x²}` into
29//! the same integrand times `(x² − ½)/T`. Value and derivative therefore
30//! share every panel, and a derivative converged on those panels costs one
31//! extra multiply per node rather than a second adaptive pass.
32//!
33//! ## Why there is no eligibility test
34//!
35//! Every source energy is evaluated through
36//! [`CrossSectionPlan::evaluate_one`], which sums whichever ranges cover it
37//! and dispatches SLBW, MLBW and Reich-Moore alike. So a window spanning two
38//! ranges, or a Reich-Moore evaluation, needs nothing special: the
39//! quadrature's only job is to know where the structure is, which is a
40//! question about breakpoints.
41//!
42//! This matters beyond tidiness. An earlier revision chose between this
43//! integral and the sampled table per isotope, from the working grid and the
44//! temperature. Both are moved by a fit, so the choice could flip mid-fit and
45//! σ stepped where the two methods disagreed. Selecting a method by anything
46//! a fit can vary makes the forward model discontinuous in the parameter
47//! being fitted; selecting it by what the INPUT IS cannot.
48//!
49//! ## Quadrature
50//!
51//! Gauss–Kronrod G10/K21 (QUADPACK `qk21`) with adaptive bisection: the panel
52//! with the largest error estimate is split until the total error meets the
53//! tolerance. Initial panel edges are the window ends, the zero crossing when
54//! the window reaches it, each covering range's bounds, the resonance
55//! breakpoints, and every knot of an energy-dependent scattering radius
56//! `AP(E′)` inside the window — each is a kink both rules would otherwise
57//! straddle and mis-estimate.
58//!
59//! Failures are hard, with one reported exception. A broadening that cannot
60//! converge returns an error rather than a degraded number — except when
61//! refinement has stopped helping at all, where QUADPACK `qagse` returns the
62//! accuracy achieved (`ier = 2`) instead of refining until a budget stops it.
63//! Those targets are counted on [`TierOneBroadening::roundoff_limited`], so a
64//! caller that needs the error certificate can see it was not met rather than
65//! having to assume it was.
66//!
67//! ## Not implemented here
68//!
69//! A range carrying a File-3 (MF=3) smooth background is integrated without
70//! it, because only File 2 is parsed and nothing can answer whether a range
71//! has one. Whichever change adds MF=3 parsing owes this.
72//!
73//! Below a resolved range's lower bound the dispatcher returns zero, while
74//! [`crate::doppler`] extrapolates 1/v. The two therefore disagree about a
75//! window reaching under that bound. Both are approximations of a File-3
76//! background neither can see.
77
78use std::cmp::Ordering;
79use std::collections::BinaryHeap;
80
81use nereids_endf::resonance::ResonanceData;
82use rayon::prelude::*;
83
84use crate::doppler::{DopplerError, DopplerParams, validate_doppler_grid, zero_negative_value};
85use crate::reich_moore::{CrossSectionPlan, CrossSections};
86
87/// Half-width of the kernel support in units of `u`, so the thermal window
88/// is `[(√E − 8u)², (√E + 8u)²]`. `erfc(8) ≈ 1.1e-29` of the kernel mass
89/// lies outside it, far below any tolerance the integral works to.
90pub const SUPPORT_X: f64 = 8.0;
91
92/// Relative tolerance on each target integral. Two orders below the `1e-6`
93/// relative level of the anchors and finite-difference gates downstream, so
94/// quadrature error is not what those measure.
95pub const RELATIVE_TOLERANCE: f64 = 1.0e-8;
96
97/// Absolute tolerance (barn) on each target integral, for energies where
98/// the cross-section itself is small and a relative test alone would chase
99/// noise.
100pub const ABSOLUTE_TOLERANCE_BARN: f64 = 1.0e-8;
101
102/// Absolute tolerance (barn/K) on each temperature derivative. A typical
103/// derivative is `σ/T ≈ 1e-2 barn/K`, so this sits two orders below the
104/// `1e-6` relative level its consumers work to.
105pub const ABSOLUTE_DERIVATIVE_TOLERANCE_BARN_PER_K: f64 = 1.0e-10;
106
107/// Deepest bisection allowed. A panel of width `16/2^20 ≈ 1.5e-5` in `x` is
108/// far narrower than any resonance the breakpoints did not already isolate,
109/// so reaching this means the integrand is not being resolved at all.
110pub const MAX_DEPTH: usize = 20;
111
112/// Most panels alive for one target. Real MLBW sources on real grids need
113/// tens; a limit two orders above that turns a runaway into an error
114/// instead of an out-of-memory.
115pub const MAX_ACTIVE_PANELS: usize = 4_096;
116
117/// A bisection counts as roundoff-limited when it moves the integral by less
118/// than this, relatively. QUADPACK `qagse` uses `1e-5`.
119const ROUNDOFF_VALUE_TOLERANCE: f64 = 1.0e-5;
120
121/// ...and leaves at least this fraction of the parent's error estimate.
122/// QUADPACK `qagse` uses `0.99`.
123const ROUNDOFF_ERROR_FRACTION: f64 = 0.99;
124
125/// Roundoff-limited bisections tolerated before the result is accepted at the
126/// accuracy actually achieved. QUADPACK `qagse` uses 6 for the non-extrapolated
127/// case.
128const ROUNDOFF_LIMIT: usize = 6;
129
130const SQRT_PI: f64 = 1.772_453_850_905_516;
131
132/// Breakpoint offsets from each resonance energy in units of its total
133/// width, so the Lorentzian core and both shoulders each start in their own
134/// panel rather than being discovered by refinement.
135const BREAKPOINT_WIDTHS: [f64; 5] = [-4.0, -1.0, 0.0, 1.0, 4.0];
136
137/// Gauss–Kronrod 21-point abscissae on `[−1, 1]`, non-negative half
138/// (QUADPACK `qk21`). Odd indices are the 10-point Gauss–Legendre nodes,
139/// which is what lets one set of evaluations serve both rules.
140const KRONROD_ABSCISSAE: [f64; 11] = [
141 0.995_657_163_025_808_1,
142 0.973_906_528_517_171_7,
143 0.930_157_491_355_708_2,
144 0.865_063_366_688_984_5,
145 0.780_817_726_586_416_9,
146 0.679_409_568_299_024_4,
147 0.562_757_134_668_604_7,
148 0.433_395_394_129_247_2,
149 0.294_392_862_701_460_2,
150 0.148_874_338_981_631_22,
151 0.0,
152];
153
154/// Kronrod weights matching [`KRONROD_ABSCISSAE`]; the last is the centre.
155const KRONROD_WEIGHTS: [f64; 11] = [
156 0.011_694_638_867_371_874,
157 0.032_558_162_307_964_725,
158 0.054_755_896_574_351_995,
159 0.075_039_674_810_919_96,
160 0.093_125_454_583_697_6,
161 0.109_387_158_802_297_64,
162 0.123_491_976_262_065_84,
163 0.134_709_217_311_473_34,
164 0.142_775_938_577_060_09,
165 0.147_739_104_901_338_49,
166 0.149_445_554_002_916_9,
167];
168
169/// 10-point Gauss–Legendre weights for `KRONROD_ABSCISSAE[1, 3, 5, 7, 9]`.
170const GAUSS_WEIGHTS: [f64; 5] = [
171 0.066_671_344_308_688_14,
172 0.149_451_349_150_580_6,
173 0.219_086_362_515_982_04,
174 0.269_266_719_309_996_35,
175 0.295_524_224_714_752_87,
176];
177
178/// Hard limits of the adaptive quadrature. The defaults are the module
179/// constants; a smaller budget lets a test force a limit deterministically
180/// instead of hoping to construct a pathological source.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub struct QuadratureBudget {
183 /// Deepest bisection allowed (see [`MAX_DEPTH`]).
184 pub max_depth: usize,
185 /// Most panels alive for one target (see [`MAX_ACTIVE_PANELS`]).
186 pub max_active_panels: usize,
187}
188
189impl Default for QuadratureBudget {
190 fn default() -> Self {
191 Self {
192 max_depth: MAX_DEPTH,
193 max_active_panels: MAX_ACTIVE_PANELS,
194 }
195 }
196}
197
198/// Which cross-section channel an integral broadens.
199///
200/// Transmission needs `Total`. The SAMMY ex001 reference curve is a capture
201/// cross-section, so validating against it needs `Capture`. All four come
202/// out of one evaluation of the resonance equation, so choosing among them
203/// costs nothing.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum Channel {
206 /// Total cross-section.
207 Total,
208 /// Elastic scattering.
209 Elastic,
210 /// Radiative capture.
211 Capture,
212 /// Fission.
213 Fission,
214}
215
216impl Channel {
217 fn pick(self, xs: &CrossSections) -> f64 {
218 match self {
219 Channel::Total => xs.total,
220 Channel::Elastic => xs.elastic,
221 Channel::Capture => xs.capture,
222 Channel::Fission => xs.fission,
223 }
224 }
225}
226
227/// One panel's contribution, value and temperature derivative together.
228#[derive(Debug, Clone, Copy, Default)]
229struct Integral {
230 value: f64,
231 derivative: f64,
232}
233
234/// A live panel of the adaptive quadrature, ordered by error so the worst
235/// one is refined next.
236#[derive(Debug, Clone, Copy)]
237struct Panel {
238 left: f64,
239 right: f64,
240 depth: usize,
241 value: f64,
242 derivative: f64,
243 value_error: f64,
244 derivative_error: f64,
245 /// Ordering key. `sequence` breaks ties so the heap is a total order
246 /// and the refinement path does not depend on insertion accidents.
247 priority: f64,
248 sequence: usize,
249}
250
251impl PartialEq for Panel {
252 fn eq(&self, other: &Self) -> bool {
253 self.priority.to_bits() == other.priority.to_bits() && self.sequence == other.sequence
254 }
255}
256
257impl Eq for Panel {}
258
259impl PartialOrd for Panel {
260 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
261 Some(self.cmp(other))
262 }
263}
264
265impl Ord for Panel {
266 fn cmp(&self, other: &Self) -> Ordering {
267 self.priority
268 .total_cmp(&other.priority)
269 .then_with(|| self.sequence.cmp(&other.sequence))
270 }
271}
272
273/// One converged target integral.
274#[derive(Debug, Clone, Copy)]
275struct TargetIntegral {
276 value: f64,
277 derivative: f64,
278 /// Whether refinement stopped because it had stopped helping, so the
279 /// requested error certificate was never met.
280 roundoff_limited: bool,
281 /// Whether SAMMY's rule KEPT a negative value here.
282 kept_negative: bool,
283}
284
285/// Everything one target integral needs. The quadrature is sequential
286/// within a target, so a target's result is bit-reproducible under any
287/// thread schedule.
288struct TargetContext<'plan, 'data> {
289 plan: &'plan CrossSectionPlan<'data>,
290 channel: Channel,
291 target_energy: f64,
292 target_speed: f64,
293 thermal_u: f64,
294 temperature_k: f64,
295 budget: QuadratureBudget,
296 /// Whether any quadrature node so far had a positive `σ`. These nodes
297 /// ARE the contributing unbroadened points of SAMMY's negative rule.
298 any_source_positive: std::cell::Cell<bool>,
299}
300
301impl TargetContext<'_, '_> {
302 /// Value and derivative integrands at kernel coordinate `x`.
303 ///
304 /// SAMMY Eq. III B1.6 integrates `w²·s(w)` over the source speed
305 /// `w = √E + u·x`, with
306 ///
307 /// ```text
308 /// s(w) = +σ(w²) w > 0
309 /// s(w) = −σ(w²) w < 0
310 /// ```
311 ///
312 /// so the integrand is ODD through `w = 0` and passes through zero
313 /// there. The negative-`w` half is the reflected branch: the target
314 /// overtaking the neutron. It is what makes the integral correct at low
315 /// energy, where the thermal window reaches below zero speed — SAMMY
316 /// manual Sec. III.B.1, "Negative velocities are included as needed, in
317 /// order to properly evaluate the integral at low values of E". The
318 /// sampled path builds the same extension in
319 /// [`build_extended_fgm_grid`](crate::doppler).
320 ///
321 /// `evaluate_one` is called with `w²`, which is positive whenever `w`
322 /// is non-zero, and `w == 0` returns early — so its positive-energy
323 /// assertion cannot fire for any `x`.
324 fn integrand(&self, x: f64) -> (f64, f64) {
325 let source_speed = self.target_speed + self.thermal_u * x;
326 if source_speed == 0.0 {
327 return (0.0, 0.0);
328 }
329 let source_energy = source_speed * source_speed;
330 let sigma = self.channel.pick(&self.plan.evaluate_one(source_energy));
331 if sigma > 0.0 {
332 self.any_source_positive.set(true);
333 }
334 let value = (-x * x).exp() * source_speed.signum() * source_energy * sigma
335 / (SQRT_PI * self.target_energy);
336 // Always computed, even when the caller discards it. Refining
337 // against the derivative's error too changes the panel set, and a
338 // panel set that depends on what the caller asked for would make the
339 // cross-section itself depend on it — measured at 2.9e-13 relative on
340 // 56 of 201 targets before this was removed.
341 let derivative = value * (x * x - 0.5) / self.temperature_k;
342 (value, derivative)
343 }
344
345 /// G10/K21 on `[left, right]`, returning `(kronrod, gauss)`. The Gauss
346 /// rule reuses the Kronrod nodes at odd indices, so the pair costs 21
347 /// evaluations rather than 31.
348 fn gauss_kronrod(&self, left: f64, right: f64) -> (Integral, Integral) {
349 let middle = 0.5 * (left + right);
350 let radius = 0.5 * (right - left);
351 let mut kronrod = Integral::default();
352 let mut gauss = Integral::default();
353
354 let (value, derivative) = self.integrand(middle);
355 kronrod.value += KRONROD_WEIGHTS[10] * value;
356 kronrod.derivative += KRONROD_WEIGHTS[10] * derivative;
357
358 for (i, (&node, &weight)) in KRONROD_ABSCISSAE[..10]
359 .iter()
360 .zip(&KRONROD_WEIGHTS[..10])
361 .enumerate()
362 {
363 let (value_left, derivative_left) = self.integrand(middle - radius * node);
364 let (value_right, derivative_right) = self.integrand(middle + radius * node);
365 let value = value_left + value_right;
366 let derivative = derivative_left + derivative_right;
367 kronrod.value += weight * value;
368 kronrod.derivative += weight * derivative;
369 if i % 2 == 1 {
370 let gauss_weight = GAUSS_WEIGHTS[i / 2];
371 gauss.value += gauss_weight * value;
372 gauss.derivative += gauss_weight * derivative;
373 }
374 }
375
376 let scale = |integral: Integral| Integral {
377 value: radius * integral.value,
378 derivative: radius * integral.derivative,
379 };
380 (scale(kronrod), scale(gauss))
381 }
382
383 fn evaluate_panel(&self, left: f64, right: f64, depth: usize, sequence: usize) -> Panel {
384 let (fine, coarse) = self.gauss_kronrod(left, right);
385 let value_error = (fine.value - coarse.value).abs();
386 let derivative_error = (fine.derivative - coarse.derivative).abs();
387 Panel {
388 left,
389 right,
390 depth,
391 value: fine.value,
392 derivative: fine.derivative,
393 value_error,
394 derivative_error,
395 // The derivative carries a 1/T, so comparing its raw error
396 // against the value's would make the derivative dominate the
397 // refinement order at low temperature for no reason.
398 priority: value_error.max(self.temperature_k * derivative_error),
399 sequence,
400 }
401 }
402
403 /// Initial panel edges in `x`: the window ends, every resonance
404 /// breakpoint inside it, and every `AP(E′)` knot inside it.
405 fn breakpoints(&self, data: &ResonanceData) -> Vec<f64> {
406 let speed_low = self.target_speed - SUPPORT_X * self.thermal_u;
407 let speed_high = self.target_speed + SUPPORT_X * self.thermal_u;
408 // A window reaching below zero speed covers every energy down to
409 // zero on its reflected branch, so the culling bound below must not
410 // be `speed_low²` — that would discard resonances the window
411 // actually sees.
412 let folds = speed_low < 0.0;
413 let low_energy = if folds { 0.0 } else { speed_low * speed_low };
414 let high_energy = speed_high * speed_high;
415 let mut points = vec![-SUPPORT_X, SUPPORT_X];
416 // `w²·s(w)` is continuous through `w = 0` but has a kink there, and
417 // Gauss-Kronrod converges slowly across a kink it is not told
418 // about. Make the crossing a panel boundary.
419 if folds {
420 points.push(-self.target_speed / self.thermal_u);
421 }
422 let mut push_source_energy = |source_energy: f64| {
423 if source_energy <= 0.0 {
424 return;
425 }
426 let speed = source_energy.sqrt();
427 // A folded window reaches one source energy at BOTH ±√E. When
428 // it does not fold, the reflected coordinate lands outside
429 // `±SUPPORT_X` and the filter drops it, so no test is needed.
430 for signed_speed in [speed, -speed] {
431 let coordinate = (signed_speed - self.target_speed) / self.thermal_u;
432 if coordinate > -SUPPORT_X && coordinate < SUPPORT_X {
433 points.push(coordinate);
434 }
435 }
436 };
437 // Every range the window reaches, not just the target's own. The
438 // cross-section dispatcher already evaluates each source energy
439 // with whichever range covers it, so the quadrature's only job is
440 // to know where the structure is.
441 for range in &data.ranges {
442 if !range.is_evaluable()
443 || range.energy_high < low_energy
444 || range.energy_low > high_energy
445 {
446 continue;
447 }
448 // σ can step where one range stops contributing and the next
449 // starts, so the edges are panel boundaries.
450 push_source_energy(range.energy_low);
451 push_source_energy(range.energy_high);
452 for group in &range.l_groups {
453 for resonance in &group.resonances {
454 let total_width = resonance.gn.abs()
455 + resonance.gg.abs()
456 + resonance.gfa.abs()
457 + resonance.gfb.abs();
458 // Skip resonances whose Lorentzian cannot reach the
459 // window; their breakpoints would all be clipped anyway.
460 if total_width <= 0.0
461 || resonance.energy + 4.0 * total_width < low_energy
462 || resonance.energy - 4.0 * total_width > high_energy
463 {
464 continue;
465 }
466 for multiplier in BREAKPOINT_WIDTHS {
467 push_source_energy(resonance.energy + multiplier * total_width);
468 }
469 }
470 }
471 for &(knot_energy, _) in range.ap_table.iter().flat_map(|table| &table.points) {
472 push_source_energy(knot_energy);
473 }
474 }
475 points.sort_by(f64::total_cmp);
476 points.dedup_by(|left, right| left.to_bits() == right.to_bits());
477 points
478 }
479
480 fn integrate(&self, data: &ResonanceData) -> Result<TargetIntegral, DopplerError> {
481 let points = self.breakpoints(data);
482 // The budget bounds the INITIAL panels as well as the refined ones.
483 // A source with many resonances, or a dense AP(E) table, produces
484 // its panel count from the breakpoints alone, so checking only
485 // inside the refinement loop would let a wide source allocate past
486 // the limit and — if those panels happened to converge — never
487 // consult it at all.
488 if points.len() - 1 > self.budget.max_active_panels {
489 return Err(DopplerError::PanelLimit {
490 energy_ev: self.target_energy,
491 limit: self.budget.max_active_panels,
492 });
493 }
494 let mut stalled_bisections = 0usize;
495 let mut roundoff_limited = false;
496 let mut heap = BinaryHeap::with_capacity(points.len());
497 let mut value = 0.0;
498 let mut derivative = 0.0;
499 let mut value_error = 0.0;
500 let mut derivative_error = 0.0;
501 let mut sequence = 0usize;
502 for pair in points.windows(2) {
503 let panel = self.evaluate_panel(pair[0], pair[1], 0, sequence);
504 sequence += 1;
505 value += panel.value;
506 derivative += panel.derivative;
507 value_error += panel.value_error;
508 derivative_error += panel.derivative_error;
509 heap.push(panel);
510 }
511
512 while value_error > ABSOLUTE_TOLERANCE_BARN + RELATIVE_TOLERANCE * value.abs()
513 || derivative_error
514 > ABSOLUTE_DERIVATIVE_TOLERANCE_BARN_PER_K + RELATIVE_TOLERANCE * derivative.abs()
515 {
516 if heap.len() >= self.budget.max_active_panels {
517 return Err(DopplerError::PanelLimit {
518 energy_ev: self.target_energy,
519 limit: self.budget.max_active_panels,
520 });
521 }
522 let panel = heap
523 .pop()
524 .expect("an active panel while the error is nonzero");
525 if panel.depth >= self.budget.max_depth {
526 return Err(DopplerError::DepthLimit {
527 energy_ev: self.target_energy,
528 depth: self.budget.max_depth,
529 });
530 }
531 let middle = 0.5 * (panel.left + panel.right);
532 if !(panel.left < middle && middle < panel.right) {
533 return Err(DopplerError::MidpointStagnation {
534 energy_ev: self.target_energy,
535 left: panel.left,
536 right: panel.right,
537 });
538 }
539 let children = [
540 self.evaluate_panel(panel.left, middle, panel.depth + 1, sequence),
541 self.evaluate_panel(middle, panel.right, panel.depth + 1, sequence + 1),
542 ];
543 sequence += 2;
544
545 // QUADPACK `qagse`'s roundoff detection, which this loop was
546 // missing.
547 //
548 // A bisection that leaves the value unchanged AND barely reduces
549 // the error estimate is not making progress: the two halves'
550 // error estimates have reached the floor set by floating-point
551 // cancellation, where halving an interval no longer halves its
552 // reported error. Past that point every further bisection ADDS a
553 // floor-level estimate to the running total, so the total grows
554 // with the panel count and the tolerance moves further away.
555 //
556 // Measured on the SAMMY tr165 pseudo-Al case at 1e-5 eV: the
557 // worst single panel was wrong by 2.4e-10 on a 24.8 barn answer,
558 // yet 4096 of those summed to 3.9e-7 against a 2.6e-7 tolerance.
559 // The same integral needed ~2600 panels to sit UNDER tolerance,
560 // so refining past that made the certificate worse while the
561 // answer stayed right (24.79 barn against SAMMY's 24.89).
562 //
563 // QUADPACK counts these and returns the accuracy it achieved
564 // (`ier = 2`) instead of refining until a budget stops it.
565 let children_value = children[0].value + children[1].value;
566 let children_value_error = children[0].value_error + children[1].value_error;
567 let children_derivative = children[0].derivative + children[1].derivative;
568 let children_derivative_error =
569 children[0].derivative_error + children[1].derivative_error;
570 let stalled = |parent: f64, child: f64, parent_err: f64, child_err: f64| {
571 (parent - child).abs() <= ROUNDOFF_VALUE_TOLERANCE * child.abs()
572 && child_err >= ROUNDOFF_ERROR_FRACTION * parent_err
573 };
574 if stalled(
575 panel.value,
576 children_value,
577 panel.value_error,
578 children_value_error,
579 ) && stalled(
580 panel.derivative,
581 children_derivative,
582 panel.derivative_error,
583 children_derivative_error,
584 ) {
585 stalled_bisections += 1;
586 }
587 // Running totals are corrected by the delta rather than
588 // recomputed, so the cost per bisection stays constant.
589 value += children[0].value + children[1].value - panel.value;
590 derivative += children[0].derivative + children[1].derivative - panel.derivative;
591 value_error = (value_error + children[0].value_error + children[1].value_error
592 - panel.value_error)
593 .max(0.0);
594 derivative_error =
595 (derivative_error + children[0].derivative_error + children[1].derivative_error
596 - panel.derivative_error)
597 .max(0.0);
598 heap.extend(children);
599
600 if stalled_bisections >= ROUNDOFF_LIMIT {
601 // Refining further would only inflate the summed estimate.
602 roundoff_limited = true;
603 break;
604 }
605 }
606
607 if !value.is_finite() {
608 return Err(DopplerError::NonFiniteIntegral {
609 energy_ev: self.target_energy,
610 value,
611 derivative: false,
612 });
613 }
614 if !derivative.is_finite() {
615 return Err(DopplerError::NonFiniteIntegral {
616 energy_ev: self.target_energy,
617 value: derivative,
618 derivative: true,
619 });
620 }
621
622 // SAMMY's negative-value rule, with the quadrature nodes as the
623 // contributing unbroadened points. The integrand carries
624 // `1/(√π·E)`, so `value·E` is the kernel-weighted mean of `E′·σ` in
625 // barn·eV — exactly the quantity SAMMY tests before its `/Em`.
626 if value < 0.0 {
627 let zero = zero_negative_value(value * self.target_energy, || {
628 self.any_source_positive.get()
629 });
630 if zero {
631 return Ok(TargetIntegral {
632 value: 0.0,
633 derivative: 0.0,
634 kept_negative: false,
635 roundoff_limited,
636 });
637 }
638 return Ok(TargetIntegral {
639 value,
640 derivative,
641 kept_negative: true,
642 roundoff_limited,
643 });
644 }
645 Ok(TargetIntegral {
646 value,
647 derivative,
648 kept_negative: false,
649 roundoff_limited,
650 })
651 }
652}
653
654/// A converged tier-1 broadening of one channel over a whole grid.
655#[derive(Debug, Clone, PartialEq)]
656pub struct TierOneBroadening {
657 /// Broadened cross-section at each target energy (barn).
658 pub values: Vec<f64>,
659 /// Temperature derivative at each target (barn/K). All zero when the
660 /// caller did not ask for one, because an unrequested derivative was
661 /// never converged to its own tolerance.
662 pub derivatives: Vec<f64>,
663 /// Targets whose reported value is negative: SAMMY's rule KEPT them
664 /// when broadening ran, and on the unbroadened path they are simply
665 /// the negative values of the resonance equation.
666 pub negative_values: usize,
667 /// Targets whose refinement stopped because it had stopped helping,
668 /// rather than because the requested tolerance was met.
669 ///
670 /// The value at such a target is stationary — six consecutive bisections
671 /// moved it by less than 1e-5 relative — but its error certificate was
672 /// never satisfied, and a caller that needs one must not assume it. This
673 /// is QUADPACK `qagse`'s `ier = 2` reported rather than swallowed: the
674 /// alternative is refining until a budget stops it and failing on a
675 /// value that was already correct.
676 pub roundoff_limited: usize,
677}
678
679/// Value and (optionally) temperature derivative of one channel at every
680/// target energy, under an explicit quadrature budget.
681///
682/// The kernel width `u = √(k_B T / A)` is built from the source's OWN
683/// `awr`, so the kernel width and the resonance equation can never describe
684/// different nuclides. Taking a caller-supplied [`DopplerParams`] would
685/// allow that: an AWR wrong by 0.87% moves the width by 0.43%, which is
686/// below every tolerance in this module.
687///
688/// At zero temperature, or an underflowed `u`, there is no kernel and the
689/// values are the unbroadened equation.
690///
691/// # Errors
692/// [`DopplerError`] for an invalid grid or temperature, or for a quadrature
693/// that cannot converge inside `budget`.
694pub fn broaden_with_budget(
695 energies: &[f64],
696 data: &ResonanceData,
697 temperature_k: f64,
698 channel: Channel,
699 budget: QuadratureBudget,
700) -> Result<TierOneBroadening, DopplerError> {
701 let params = DopplerParams::new(temperature_k, data.awr)?;
702 validate_doppler_grid(energies)?;
703 if energies.is_empty() {
704 return Err(DopplerError::EmptyGrid);
705 }
706 let thermal_u = params.u();
707 let plan = CrossSectionPlan::new(data);
708
709 // No kernel: the "integral" is the resonance equation itself. This sits
710 // ABOVE the gate because the gate is about which BROADENING tier to
711 // take, and at 0 K neither runs — the route gate says `Unbroadened`
712 // here, so refusing a Reich-Moore source would contradict it.
713 if temperature_k <= 0.0 || thermal_u == 0.0 {
714 let values: Vec<f64> = energies
715 .iter()
716 .map(|&energy| channel.pick(&plan.evaluate_one(energy)))
717 .collect();
718 // The unbroadened equation can itself be negative, so the count has
719 // to be taken here too rather than assumed zero.
720 let negative_values = values.iter().filter(|&&v| v < 0.0).count();
721 return Ok(TierOneBroadening {
722 values,
723 derivatives: vec![0.0; energies.len()],
724 negative_values,
725 // No kernel, no quadrature, nothing to be roundoff-limited by.
726 roundoff_limited: 0,
727 });
728 }
729
730 // Each target is an independent integral, so targets are the unit of
731 // parallelism — the common thermometry case has ONE isotope, so
732 // per-isotope parallelism would leave this serial. Results are gathered
733 // in grid order so the reported error is always the lowest-index
734 // failure, whatever order the threads finished in.
735 let results: Vec<Result<TargetIntegral, DopplerError>> = energies
736 .par_iter()
737 .map(|&target_energy| {
738 TargetContext {
739 plan: &plan,
740 channel,
741 target_energy,
742 target_speed: target_energy.sqrt(),
743 thermal_u,
744 temperature_k,
745 budget,
746 any_source_positive: std::cell::Cell::new(false),
747 }
748 .integrate(data)
749 })
750 .collect();
751 let integrals = results
752 .into_iter()
753 .collect::<Result<Vec<TargetIntegral>, DopplerError>>()?;
754 let negative_values = integrals.iter().filter(|i| i.kept_negative).count();
755 let roundoff_limited = integrals.iter().filter(|i| i.roundoff_limited).count();
756 let (values, derivatives): (Vec<f64>, Vec<f64>) = integrals
757 .into_iter()
758 .map(|integral| (integral.value, integral.derivative))
759 .unzip();
760 Ok(TierOneBroadening {
761 derivatives,
762 values,
763 negative_values,
764 roundoff_limited,
765 })
766}
767
768/// Tier-1 cross-section of one channel at every target energy.
769///
770/// # Errors
771/// As [`broaden_with_budget`].
772pub fn broaden_channel(
773 energies: &[f64],
774 data: &ResonanceData,
775 temperature_k: f64,
776 channel: Channel,
777) -> Result<Vec<f64>, DopplerError> {
778 broaden_with_budget(
779 energies,
780 data,
781 temperature_k,
782 channel,
783 QuadratureBudget::default(),
784 )
785 .map(|broadening| broadening.values)
786}
787
788/// Tier-1 total cross-section at every target energy.
789///
790/// # Errors
791/// As [`broaden_with_budget`].
792pub fn broaden(
793 energies: &[f64],
794 data: &ResonanceData,
795 temperature_k: f64,
796) -> Result<Vec<f64>, DopplerError> {
797 broaden_channel(energies, data, temperature_k, Channel::Total)
798}
799
800/// Tier-1 total cross-section and its exact temperature derivative
801/// (barn/K), both converged on the same panels.
802///
803/// # Errors
804/// As [`broaden_with_budget`].
805pub fn broaden_with_derivative(
806 energies: &[f64],
807 data: &ResonanceData,
808 temperature_k: f64,
809) -> Result<(Vec<f64>, Vec<f64>), DopplerError> {
810 broaden_with_budget(
811 energies,
812 data,
813 temperature_k,
814 Channel::Total,
815 QuadratureBudget::default(),
816 )
817 .map(|broadening| (broadening.values, broadening.derivatives))
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use crate::doppler::{DopplerParams, doppler_broaden};
824 use nereids_core::constants::BOLTZMANN_EV_PER_K;
825 use nereids_endf::parser::parse_endf_file2;
826 use nereids_endf::resonance::test_support::{
827 ex001_hydrogen_single_resonance, synthetic_swave_slbw, u238_with_formalism,
828 };
829 use nereids_endf::resonance::{Resonance, ResonanceFormalism};
830
831 /// Room temperature. For the U-238 fixtures 8u ≈ 0.083 √eV, so the
832 /// window at 6.674 eV spans about ±0.43 eV.
833 const ROOM_K: f64 = 293.6;
834
835 // ── the quadrature rule itself ─────────────────────────────────────────
836
837 /// The QUADPACK pair, checked by what defines it rather than by
838 /// restating the table: K21 integrates polynomials exactly to degree 31
839 /// and G10 to degree 19. A single mistyped digit in any node or weight
840 /// breaks this far above 1e-14.
841 #[test]
842 fn the_gauss_kronrod_constants_are_the_quadpack_pair() {
843 let kronrod_sum = 2.0 * KRONROD_WEIGHTS[..10].iter().sum::<f64>() + KRONROD_WEIGHTS[10];
844 let gauss_sum = 2.0 * GAUSS_WEIGHTS.iter().sum::<f64>();
845 assert!(
846 (kronrod_sum - 2.0).abs() < 1e-15,
847 "kronrod weights {kronrod_sum}"
848 );
849 assert!((gauss_sum - 2.0).abs() < 1e-15, "gauss weights {gauss_sum}");
850
851 for degree in 0..=31u32 {
852 let exact = if degree % 2 == 0 {
853 2.0 / f64::from(degree + 1)
854 } else {
855 0.0
856 };
857 let symmetric = |x: f64| x.powi(degree as i32) + (-x).powi(degree as i32);
858 let kronrod = KRONROD_WEIGHTS[10] * 0.0_f64.powi(degree as i32)
859 + KRONROD_ABSCISSAE[..10]
860 .iter()
861 .zip(&KRONROD_WEIGHTS[..10])
862 .map(|(&x, &w)| w * symmetric(x))
863 .sum::<f64>();
864 assert!(
865 (kronrod - exact).abs() < 1e-14,
866 "K21 degree {degree}: {kronrod} vs {exact}"
867 );
868 if degree <= 19 {
869 let gauss = (0..5)
870 .map(|k| GAUSS_WEIGHTS[k] * symmetric(KRONROD_ABSCISSAE[2 * k + 1]))
871 .sum::<f64>();
872 assert!(
873 (gauss - exact).abs() < 1e-14,
874 "G10 degree {degree}: {gauss} vs {exact}"
875 );
876 }
877 }
878 }
879
880 // ── independent quadrature oracle ──────────────────────────────────────
881
882 /// A uniform-speed trapezoid rule over the same window.
883 ///
884 /// This is an independent QUADRATURE, not an independent PHYSICS
885 /// oracle. It deliberately reuses `CrossSectionPlan::evaluate_one`, the
886 /// `(√E + u·x)²` mapping, the same `u`, and the same `E′σ/(√π E)`
887 /// weighting, so it can only catch an error in the adaptive scheme —
888 /// the panels, the error estimate, the refinement order. An error in
889 /// the kernel itself would move both sides together and pass. SAMMY
890 /// ex001 and the free-gas FWHM are what constrain the physics.
891 fn trapezoid_oracle(
892 data: &ResonanceData,
893 target_energy: f64,
894 temperature_k: f64,
895 n_points: usize,
896 ) -> (f64, f64) {
897 let dx = 2.0 * SUPPORT_X / (n_points - 1) as f64;
898 let thermal_u = (BOLTZMANN_EV_PER_K * temperature_k / data.awr).sqrt();
899 let target_speed = target_energy.sqrt();
900 let plan = CrossSectionPlan::new(data);
901 let normalization = SQRT_PI * target_energy;
902 let (mut value_sum, mut derivative_sum) = (0.0, 0.0);
903 for index in 0..n_points {
904 let x = -SUPPORT_X + index as f64 * dx;
905 let source_energy = (target_speed + thermal_u * x).powi(2);
906 let sigma = plan.evaluate_one(source_energy).total;
907 let endpoint_weight = if index == 0 || index + 1 == n_points {
908 0.5
909 } else {
910 1.0
911 };
912 let contribution =
913 endpoint_weight * (-x * x).exp() * source_energy * sigma / normalization;
914 value_sum += contribution;
915 derivative_sum += contribution * (x * x - 0.5) / temperature_k;
916 }
917 (value_sum * dx, derivative_sum * dx)
918 }
919
920 fn hf177() -> ResonanceData {
921 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
922 .parent()
923 .unwrap()
924 .parent()
925 .unwrap()
926 .join("tests/data/endf/Hf-177.endf");
927 let text = std::fs::read_to_string(&path)
928 .unwrap_or_else(|error| panic!("required Hf-177 fixture missing at {path:?}: {error}"));
929 parse_endf_file2(&text).expect("tracked Hf-177 fixture must parse")
930 }
931
932 /// Real multi-resonance MLBW data against 400,001 trapezoid points, for
933 /// BOTH the value and the temperature derivative.
934 #[test]
935 fn a_real_mlbw_source_matches_the_uniform_speed_trapezoid_oracle() {
936 let data = hf177();
937 let target_energy = 8.876_917_538_350_767_f64;
938 let temperature_k = 300.0_f64;
939 let (oracle_value, oracle_derivative) =
940 trapezoid_oracle(&data, target_energy, temperature_k, 400_001);
941
942 let (values, derivatives) =
943 broaden_with_derivative(&[target_energy], &data, temperature_k).unwrap();
944 let value_rel = (values[0] - oracle_value).abs() / oracle_value.abs();
945 let derivative_rel = (derivatives[0] - oracle_derivative).abs() / oracle_derivative.abs();
946 assert!(
947 value_rel <= 1.0e-10,
948 "value: adaptive={:.16e} trapezoid={oracle_value:.16e} rel={value_rel:.3e}",
949 values[0]
950 );
951 assert!(
952 derivative_rel <= 1.0e-10,
953 "derivative: adaptive={:.16e} trapezoid={oracle_derivative:.16e} rel={derivative_rel:.3e}",
954 derivatives[0]
955 );
956 }
957
958 // ── SAMMY ───────────────────────────────────────────────────────────────
959
960 /// SAMMY's own ex001a output, all 315 rows. Columns 1-3 of the vendored
961 /// file are SAMMY echoing its input data and match
962 /// `samexm/ex001/ex001a.dat` byte for byte; column 4 is its theory
963 /// curve, the Doppler-broadened capture cross-section at 300 K.
964 ///
965 /// ## What the residual is, and what it is not
966 ///
967 /// We sit a FLAT +0.77% above SAMMY across the resonance line and agree
968 /// to ~1e-6 in the wings. That shape is measured, not assumed, and it
969 /// rules out the obvious causes: an AWR error would give an S-shaped
970 /// residual changing sign across the peak (scanning AWR 9.90-10.00
971 /// never brings the maximum below 7.7e-3), 300 K is the optimum over
972 /// 295-305 K, and the two plausible alternative kernel weightings give
973 /// 8.1e-2 and 4.3e-2 instead of 7.7e-3.
974 ///
975 /// The sampled-table tier sits at +0.80% on the same curve. The two
976 /// tiers therefore agree with EACH OTHER to well under 0.1% while both
977 /// stand 0.8% from SAMMY, so the residual is not this integrator.
978 ///
979 /// ## Why SAMMY is the low one
980 ///
981 /// SAMMY's own run log for this case (`samexm/ex001/answers/
982 /// ex001aa.lpt`) reports `** One resonance has fewer than 9 points
983 /// across width` and `Number of points in auxiliary grid = 396`. That
984 /// is 396 points spanning 8.04-11.96 eV, about 9.9 meV apart, against a
985 /// natural width `Γ = Γn + Γγ = 1.5 meV` — SAMMY samples the Lorentzian
986 /// at roughly 0.15 points across its OWN width before convolving.
987 /// Under-sampling a narrow line loses line area, so SAMMY's broadened
988 /// curve comes out low; we place quadrature breakpoints ON the
989 /// resonance and integrate it to 1e-8, so we keep the area. A loss on
990 /// SAMMY's side is the direction and the localisation we measure: the
991 /// deficit lives in the core and the wings, where the line is
992 /// resolved, agree to 1e-4.
993 ///
994 /// So the residual is SAMMY's grid, not our kernel — but it is still
995 /// 0.77%, so it is pinned tightly rather than waved through. The
996 /// assertion below is a two-sided band on the value actually observed;
997 /// a loose tolerance would absorb a sub-0.8% physics error, which is
998 /// larger than the 0.43% kernel-width error this very branch removed.
999 ///
1000 /// The same log independently confirms the mass ratio this branch
1001 /// corrected: it prints `mass of neutron = 1.008664915600000 in amu`
1002 /// and `Dopp_FWHM` at 10 eV as 0.5378 eV, which is the width AWR
1003 /// 9.9141 gives (0.537766) and not the one AWR 10 gives (0.535451).
1004 #[test]
1005 fn the_sammy_ex001_capture_curve_is_matched_to_a_measured_residual() {
1006 let data = ex001_hydrogen_single_resonance();
1007 let (energies, reference): (Vec<f64>, Vec<f64>) =
1008 include_str!("../tests/data/sammy_ex001a_answers.lst")
1009 .lines()
1010 .filter(|line| !line.trim().is_empty())
1011 .map(|line| {
1012 let columns: Vec<f64> = line
1013 .split_whitespace()
1014 .map(|c| c.parse::<f64>().unwrap())
1015 .collect();
1016 assert_eq!(
1017 columns.len(),
1018 4,
1019 "ex001a rows are E, data, uncertainty, theory"
1020 );
1021 (columns[0], columns[3])
1022 })
1023 .unzip();
1024 assert_eq!(energies.len(), 315);
1025
1026 let ours = broaden_channel(&energies, &data, 300.0, Channel::Capture).unwrap();
1027 let (worst, max_rel) = ours
1028 .iter()
1029 .zip(&reference)
1030 .enumerate()
1031 .map(|(i, (a, b))| (i, (a - b).abs() / b))
1032 .fold((0, 0.0_f64), |acc, x| if x.1 > acc.1 { x } else { acc });
1033 eprintln!(
1034 "ex001 tier 1: max_rel={max_rel:.3e} at E={} eV (ours {} vs SAMMY {})",
1035 energies[worst], ours[worst], reference[worst]
1036 );
1037 // Two-sided: moving in EITHER direction is a change worth seeing.
1038 assert!(
1039 (7.5e-3..8.0e-3).contains(&max_rel),
1040 "max relative error {max_rel:.3e} left the measured band [7.5e-3, 8.0e-3]"
1041 );
1042
1043 // The residual is in the line, not the wings, and it is one-signed.
1044 let wing = ours
1045 .iter()
1046 .zip(&reference)
1047 .zip(&energies)
1048 .filter(|(_, e)| **e < 8.5 || **e > 11.5)
1049 .map(|((a, b), _)| (a - b).abs() / b)
1050 .fold(0.0_f64, f64::max);
1051 assert!(
1052 wing < 1.0e-4,
1053 "the wings should agree closely, got {wing:.3e}"
1054 );
1055 assert!(
1056 ours.iter().zip(&reference).all(|(a, b)| a >= b),
1057 "the residual is one-signed: we sit above SAMMY everywhere"
1058 );
1059 }
1060
1061 // ── analytic oracles ───────────────────────────────────────────────────
1062
1063 /// A line far narrower than the kernel broadens to the kernel's own
1064 /// width, so the measured FWHM of `E·σ` must be the free-gas
1065 /// `2√(ln2)·Δ_D`. This tests the kernel, not the integrator.
1066 #[test]
1067 fn a_narrow_line_broadens_to_the_free_gas_fwhm() {
1068 let (resonance_ev, temperature_k, awr) = (10.0_f64, 300.0_f64, 10.0_f64);
1069 let data = synthetic_swave_slbw(awr, resonance_ev, 1.5e-6, 1.5e-6, 5.0);
1070 let doppler_width = (4.0 * resonance_ev * BOLTZMANN_EV_PER_K * temperature_k / awr).sqrt();
1071 let expected_fwhm = 2.0 * 2.0_f64.ln().sqrt() * doppler_width;
1072 assert!(
1073 doppler_width > 1e4 * 3e-6,
1074 "the line must be far narrower than the kernel for this to mean anything"
1075 );
1076
1077 let energies: Vec<f64> = (0..=2400).map(|i| 9.4 + f64::from(i) * 5.0e-4).collect();
1078 let capture = broaden_channel(&energies, &data, temperature_k, Channel::Capture).unwrap();
1079 let profile: Vec<f64> = energies.iter().zip(&capture).map(|(e, s)| e * s).collect();
1080 let half = 0.5 * profile.iter().copied().fold(f64::MIN, f64::max);
1081 let crossing = |i: usize| {
1082 energies[i]
1083 + (half - profile[i]) / (profile[i + 1] - profile[i])
1084 * (energies[i + 1] - energies[i])
1085 };
1086 let rise = (0..profile.len() - 1)
1087 .find(|&i| profile[i] < half && profile[i + 1] >= half)
1088 .unwrap();
1089 let fall = (rise + 1..profile.len() - 1)
1090 .find(|&i| profile[i] >= half && profile[i + 1] < half)
1091 .unwrap();
1092 let fwhm = crossing(fall) - crossing(rise);
1093 let rel = (fwhm - expected_fwhm).abs() / expected_fwhm;
1094 assert!(
1095 rel < 2.0e-3,
1096 "FWHM {fwhm:.6} vs free-gas {expected_fwhm:.6} (rel {rel:.3e})"
1097 );
1098 }
1099
1100 /// The analytic temperature derivative against a five-point central
1101 /// difference of the VALUE path, which never touches the derivative
1102 /// integrand.
1103 #[test]
1104 fn the_analytic_temperature_derivative_matches_a_five_point_difference() {
1105 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1106 let energies = [6.5, 6.674, 7.1];
1107 let t = 300.0_f64;
1108 let h = 2.0_f64;
1109 let (_, analytic) = broaden_with_derivative(&energies, &data, t).unwrap();
1110
1111 let at = |temperature: f64| broaden(&energies, &data, temperature).unwrap();
1112 let (m2, m1, p1, p2) = (at(t - 2.0 * h), at(t - h), at(t + h), at(t + 2.0 * h));
1113 for i in 0..energies.len() {
1114 let fd = (m2[i] - 8.0 * m1[i] + 8.0 * p1[i] - p2[i]) / (12.0 * h);
1115 let rel = (analytic[i] - fd).abs() / fd.abs();
1116 assert!(
1117 rel < 1.0e-6,
1118 "E={} eV: analytic {} vs five-point {fd} (rel {rel:.3e})",
1119 energies[i],
1120 analytic[i]
1121 );
1122 }
1123 }
1124
1125 // ── limits and degenerate cases ────────────────────────────────────────
1126
1127 /// Zero temperature is the unbroadened equation exactly, not
1128 /// approximately: there is no kernel to apply, so returning anything
1129 /// else would be a quadrature artefact.
1130 #[test]
1131 fn zero_temperature_is_bit_exactly_the_resonance_equation() {
1132 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1133 let energies = [6.5, 6.674, 6.9];
1134 let expected: Vec<f64> = CrossSectionPlan::new(&data)
1135 .evaluate(&energies)
1136 .into_iter()
1137 .map(|xs| xs.total)
1138 .collect();
1139
1140 let (values, derivatives) = broaden_with_derivative(&energies, &data, 0.0).unwrap();
1141 assert_eq!(values, expected);
1142 assert_eq!(derivatives, vec![0.0; 3]);
1143
1144 // A temperature so small that u underflows to zero takes the same
1145 // path, rather than dividing by a zero width — and the ROUTE must
1146 // say so too, or it would disclose a continuous integral over a
1147 // curve that was never broadened.
1148 let tiny = f64::from_bits(1);
1149 assert_eq!(DopplerParams::new(tiny, data.awr).unwrap().u(), 0.0);
1150 assert_eq!(broaden(&energies, &data, tiny).unwrap(), expected);
1151
1152 // Every formalism the cross-section dispatcher evaluates goes
1153 // through the same integral, so Reich-Moore is not a special case
1154 // here or anywhere else.
1155 let rm = u238_with_formalism(ResonanceFormalism::ReichMoore);
1156 assert!(broaden(&energies, &rm, 0.0).is_ok());
1157 }
1158
1159 /// The sampled table converges to the integral as its grid is refined:
1160 /// the two tiers are answers to the same question, and this is what
1161 /// makes that claim testable rather than asserted.
1162 #[test]
1163 fn the_sampled_table_converges_to_the_integral_under_refinement() {
1164 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1165 let params = DopplerParams::new(293.6, data.awr).unwrap();
1166 let targets = [6.4, 6.674, 6.95];
1167 let exact = broaden(&targets, &data, 293.6).unwrap();
1168
1169 let mut previous = f64::INFINITY;
1170 for refinement in [1usize, 4, 16] {
1171 let step = 0.01 / refinement as f64;
1172 let grid: Vec<f64> = (0..=((3.0 / step) as usize))
1173 .map(|i| 5.0 + i as f64 * step)
1174 .collect();
1175 let table: Vec<f64> = CrossSectionPlan::new(&data)
1176 .evaluate(&grid)
1177 .into_iter()
1178 .map(|xs| xs.total)
1179 .collect();
1180 let sampled = doppler_broaden(&grid, &table, ¶ms).unwrap();
1181 let worst = targets
1182 .iter()
1183 .zip(&exact)
1184 .map(|(&target, &want)| {
1185 let i = grid
1186 .iter()
1187 .position(|&g| (g - target).abs() < 0.5 * step)
1188 .expect("target on the sampled grid");
1189 (sampled[i] - want).abs() / want
1190 })
1191 .fold(0.0_f64, f64::max);
1192 assert!(
1193 worst < previous,
1194 "refinement {refinement} did not improve: {worst:.3e} vs {previous:.3e}"
1195 );
1196 previous = worst;
1197 }
1198 assert!(previous < 5.0e-3, "finest grid still {previous:.3e} away");
1199 }
1200
1201 /// The integral must cover the part of the thermal window that folds
1202 /// through zero velocity.
1203 ///
1204 /// SAMMY Eq. III B1.6 integrates `w²·s(w)` with `s(w) = σ(w²)` for
1205 /// `w > 0` and `−σ(w²)` for `w < 0` — an ODD integrand through the
1206 /// origin. The sampled path builds exactly that odd extension
1207 /// (`build_extended_fgm_grid`), quoting the SAMMY manual Sec. III.B.1:
1208 /// negative velocities are included "in order to properly evaluate the
1209 /// integral at low values of E".
1210 ///
1211 /// How much the reflected branch matters is set by `√E/u`, and NOT by
1212 /// whether the truncated window happens to fold. The fraction of kernel
1213 /// mass on the reflected side is `erfc(√E/u)/2`: it is 24% at
1214 /// `√E/u = 0.5`, 7.9% at 1, and already 2e-3 at 2. Choosing targets by
1215 /// the old gate's `√E < 8u` instead would put them at `√E/u ≈ 4`, where
1216 /// the reflected mass is 1e-8 and the test measures nothing.
1217 ///
1218 /// `awr = 1` makes `u` large, so these ratios occur at energies well
1219 /// above the fixture's 1e-5 eV resolved-range floor — below that floor
1220 /// the dispatcher returns zero while the sampled path extrapolates
1221 /// 1/v, and keeping the targets clear of it keeps that disagreement out
1222 /// of this measurement.
1223 ///
1224 /// Measured against the sampled reference: with the sign the integral
1225 /// agrees to 2e-4 or better; without it the error is 34%, 11% and 3.7%
1226 /// at the three ratios.
1227 #[test]
1228 fn the_reflected_branch_carries_the_integral_at_low_energy() {
1229 let data = synthetic_swave_slbw(1.0, 5.0, 1.0e-3, 2.0e-2, 5.0);
1230 let params = DopplerParams::new(ROOM_K, data.awr).unwrap();
1231 let thermal_u = params.u();
1232
1233 let step = 2.0e-5;
1234 let grid: Vec<f64> = (1..=20_000).map(|i| f64::from(i) * step).collect();
1235 let table: Vec<f64> = CrossSectionPlan::new(&data)
1236 .evaluate(&grid)
1237 .into_iter()
1238 .map(|xs| xs.total)
1239 .collect();
1240 let sampled = doppler_broaden(&grid, &table, ¶ms).unwrap();
1241
1242 for ratio in [0.5_f64, 0.75, 1.0] {
1243 let index = grid
1244 .iter()
1245 .position(|&e| (e - (ratio * thermal_u).powi(2)).abs() < 0.5 * step)
1246 .expect("target on the sampled grid");
1247 let target = grid[index];
1248
1249 // Non-vacuity: at least 5% of the kernel must sit on the
1250 // reflected side, or this target proves nothing about it.
1251 let reflected_mass = 0.5 * erfc_approximation(ratio);
1252 assert!(
1253 reflected_mass > 0.05,
1254 "√E/u = {ratio} leaves only {reflected_mass:.2e} reflected mass"
1255 );
1256
1257 let ours = broaden(&[target], &data, ROOM_K).unwrap()[0];
1258 let want = sampled[index];
1259 let relative = (ours - want).abs() / want.abs();
1260 assert!(
1261 relative < 1.0e-3,
1262 "at √E/u = {ratio} (E = {target:.3e} eV, {reflected_mass:.3e} of \
1263 the kernel reflected) the integral gives {ours:.6e} against the \
1264 sampled reference {want:.6e} — {relative:.3e} relative"
1265 );
1266 }
1267 }
1268
1269 /// Abramowitz & Stegun 7.1.26. Only used to state how much kernel mass a
1270 /// test target puts on the reflected side, so ~1e-7 absolute is ample.
1271 fn erfc_approximation(x: f64) -> f64 {
1272 let t = 1.0 / (1.0 + 0.327_591_1 * x);
1273 let poly = t
1274 * (0.254_829_592
1275 + t * (-0.284_496_736
1276 + t * (1.421_413_741 + t * (-1.453_152_027 + t * 1.061_405_429))));
1277 poly * (-x * x).exp()
1278 }
1279
1280 /// The value at one target does not depend on which grid asked for it.
1281 /// Targets are independent integrals, and this pins that they stay so
1282 /// once they are farmed out to threads.
1283 #[test]
1284 fn a_target_is_bit_identical_across_grids_that_contain_it() {
1285 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1286 let alone = broaden(&[6.674], &data, 293.6).unwrap()[0];
1287 for grid in [
1288 vec![6.674, 7.0],
1289 vec![6.0, 6.674],
1290 vec![6.0, 6.3, 6.674, 7.0, 7.5],
1291 ] {
1292 let index = grid.iter().position(|&e| e == 6.674).unwrap();
1293 let together = broaden(&grid, &data, 293.6).unwrap()[index];
1294 assert_eq!(
1295 together.to_bits(),
1296 alone.to_bits(),
1297 "grid {grid:?} moved the value at 6.674 eV"
1298 );
1299 }
1300 }
1301
1302 /// A budget too small to converge reports the limit and the energy it
1303 /// was reached at, rather than returning an unconverged number.
1304 ///
1305 /// The target has to be one that actually refines: the limits are
1306 /// checked inside the refinement loop, so a target whose breakpoint
1307 /// panels already meet the tolerance never reaches them however small
1308 /// the budget. A line 1.5 μeV wide inside a 0.6 eV window is such a
1309 /// target — the breakpoints all collapse onto one kernel coordinate,
1310 /// so the spike must be found by bisection.
1311 #[test]
1312 fn an_exhausted_quadrature_budget_is_reported_not_absorbed() {
1313 let data = synthetic_swave_slbw(10.0, 10.0, 1.5e-6, 1.5e-6, 5.0);
1314 let limited = |budget| broaden_with_budget(&[10.0], &data, 300.0, Channel::Capture, budget);
1315 assert!(matches!(
1316 limited(QuadratureBudget {
1317 max_depth: 4,
1318 max_active_panels: MAX_ACTIVE_PANELS,
1319 }),
1320 Err(DopplerError::DepthLimit { depth: 4, .. })
1321 ));
1322 assert!(matches!(
1323 limited(QuadratureBudget {
1324 max_depth: MAX_DEPTH,
1325 max_active_panels: 8,
1326 }),
1327 Err(DopplerError::PanelLimit { limit: 8, .. })
1328 ));
1329 // Control: the default budget resolves the same spike.
1330 assert!(limited(QuadratureBudget::default()).is_ok());
1331 }
1332
1333 /// SAMMY keeps a genuinely negative broadened value (`fgm/mfgm4.f90`
1334 /// 83-101) rather than clamping it: an SLBW total whose same-J
1335 /// interference outweighs the shared potential term really is negative.
1336 #[test]
1337 fn a_negative_slbw_total_survives_broadening() {
1338 let mut data = synthetic_swave_slbw(55.45, 20_095.0, 30.0, 0.5, 5.0);
1339 data.ranges[0].l_groups[0].resonances.push(Resonance {
1340 energy: 20_105.0,
1341 j: 0.5,
1342 gn: 30.0,
1343 gg: 0.5,
1344 gfa: 0.0,
1345 gfb: 0.0,
1346 });
1347 // The destructive-interference trough between the two same-J levels
1348 // sits near 20.00 keV, well BELOW both resonance energies.
1349 let energies: Vec<f64> = (0..=40).map(|i| 19_990.0 + f64::from(i) * 0.5).collect();
1350
1351 // Non-vacuity: the UNBROADENED source must actually go negative
1352 // somewhere on this grid, or the test proves nothing.
1353 let plan = CrossSectionPlan::new(&data);
1354 assert!(
1355 energies.iter().any(|&e| plan.evaluate_one(e).total < 0.0),
1356 "fixture must have a negative unbroadened total"
1357 );
1358
1359 let broadened = broaden_with_budget(
1360 &energies,
1361 &data,
1362 293.6,
1363 Channel::Total,
1364 QuadratureBudget::default(),
1365 )
1366 .unwrap();
1367 assert!(
1368 broadened.negative_values > 0,
1369 "SAMMY's rule must KEEP at least one negative value here"
1370 );
1371 assert!(
1372 broadened.values.iter().any(|&v| v < 0.0),
1373 "a kept negative must reach the caller, not be clamped to zero"
1374 );
1375 // The count and the values agree: every kept negative is a negative
1376 // the caller can see.
1377 assert_eq!(
1378 broadened.negative_values,
1379 broadened.values.iter().filter(|&&v| v < 0.0).count()
1380 );
1381 }
1382
1383 /// The cross-section does not depend on whether the caller also asked
1384 /// for its slope.
1385 ///
1386 /// Refining against the derivative's error too changes which panels get
1387 /// split, and that used to change the converged value: 56 of 201 targets
1388 /// differed, worst 2.9e-13 relative. Divided by a finite-difference step
1389 /// that is enough to fail an analytic-vs-FD Jacobian check, and it is the
1390 /// same shape of defect as a model whose derivative describes a different
1391 /// curve from the one it reports. The derivative is now always computed
1392 /// and always part of the refinement criterion, so there is one panel set
1393 /// and one value.
1394 #[test]
1395 fn asking_for_the_derivative_does_not_change_the_value() {
1396 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1397 let energies: Vec<f64> = (0..201).map(|i| 1.0 + f64::from(i) * 0.05).collect();
1398
1399 let value_only = broaden(&energies, &data, 293.6).unwrap();
1400 let (value_with, derivatives) = broaden_with_derivative(&energies, &data, 293.6).unwrap();
1401
1402 for (i, (a, b)) in value_only.iter().zip(&value_with).enumerate() {
1403 assert_eq!(
1404 a.to_bits(),
1405 b.to_bits(),
1406 "target {i} at {} eV: {a} without the derivative, {b} with it",
1407 energies[i]
1408 );
1409 }
1410 // Non-vacuity: the derivative is a real converged quantity, not zero.
1411 assert!(
1412 derivatives.iter().any(|d| d.abs() > 0.0),
1413 "no derivative was produced, so the equality above is vacuous"
1414 );
1415 }
1416
1417 /// A roundoff-limited target is COUNTED, not silently passed off as
1418 /// converged.
1419 ///
1420 /// The tr165 pseudo-Al source at the bottom of its grid is the case that
1421 /// forced this: its window spans four decades of energy, so the summed
1422 /// per-panel error estimate cannot reach the tolerance however far the
1423 /// panels are split. The value is right — it agrees with SAMMY to 0.4 %
1424 /// — but its error certificate was never met, and a caller that needs
1425 /// one has to be able to tell.
1426 #[test]
1427 fn a_roundoff_limited_target_is_reported_as_such() {
1428 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1429 .parent()
1430 .unwrap()
1431 .parent()
1432 .unwrap()
1433 .join("tests/data/samtry/tr165_pseudo_al_total_xs");
1434 let inp = nereids_endf::sammy::parse_sammy_inp(
1435 &std::fs::read_to_string(dir.join("t165a.inp")).unwrap(),
1436 )
1437 .unwrap();
1438 let par = nereids_endf::sammy::parse_sammy_par(
1439 &std::fs::read_to_string(dir.join("t165a.par")).unwrap(),
1440 )
1441 .unwrap();
1442 let data = nereids_endf::sammy::sammy_to_resonance_data(&inp, &par).unwrap();
1443
1444 // The first data point of SAMMY's own answer grid, which is where the
1445 // window is widest relative to the energy.
1446 let broadening = broaden_with_budget(
1447 &[9.99999975e-6],
1448 &data,
1449 300.0,
1450 Channel::Total,
1451 QuadratureBudget::default(),
1452 )
1453 .expect("a roundoff-limited target returns its value rather than failing");
1454
1455 assert_eq!(
1456 broadening.roundoff_limited, 1,
1457 "this target is the one that cannot meet its certificate; if it now \
1458 can, the report is untested rather than unnecessary"
1459 );
1460 assert!(
1461 broadening.values[0] > 20.0 && broadening.values[0] < 30.0,
1462 "the reported value must still be the right one (SAMMY gives \
1463 24.894 barn), got {}",
1464 broadening.values[0]
1465 );
1466
1467 // Control: an ordinary target meets its certificate, so the count is
1468 // reporting a real distinction rather than being always set.
1469 let ordinary = broaden_with_budget(
1470 &[10.0],
1471 &data,
1472 300.0,
1473 Channel::Total,
1474 QuadratureBudget::default(),
1475 )
1476 .expect("an ordinary target converges");
1477 assert_eq!(
1478 ordinary.roundoff_limited, 0,
1479 "an ordinary target must not be reported as roundoff-limited"
1480 );
1481 }
1482
1483 /// The budget bounds the panels the BREAKPOINTS produce, not only the
1484 /// ones refinement adds. Before this, a source wide enough to exceed
1485 /// the cap on its initial panels sailed past it, and if those panels
1486 /// converged the cap was never consulted at all.
1487 #[test]
1488 fn the_panel_budget_bounds_the_initial_panels_too() {
1489 let data = u238_with_formalism(ResonanceFormalism::MLBW);
1490 // One resonance yields five breakpoints, so the initial panel count
1491 // is above 1 but the target converges without any refinement — the
1492 // case that used to escape.
1493 assert!(matches!(
1494 broaden_with_budget(
1495 &[6.674],
1496 &data,
1497 293.6,
1498 Channel::Total,
1499 QuadratureBudget {
1500 max_depth: MAX_DEPTH,
1501 max_active_panels: 1,
1502 },
1503 ),
1504 Err(DopplerError::PanelLimit { limit: 1, .. })
1505 ));
1506 assert!(broaden(&[6.674], &data, 293.6).is_ok());
1507 }
1508
1509 /// Every formalism the cross-section dispatcher evaluates is broadened
1510 /// by the same integral.
1511 ///
1512 /// The integrand calls [`CrossSectionPlan::evaluate_one`], which
1513 /// dispatches SLBW, MLBW and Reich-Moore alike, so there is nothing for
1514 /// the broadening to special-case. Reich-Moore used to be refused here,
1515 /// which is what made the choice of method a runtime decision.
1516 ///
1517 /// The results must also DIFFER between formalisms, or the test would
1518 /// pass on an integrand that ignored the formalism entirely.
1519 #[test]
1520 fn every_formalism_the_dispatcher_evaluates_is_integrated() {
1521 let targets = [6.5, 6.674, 6.9];
1522 let mut results = Vec::new();
1523 for formalism in [
1524 ResonanceFormalism::SLBW,
1525 ResonanceFormalism::MLBW,
1526 ResonanceFormalism::ReichMoore,
1527 ] {
1528 let data = u238_with_formalism(formalism);
1529 let values = broaden(&targets, &data, ROOM_K)
1530 .unwrap_or_else(|e| panic!("{formalism:?} was not integrated: {e:?}"));
1531 assert!(
1532 values.iter().all(|v| v.is_finite() && *v > 0.0),
1533 "{formalism:?} produced {values:?}"
1534 );
1535 results.push((formalism, values));
1536 }
1537 for pair in results.windows(2) {
1538 let ((left, a), (right, b)) = (&pair[0], &pair[1]);
1539 assert!(
1540 a.iter().zip(b).any(|(x, y)| x != y),
1541 "{left:?} and {right:?} broadened identically, so the \
1542 formalism never reached the integrand"
1543 );
1544 }
1545 }
1546}