Skip to main content

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