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, ¶ms).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, ¶ms).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}