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//! ## 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, &params).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, &params).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}