Skip to main content

nereids_physics/
auxiliary_grid.rs

1//! Auxiliary energy grid construction for resolution broadening.
2//!
3//! SAMMY extends the energy grid before computing cross-sections and applying
4//! broadening.  This module reproduces SAMMY's default grid construction:
5//!
6//! 1. **Boundary extension** (Eqcon/Vqcon): Extend below E_min and above
7//!    E_max using spacing of the first/last 5 data points, uniform in √E
8//!    (FGM Doppler convention) for the Gaussian family and uniform in time
9//!    of flight for kernels tabulated there.
10//! 2. **Resonance fine-structure** (Fspken/Add_Pnts): Add dense points around
11//!    narrow resonances where the existing grid has fewer than 10 points per
12//!    resonance width (SAMMY default iptdop=9).
13//! 3. **Intermediate points** (Eqxtra): Insert extra points between each pair.
14//!    Default is 0 — none of our test cases override this.
15//!
16//! ## SAMMY Reference
17//! - `dat/mdat4.f90` — Escale (main entry), Fspken (resonance scan),
18//!   Add_Pnts (fine-structure insertion)
19//! - `dat/mdata.f90` — Eqxtra (intermediate points), Eqcon/Vqcon (boundary
20//!   extension)
21//! - `inp/InputInfoData.cpp` — Default iptdop=9, iptwid=5, nxtra=0
22
23use crate::resolution::{ResolutionFunction, ResolutionParams};
24use nereids_core::constants::NEAR_ZERO_FLOOR;
25
26/// Number of boundary data points used to compute extension spacing.
27///
28/// SAMMY Ref: `dat/mdat4.f90` Escale lines 56-97 for the spacing; the
29/// amounts come from `rsl/mrsl4.f90` Wdsint
30const N_BOUNDARY_REF: usize = 5;
31
32/// Relative tolerance for duplicate detection during grid merge.
33/// Points closer than `tol * E` are considered duplicates.
34const MERGE_RELATIVE_TOL: f64 = 1e-10;
35
36/// SAMMY default iptdop: controls fine-structure point density.
37///
38/// SAMMY Ref: `inp/InputInfoData.cpp` line 23
39const IPTDOP: usize = 9;
40
41/// Minimum grid points required within one resonance width [E_res−Gd, E_res+Gd].
42/// If the existing grid has fewer, fine-structure points are added.
43///
44/// SAMMY Ref: `dat/mdat4.f90` Fspken lines 276-279
45const MIN_POINTS_PER_WIDTH: usize = IPTDOP + 1;
46
47/// Fraction of resonance width used as fine-structure spacing.
48/// `Eg = FRACTN * Gd` gives ~14 uniformly-spaced points across 2·Gd.
49///
50/// SAMMY Ref: `dat/mdat4.f90` Fspken line 310
51const FRACTN: f64 = 2.0 / (IPTDOP as f64 + 5.0);
52
53/// Build an extended energy grid with boundary extension and resonance
54/// fine-structure for resolution broadening.
55///
56/// Returns `(extended_energies, data_indices)` where:
57/// - `extended_energies` is sorted ascending and includes all `data_energies`
58/// - `data_indices[i]` is the index of `data_energies[i]` in `extended_energies`
59///
60/// When `data_energies` has fewer than 2 points or `resolution` is `None`,
61/// returns a copy of the data grid with identity indices.
62///
63/// # Arguments
64/// * `data_energies` — Experimental energy grid (sorted ascending, eV).
65/// * `resolution` — Resolution parameters (for computing boundary width).
66/// * `resonances` — (energy_eV, gd_eV) pairs for fine-structure densification.
67///   `gd = 0.001 * Σ|Γ_i|` is the resonance half-width parameter from SAMMY's
68///   Fspken convention.
69///
70/// # SAMMY Reference
71/// `dat/mdat4.f90` Escale+Fspken+Add_Pnts, `dat/mdata.f90` Vqcon
72pub fn build_extended_grid(
73    data_energies: &[f64],
74    resolution: Option<&ResolutionParams>,
75    resonances: &[(f64, f64)],
76) -> (Vec<f64>, Vec<usize>) {
77    build_extended_grid_inner(data_energies, resolution, resonances, true)
78}
79
80/// Build extended grid with boundary extension only (no intermediate points).
81///
82/// Used when the combined Gaussian+exponential kernel is active, where
83/// non-uniform spacing from adaptive intermediates degrades the Xcoef
84/// quadrature accuracy.
85pub fn build_extended_grid_boundary_only(
86    data_energies: &[f64],
87    resolution: Option<&ResolutionParams>,
88) -> (Vec<f64>, Vec<usize>) {
89    build_extended_grid_inner(data_energies, resolution, &[], false)
90}
91
92/// Extend a data grid past both ends by the reach of any resolution family.
93/// Boundary extension only: the intermediate points and resonance fine
94/// structure of [`build_extended_grid`] are built for the Gaussian family alone.
95pub fn build_extended_grid_for(
96    data_energies: &[f64],
97    resolution: &ResolutionFunction,
98) -> (Vec<f64>, Vec<usize>) {
99    if data_energies.len() < 2 {
100        let indices: Vec<usize> = (0..data_energies.len()).collect();
101        return (data_energies.to_vec(), indices);
102    }
103    let (low, high) = resolution.grid_bounds_ev(data_energies);
104    let spacing = match resolution {
105        // The Gaussian's width is an energy.
106        ResolutionFunction::Gaussian(_) => Spacing::SqrtEnergy,
107        // These kernels are tabulated in time of flight.
108        ResolutionFunction::Tabulated(_) | ResolutionFunction::IkedaCarpenter(_) => {
109            Spacing::TimeOfFlight
110        }
111    };
112    extend_boundaries(data_energies, low, high, spacing)
113}
114
115/// The variable in which the added boundary points are evenly spaced, at the
116/// average spacing of the five data points nearest the edge.
117///
118/// SAMMY Ref: `dat/mdat4.f90` Escale, `sqrt(E)` for free-gas Doppler
119/// (`dat/mdata.f90` Vqcon); time of flight is `1/sqrt(E)` up to the flight path.
120#[derive(Clone, Copy)]
121enum Spacing {
122    SqrtEnergy,
123    TimeOfFlight,
124}
125
126impl Spacing {
127    fn to_u(self, e: f64) -> f64 {
128        match self {
129            Spacing::SqrtEnergy => e.sqrt(),
130            Spacing::TimeOfFlight => 1.0 / e.sqrt(),
131        }
132    }
133
134    fn to_e(self, u: f64) -> f64 {
135        match self {
136            Spacing::SqrtEnergy => u * u,
137            Spacing::TimeOfFlight => 1.0 / (u * u),
138        }
139    }
140}
141
142/// The points stepping outward from `e_edge` to `target_e`, evenly spaced in
143/// `spacing` at the average spacing of the `n_ref` data points from `e_edge`
144/// to `e_ref`, ending at `target_e` itself; a lattice point within the merge
145/// tolerance of the target is left out.
146fn step_outward(
147    spacing: Spacing,
148    e_edge: f64,
149    e_ref: f64,
150    n_ref: usize,
151    target_e: f64,
152) -> Vec<f64> {
153    let u_edge = spacing.to_u(e_edge);
154    let u_target = spacing.to_u(target_e);
155    let step = (u_edge - spacing.to_u(e_ref)) / (n_ref as f64 - 1.0).max(1.0);
156    let steps = (u_target - u_edge) / step;
157    if step.abs() <= 1e-30 || !steps.is_finite() || steps <= 0.0 {
158        return Vec::new();
159    }
160    let n_between = (steps - MERGE_RELATIVE_TOL * (u_target / step).abs()).floor() as usize;
161    let mut points: Vec<f64> = (1..=n_between)
162        .map(|k| spacing.to_e(u_edge + step * k as f64))
163        .collect();
164    points.push(target_e);
165    points
166}
167
168/// Extend a grid so it spans `[low, high]`, at the edge spacing of the data
169/// itself, ending exactly at `low` and `high`.  The data points are carried
170/// unchanged between the two extensions.
171fn extend_boundaries(
172    data_energies: &[f64],
173    low: f64,
174    high: f64,
175    spacing: Spacing,
176) -> (Vec<f64>, Vec<usize>) {
177    let n = data_energies.len();
178    let e_min = data_energies[0];
179    let e_max = data_energies[n - 1];
180    let n_ref = N_BOUNDARY_REF.min(n);
181
182    let mut below = if low < e_min && e_min > 0.0 {
183        step_outward(spacing, e_min, data_energies[n_ref - 1], n_ref, low)
184    } else {
185        Vec::new()
186    };
187    below.reverse();
188    let above = if high > e_max {
189        step_outward(spacing, e_max, data_energies[n - n_ref], n_ref, high)
190    } else {
191        Vec::new()
192    };
193
194    let indices = (below.len()..below.len() + n).collect();
195    let mut grid = below;
196    grid.extend_from_slice(data_energies);
197    grid.extend(above);
198    (grid, indices)
199}
200
201fn build_extended_grid_inner(
202    data_energies: &[f64],
203    resolution: Option<&ResolutionParams>,
204    resonances: &[(f64, f64)],
205    add_intermediate: bool,
206) -> (Vec<f64>, Vec<usize>) {
207    if data_energies.is_empty() {
208        return (vec![], vec![]);
209    }
210    if data_energies.len() == 1 {
211        return (data_energies.to_vec(), vec![0]);
212    }
213
214    let res = match resolution {
215        Some(r) => r,
216        None => {
217            let indices: Vec<usize> = (0..data_energies.len()).collect();
218            return (data_energies.to_vec(), indices);
219        }
220    };
221
222    // ── Step 1: Boundary extension ──────────────────────────────────────
223    let (low, high) = ResolutionFunction::Gaussian(*res).grid_bounds_ev(data_energies);
224    let (mut grid, _) = extend_boundaries(data_energies, low, high, Spacing::SqrtEnergy);
225
226    // ── Step 2: Adaptive intermediate points ────────────────────────────
227    // Insert intermediate points where the grid spacing exceeds a fraction
228    // of the local resolution width.  This ensures the resolution broadening
229    // integral has enough quadrature points even on coarse grids.
230    //
231    // Target: spacing ≤ W/4 (at least ~20 points per 5σ window).
232    //
233    // W/4 is sufficient: the PW-linear Gaussian integration is exact for
234    // linear cross-section segments, so the error depends on the cross-section
235    // curvature × h², not on quadrature point count.  Fine-structure points
236    // (Step 3) densify around narrow resonances where curvature is high.
237    //
238    // SAMMY analogue: dat/mdata.f90 Eqxtra (with nxtra=0 default, but
239    // SAMMY's fine-structure + Xcoef quadrature compensates).
240    if add_intermediate {
241        let mut extra: Vec<f64> = Vec::new();
242        for k in 0..grid.len() - 1 {
243            let e_lo = grid[k];
244            let e_hi = grid[k + 1];
245            let h = e_hi - e_lo;
246            let e_mid = (e_lo + e_hi) * 0.5;
247            let w = res.gaussian_width(e_mid);
248            if w < NEAR_ZERO_FLOOR {
249                continue;
250            }
251            let max_spacing = w * 0.25;
252            if h > max_spacing {
253                // Insert enough uniformly-spaced points.
254                let n_ins = (h / max_spacing).ceil() as usize;
255                let step = h / n_ins as f64;
256                for j in 1..n_ins {
257                    extra.push(e_lo + step * j as f64);
258                }
259            }
260        }
261        if !extra.is_empty() {
262            grid.extend(extra);
263            grid.sort_unstable_by(|a, b| a.total_cmp(b));
264            dedup(&mut grid);
265        }
266    }
267
268    // ── Step 3: Resonance fine-structure (Fspken) ───────────────────────
269    // For each resonance within the grid range, check if the grid has at
270    // least MIN_POINTS_PER_WIDTH points across [E_res-Gd, E_res+Gd].
271    // If not, add uniformly-spaced points with spacing Eg = FRACTN * Gd,
272    // plus exponentially-graded tail/transition points.
273    // SAMMY Ref: dat/mdat4.f90 Fspken lines 243-284, Add_Pnts lines 333-532
274    if !resonances.is_empty() {
275        let mut fine_pts: Vec<f64> = Vec::new();
276        for &(eres, gd) in resonances {
277            let pts = fine_structure_points(&grid, eres, gd);
278            fine_pts.extend(pts);
279        }
280        if !fine_pts.is_empty() {
281            grid.extend(fine_pts);
282            grid.sort_unstable_by(|a, b| a.total_cmp(b));
283            dedup(&mut grid);
284        }
285    }
286
287    // Filter to positive energies.
288    grid.retain(|&e| e > 0.0);
289
290    // Build data_indices.
291    let data_indices = build_data_indices(&grid, data_energies);
292
293    (grid, data_indices)
294}
295
296/// Generate fine-structure points around a single resonance.
297///
298/// SAMMY's `Fspken` identifies resonances where the existing grid has fewer
299/// than `IPTDOP+1` (=10) points within [E_res−Gd, E_res+Gd].  For each such
300/// resonance, `Add_Pnts` inserts:
301/// - Uniform points across [E_res−Gd, E_res+Gd] with spacing `Eg = FRACTN * Gd`
302/// - Exponentially graded transition points beyond ±Gd (spacing doubles each step)
303///   up to ±3·Gd, preventing abrupt density jumps at the fine-structure boundary.
304///
305/// SAMMY Ref: `dat/mdat4.f90` Fspken lines 243-284, Add_Pnts lines 333-532,
306///            DgradV/UgradV (graded transition)
307fn fine_structure_points(grid: &[f64], eres: f64, gd: f64) -> Vec<f64> {
308    if gd < 1e-30 || eres <= 0.0 {
309        return vec![];
310    }
311
312    let xmin = (eres - gd).max(1e-6);
313    let xmax = eres + gd;
314
315    // Skip resonances outside the grid range.
316    // SAMMY Ref: Fspken line 253: `IF (eres.LT.el_energb .OR. eres.GT.eh_energb) cycle`
317    if grid.is_empty() || eres < grid[0] || eres > *grid.last().unwrap() {
318        return vec![];
319    }
320
321    // Count existing grid points in [xmin, xmax].
322    // SAMMY Ref: Fspken lines 269-279 (Pointr + K+iptdop+1 check)
323    let lo = grid.partition_point(|&e| e < xmin);
324    let hi = grid.partition_point(|&e| e <= xmax);
325    let count = hi - lo;
326
327    if count >= MIN_POINTS_PER_WIDTH {
328        return vec![];
329    }
330
331    let eg = FRACTN * gd;
332    if eg < 1e-30 {
333        return vec![];
334    }
335
336    let mut new_points = Vec::new();
337
338    // Uniform points across [xmin, xmax] with spacing eg.
339    // SAMMY Ref: Add_Pnts — uniform fill within resonance width
340    let n_pts = ((xmax - xmin) / eg).ceil() as usize;
341    for i in 0..=n_pts {
342        let e = xmin + eg * i as f64;
343        if e > 0.0 && e <= xmax + eg * 0.01 {
344            new_points.push(e);
345        }
346    }
347
348    // Exponentially graded transition points beyond ±Gd.
349    // Bridge from fine-structure spacing to the surrounding grid spacing
350    // with doubling steps, preventing the abrupt spacing jumps that cause
351    // Xcoef quadrature weight instability.
352    // SAMMY Ref: Add_Pnts — DgradV/UgradV calls at lines 551-553, 659-661
353
354    // Down-side: bridge from xmin to the nearest grid point below.
355    let idx_below = lo; // lo is the first grid index >= xmin
356    if idx_below > 0 {
357        let e_below = grid[idx_below - 1];
358        let gap = xmin - e_below;
359        if gap > eg * 2.0 {
360            let mut spacing = eg;
361            let mut e = xmin;
362            for _ in 0..20 {
363                spacing *= 2.0;
364                e -= spacing;
365                if e <= e_below + MERGE_RELATIVE_TOL * e_below.abs().max(1e-30) {
366                    break;
367                }
368                new_points.push(e);
369            }
370        }
371    }
372
373    // Up-side: bridge from xmax to the nearest grid point above.
374    if hi < grid.len() {
375        let e_above = grid[hi];
376        let gap = e_above - xmax;
377        if gap > eg * 2.0 {
378            let mut spacing = eg;
379            let mut e = xmax;
380            for _ in 0..20 {
381                spacing *= 2.0;
382                e += spacing;
383                if e >= e_above - MERGE_RELATIVE_TOL * e_above.abs().max(1e-30) {
384                    break;
385                }
386                new_points.push(e);
387            }
388        }
389    }
390
391    new_points
392}
393
394/// Sort and deduplicate within tolerance.
395fn dedup(grid: &mut Vec<f64>) {
396    if grid.len() < 2 {
397        return;
398    }
399    let mut deduped = Vec::with_capacity(grid.len());
400    deduped.push(grid[0]);
401    for &val in grid.iter().skip(1) {
402        let prev = *deduped.last().unwrap();
403        let tol = MERGE_RELATIVE_TOL * prev.abs().max(1e-30);
404        if (val - prev).abs() > tol {
405            deduped.push(val);
406        }
407    }
408    *grid = deduped;
409}
410
411/// Build mapping from data energies to their indices in the extended grid.
412///
413/// Each data energy must appear exactly in the grid (guaranteed by
414/// construction — data points are always included and never dropped by dedup).
415///
416/// Uses binary search for O(N log M) where N = data points, M = grid size.
417fn build_data_indices(grid: &[f64], data_energies: &[f64]) -> Vec<usize> {
418    data_energies
419        .iter()
420        .map(|&e| {
421            let idx = grid.partition_point(|&ae| ae < e);
422            // Search nearby for exact match (floating-point tolerance).
423            let search_range = idx.saturating_sub(1)..grid.len().min(idx + 2);
424            let mut best_idx = idx.min(grid.len() - 1);
425            let mut best_dist = (grid[best_idx] - e).abs();
426            for j in search_range {
427                let dist = (grid[j] - e).abs();
428                if dist < best_dist {
429                    best_dist = dist;
430                    best_idx = j;
431                }
432            }
433            best_idx
434        })
435        .collect()
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_empty_grid() {
444        let (ext, indices) = build_extended_grid(&[], None, &[]);
445        assert!(ext.is_empty());
446        assert!(indices.is_empty());
447    }
448
449    #[test]
450    fn test_single_point() {
451        let energies = vec![100.0];
452        let (ext, indices) = build_extended_grid(&energies, None, &[]);
453        assert_eq!(ext, vec![100.0]);
454        assert_eq!(indices, vec![0]);
455    }
456
457    #[test]
458    fn test_no_resolution_identity() {
459        let data = vec![1.0, 5.0, 10.0];
460        let (ext, indices) = build_extended_grid(&data, None, &[]);
461        assert_eq!(ext, data);
462        assert_eq!(indices, vec![0, 1, 2]);
463    }
464
465    #[test]
466    fn test_data_indices_roundtrip() {
467        let data = vec![1.0, 5.0, 10.0, 100.0];
468        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
469        let (ext, indices) = build_extended_grid(&data, Some(&res), &[]);
470        assert!(ext.len() >= data.len());
471        for (i, &e) in data.iter().enumerate() {
472            assert!(
473                (ext[indices[i]] - e).abs() < 1e-10,
474                "data[{i}]={e} not at ext[{}]={}",
475                indices[i],
476                ext[indices[i]]
477            );
478        }
479    }
480
481    #[test]
482    fn test_extension_covers_5sigma() {
483        let data: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 5.0).collect();
484        let res = ResolutionParams::new(10.0, 0.1, 0.01, 0.0).unwrap();
485        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
486
487        assert!(
488            ext[0] < data[0],
489            "expected extension below data[0]={}, got ext[0]={}",
490            data[0],
491            ext[0]
492        );
493        assert!(
494            *ext.last().unwrap() > *data.last().unwrap(),
495            "expected extension above data max"
496        );
497    }
498
499    #[test]
500    fn test_grid_is_sorted() {
501        let data: Vec<f64> = (0..10).map(|i| 1000.0 + i as f64 * 100.0).collect();
502        let res = ResolutionParams::new(50.0, 0.05, 0.01, 0.0).unwrap();
503        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
504        for pair in ext.windows(2) {
505            assert!(
506                pair[0] < pair[1],
507                "grid not sorted: {} >= {}",
508                pair[0],
509                pair[1]
510            );
511        }
512    }
513
514    #[test]
515    fn test_grid_all_positive() {
516        let data = vec![1.0, 2.0, 3.0];
517        let res = ResolutionParams::new(10.0, 0.1, 0.01, 0.0).unwrap();
518        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
519        for &e in &ext {
520            assert!(e > 0.0, "non-positive energy: {e}");
521        }
522    }
523
524    #[test]
525    fn test_fine_structure_adds_points() {
526        // Test fine-structure in isolation (no intermediate points) by calling
527        // build_extended_grid_inner directly.
528        // Sparse grid with a narrow resonance at 500 eV, Gd = 1 eV.
529        // Grid has ~5 eV spacing → only ~0-1 point in [499, 501].
530        let data: Vec<f64> = (0..20).map(|i| 490.0 + i as f64 * 5.0).collect();
531        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
532        let resonances = vec![(500.0, 1.0)]; // E_res=500 eV, Gd=1 eV
533
534        // Without fine-structure, boundary-only:
535        let (ext_without, _) = build_extended_grid_inner(&data, Some(&res), &[], false);
536        // With fine-structure, still no intermediates:
537        let (ext_with, _) = build_extended_grid_inner(&data, Some(&res), &resonances, false);
538
539        assert!(
540            ext_with.len() > ext_without.len(),
541            "fine-structure should add points: {} vs {}",
542            ext_with.len(),
543            ext_without.len()
544        );
545
546        // Check that there are now ≥10 points in [499, 501].
547        let lo = ext_with.partition_point(|&e| e < 499.0);
548        let hi = ext_with.partition_point(|&e| e <= 501.0);
549        assert!(
550            hi - lo >= MIN_POINTS_PER_WIDTH,
551            "expected ≥{MIN_POINTS_PER_WIDTH} points in resonance width, got {}",
552            hi - lo
553        );
554    }
555
556    #[test]
557    fn test_fine_structure_skips_dense_grid() {
558        // Dense grid: 0.1 eV spacing around a resonance with Gd=1.0 eV.
559        // Already has ~20 points in [499, 501] → no fine-structure needed.
560        let data: Vec<f64> = (0..100).map(|i| 495.0 + i as f64 * 0.1).collect();
561        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
562        let resonances = vec![(500.0, 1.0)];
563
564        let (ext_without, _) = build_extended_grid(&data, Some(&res), &[]);
565        let (ext_with, _) = build_extended_grid(&data, Some(&res), &resonances);
566
567        assert_eq!(
568            ext_without.len(),
569            ext_with.len(),
570            "dense grid should not get extra fine-structure points"
571        );
572    }
573
574    #[test]
575    fn test_fine_structure_data_indices_valid() {
576        // Verify data points are still found correctly after fine-structure insertion.
577        let data: Vec<f64> = (0..20).map(|i| 490.0 + i as f64 * 5.0).collect();
578        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
579        let resonances = vec![(500.0, 1.0), (520.0, 0.5)];
580
581        let (ext, indices) = build_extended_grid(&data, Some(&res), &resonances);
582        assert_eq!(indices.len(), data.len());
583        for (i, &e) in data.iter().enumerate() {
584            assert!(
585                (ext[indices[i]] - e).abs() < 1e-10,
586                "data[{i}]={e} not at ext[{}]={}",
587                indices[i],
588                ext[indices[i]]
589            );
590        }
591    }
592
593    #[test]
594    fn test_fine_structure_outside_range_ignored() {
595        // Resonance outside data range should not add points.
596        let data: Vec<f64> = (0..10).map(|i| 100.0 + i as f64 * 10.0).collect();
597        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
598        let resonances = vec![(50.0, 1.0), (300.0, 1.0)]; // Both outside [100, 190]
599
600        let (ext_without, _) = build_extended_grid(&data, Some(&res), &[]);
601        let (ext_with, _) = build_extended_grid(&data, Some(&res), &resonances);
602
603        // May differ slightly due to boundary extension, but the resonance
604        // outside the extended range should not add fine-structure.
605        // Just verify the grid is valid.
606        assert!(ext_with.len() >= data.len());
607        for pair in ext_with.windows(2) {
608            assert!(
609                pair[0] < pair[1],
610                "grid not sorted: {} >= {}",
611                pair[0],
612                pair[1]
613            );
614        }
615        // Both resonances are far outside data range, should have same grid.
616        assert_eq!(ext_without.len(), ext_with.len());
617    }
618
619    /// The grid ends exactly at the bounds it was asked to span and keeps
620    /// every data point, even when a bound lies within the merge tolerance of
621    /// a lattice point or of the data's own end.
622    #[test]
623    fn grid_ends_exactly_at_its_bounds_through_the_merge() {
624        let data: Vec<f64> = (0..5).map(|i| 100.0 + f64::from(i)).collect();
625        let e_min = data[0];
626        let e_max = data[4];
627        // A high end seven lattice steps out plus a sliver the merge cannot
628        // resolve, in each spacing.
629        for spacing in [Spacing::SqrtEnergy, Spacing::TimeOfFlight] {
630            let u_max = spacing.to_u(e_max);
631            let step = (u_max - spacing.to_u(data[0])) / 4.0;
632            let high = spacing.to_e(u_max + 7.0 * step * (1.0 + 3.0e-11));
633            let (grid, indices) = extend_boundaries(&data, e_min, high, spacing);
634            assert_eq!(*grid.last().unwrap(), high);
635            assert_eq!(grid.len(), data.len() + 7);
636            assert_eq!(indices, vec![0, 1, 2, 3, 4]);
637
638            let u_min = spacing.to_u(e_min);
639            let step = (spacing.to_u(data[4]) - u_min) / 4.0;
640            let low = spacing.to_e(u_min - 7.0 * step * (1.0 + 3.0e-11));
641            let (grid, indices) = extend_boundaries(&data, low, e_max, spacing);
642            assert_eq!(grid[0], low);
643            assert_eq!(grid.len(), data.len() + 7);
644            assert_eq!(indices, vec![7, 8, 9, 10, 11]);
645        }
646        // Bounds within the merge tolerance of the data's own ends.
647        let high = e_max * (1.0 + 5.0e-11);
648        let (grid, indices) = extend_boundaries(&data, e_min, high, Spacing::SqrtEnergy);
649        assert_eq!(*grid.last().unwrap(), high);
650        assert_eq!(grid.len(), data.len() + 1);
651        assert_eq!(indices, vec![0, 1, 2, 3, 4]);
652        let low = e_min * (1.0 - 5.0e-11);
653        let (grid, indices) = extend_boundaries(&data, low, e_max, Spacing::TimeOfFlight);
654        assert_eq!(grid[0], low);
655        assert_eq!(grid[1], e_min);
656        assert_eq!(grid.len(), data.len() + 1);
657        assert_eq!(indices, vec![1, 2, 3, 4, 5]);
658    }
659
660    /// A delayed tail that approaches the nominal flight time does not make the
661    /// working grid grow without bound: the extension needs no more points than
662    /// the instrument has time-of-flight channels over the same span, and it
663    /// moves smoothly as the tail crosses the flight time.
664    #[test]
665    fn extension_stays_bounded_as_the_tail_nears_the_flight_time() {
666        use crate::resolution::{TOF_FACTOR, TabulatedResolution};
667        use std::sync::Arc;
668
669        let offsets: Vec<f64> = (0..=140).map(|k| -20.0 + k as f64).collect();
670        let weights = vec![1.0; offsets.len()];
671        let table = TabulatedResolution::from_kernels(
672            vec![100.0, 300.0],
673            vec![(offsets.clone(), weights.clone()), (offsets, weights)],
674            25.0,
675        )
676        .expect("valid two-block table");
677        let resolution = ResolutionFunction::Tabulated(Arc::new(table));
678
679        let mut previous: Option<usize> = None;
680        for tenths in 2200..=2300 {
681            let e_max = tenths as f64 / 10.0;
682            let data: Vec<f64> = (0..400).map(|i| e_max - 40.0 + i as f64 * 0.1).collect();
683            let (grid, _) = build_extended_grid_for(&data, &resolution);
684            let added = grid.len() - data.len();
685            let tof = |e: f64| TOF_FACTOR * 25.0 / e.sqrt();
686            let e_top = data[data.len() - 1];
687            let channels_above = tof(e_top) / (tof(data[data.len() - 2]) - tof(e_top));
688            let channels_below = 20.0 / (tof(data[0]) - tof(data[1]));
689            assert!(
690                (added as f64) <= channels_above + channels_below + 2.0,
691                "at e_max = {e_max} eV the extension added {added} points, more than the \
692                 {channels_above:.0} + {channels_below:.0} channels the instrument has there"
693            );
694            if let Some(p) = previous {
695                assert!(
696                    added <= 2 * p + 8 && p <= 2 * added + 8,
697                    "the extension jumped from {p} to {added} points between neighbouring \
698                     windows ending near {e_max} eV"
699                );
700            }
701            previous = Some(added);
702        }
703    }
704}