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).
9//! 2. **Resonance fine-structure** (Fspken/Add_Pnts): Add dense points around
10//!    narrow resonances where the existing grid has fewer than 10 points per
11//!    resonance width (SAMMY default iptdop=9).
12//! 3. **Intermediate points** (Eqxtra): Insert extra points between each pair.
13//!    Default is 0 — none of our test cases override this.
14//!
15//! ## SAMMY Reference
16//! - `dat/mdat4.f90` — Escale (main entry), Fspken (resonance scan),
17//!   Add_Pnts (fine-structure insertion)
18//! - `dat/mdata.f90` — Eqxtra (intermediate points), Eqcon/Vqcon (boundary
19//!   extension)
20//! - `inp/InputInfoData.cpp` — Default iptdop=9, iptwid=5, nxtra=0
21
22use crate::resolution::ResolutionParams;
23use nereids_core::constants::NEAR_ZERO_FLOOR;
24
25/// Number of boundary data points used to compute extension spacing.
26///
27/// SAMMY Ref: `dat/mdat4.f90` Escale lines 56-97
28const N_BOUNDARY_REF: usize = 5;
29
30/// Relative tolerance for duplicate detection during grid merge.
31/// Points closer than `tol * E` are considered duplicates.
32const MERGE_RELATIVE_TOL: f64 = 1e-10;
33
34/// SAMMY default iptdop: controls fine-structure point density.
35///
36/// SAMMY Ref: `inp/InputInfoData.cpp` line 23
37const IPTDOP: usize = 9;
38
39/// Minimum grid points required within one resonance width [E_res−Gd, E_res+Gd].
40/// If the existing grid has fewer, fine-structure points are added.
41///
42/// SAMMY Ref: `dat/mdat4.f90` Fspken lines 276-279
43const MIN_POINTS_PER_WIDTH: usize = IPTDOP + 1;
44
45/// Fraction of resonance width used as fine-structure spacing.
46/// `Eg = FRACTN * Gd` gives ~14 uniformly-spaced points across 2·Gd.
47///
48/// SAMMY Ref: `dat/mdat4.f90` Fspken line 310
49const FRACTN: f64 = 2.0 / (IPTDOP as f64 + 5.0);
50
51/// Build an extended energy grid with boundary extension and resonance
52/// fine-structure for resolution broadening.
53///
54/// Returns `(extended_energies, data_indices)` where:
55/// - `extended_energies` is sorted ascending and includes all `data_energies`
56/// - `data_indices[i]` is the index of `data_energies[i]` in `extended_energies`
57///
58/// When `data_energies` has fewer than 2 points or `resolution` is `None`,
59/// returns a copy of the data grid with identity indices.
60///
61/// # Arguments
62/// * `data_energies` — Experimental energy grid (sorted ascending, eV).
63/// * `resolution` — Resolution parameters (for computing boundary width).
64/// * `resonances` — (energy_eV, gd_eV) pairs for fine-structure densification.
65///   `gd = 0.001 * Σ|Γ_i|` is the resonance half-width parameter from SAMMY's
66///   Fspken convention.
67///
68/// # SAMMY Reference
69/// `dat/mdat4.f90` Escale+Fspken+Add_Pnts, `dat/mdata.f90` Vqcon
70pub fn build_extended_grid(
71    data_energies: &[f64],
72    resolution: Option<&ResolutionParams>,
73    resonances: &[(f64, f64)],
74) -> (Vec<f64>, Vec<usize>) {
75    build_extended_grid_inner(data_energies, resolution, resonances, true)
76}
77
78/// Build extended grid with boundary extension only (no intermediate points).
79///
80/// Used when the combined Gaussian+exponential kernel is active, where
81/// non-uniform spacing from adaptive intermediates degrades the Xcoef
82/// quadrature accuracy.
83pub fn build_extended_grid_boundary_only(
84    data_energies: &[f64],
85    resolution: Option<&ResolutionParams>,
86) -> (Vec<f64>, Vec<usize>) {
87    build_extended_grid_inner(data_energies, resolution, &[], false)
88}
89
90fn build_extended_grid_inner(
91    data_energies: &[f64],
92    resolution: Option<&ResolutionParams>,
93    resonances: &[(f64, f64)],
94    add_intermediate: bool,
95) -> (Vec<f64>, Vec<usize>) {
96    if data_energies.is_empty() {
97        return (vec![], vec![]);
98    }
99    if data_energies.len() == 1 {
100        return (data_energies.to_vec(), vec![0]);
101    }
102
103    let res = match resolution {
104        Some(r) => r,
105        None => {
106            let indices: Vec<usize> = (0..data_energies.len()).collect();
107            return (data_energies.to_vec(), indices);
108        }
109    };
110
111    let n = data_energies.len();
112
113    // ── Step 1: Boundary extension ──────────────────────────────────────
114    // Extend by 5σ of Gaussian width at each boundary, matching SAMMY's
115    // Escale lines 54-97.
116    let n_sigma = 5.0;
117
118    let e_min = data_energies[0];
119    let wg_low = res.gaussian_width(e_min);
120    let extend_low = n_sigma * wg_low;
121
122    let e_max = data_energies[n - 1];
123    let wg_high = res.gaussian_width(e_max);
124    let we_high = res.exp_width(e_max);
125    let extend_high = if we_high > 1e-30 {
126        let rwid = wg_high / we_high;
127        if rwid <= 1.0 {
128            6.25 * we_high
129        } else if rwid <= 2.0 {
130            n_sigma * (3.0 - rwid) * wg_high
131        } else {
132            n_sigma * wg_high
133        }
134    } else {
135        n_sigma * wg_high
136    };
137
138    let mut grid = Vec::with_capacity(n + 200);
139
140    // Low-side extension: equally spaced in √E (SAMMY FGM convention).
141    // SAMMY Ref: dat/mdata.f90 Vqcon
142    if extend_low > 0.0 && e_min > 0.0 {
143        let n_ref = N_BOUNDARY_REF.min(n);
144        let e_ref = data_energies[n_ref - 1];
145        let d_sqrt = (e_ref.sqrt() - e_min.sqrt()) / (n_ref as f64 - 1.0).max(1.0);
146
147        if d_sqrt > 1e-30 {
148            let target_low = (e_min - extend_low).max(0.001);
149            let sqrt_min = e_min.sqrt();
150            let sqrt_target = target_low.sqrt();
151            let n_ext = ((sqrt_min - sqrt_target) / d_sqrt).ceil() as usize;
152            for k in 1..=n_ext {
153                let sqrt_e = sqrt_min - d_sqrt * k as f64;
154                if sqrt_e > 0.0 {
155                    grid.push(sqrt_e * sqrt_e);
156                }
157            }
158        }
159    }
160
161    // Add all data points.
162    grid.extend_from_slice(data_energies);
163
164    // High-side extension: equally spaced in √E (SAMMY FGM convention).
165    if extend_high > 0.0 {
166        let n_ref = N_BOUNDARY_REF.min(n);
167        let e_ref = data_energies[n - n_ref];
168        let d_sqrt = (e_max.sqrt() - e_ref.sqrt()) / (n_ref as f64 - 1.0).max(1.0);
169
170        if d_sqrt > 1e-30 {
171            let target_high = e_max + extend_high;
172            let sqrt_max = e_max.sqrt();
173            let sqrt_target = target_high.sqrt();
174            let n_ext = ((sqrt_target - sqrt_max) / d_sqrt).ceil() as usize;
175            for k in 1..=n_ext {
176                let sqrt_e = sqrt_max + d_sqrt * k as f64;
177                grid.push(sqrt_e * sqrt_e);
178            }
179        }
180    }
181
182    // Sort and deduplicate before inserting intermediate points.
183    grid.sort_unstable_by(|a, b| a.total_cmp(b));
184    dedup(&mut grid);
185
186    // ── Step 2: Adaptive intermediate points ────────────────────────────
187    // Insert intermediate points where the grid spacing exceeds a fraction
188    // of the local resolution width.  This ensures the resolution broadening
189    // integral has enough quadrature points even on coarse grids.
190    //
191    // Target: spacing ≤ W/4 (at least ~20 points per 5σ window).
192    //
193    // W/4 is sufficient: the PW-linear Gaussian integration is exact for
194    // linear cross-section segments, so the error depends on the cross-section
195    // curvature × h², not on quadrature point count.  Fine-structure points
196    // (Step 3) densify around narrow resonances where curvature is high.
197    //
198    // SAMMY analogue: dat/mdata.f90 Eqxtra (with nxtra=0 default, but
199    // SAMMY's fine-structure + Xcoef quadrature compensates).
200    if add_intermediate {
201        let mut extra: Vec<f64> = Vec::new();
202        for k in 0..grid.len() - 1 {
203            let e_lo = grid[k];
204            let e_hi = grid[k + 1];
205            let h = e_hi - e_lo;
206            let e_mid = (e_lo + e_hi) * 0.5;
207            let w = res.gaussian_width(e_mid);
208            if w < NEAR_ZERO_FLOOR {
209                continue;
210            }
211            let max_spacing = w * 0.25;
212            if h > max_spacing {
213                // Insert enough uniformly-spaced points.
214                let n_ins = (h / max_spacing).ceil() as usize;
215                let step = h / n_ins as f64;
216                for j in 1..n_ins {
217                    extra.push(e_lo + step * j as f64);
218                }
219            }
220        }
221        if !extra.is_empty() {
222            grid.extend(extra);
223            grid.sort_unstable_by(|a, b| a.total_cmp(b));
224            dedup(&mut grid);
225        }
226    }
227
228    // ── Step 3: Resonance fine-structure (Fspken) ───────────────────────
229    // For each resonance within the grid range, check if the grid has at
230    // least MIN_POINTS_PER_WIDTH points across [E_res-Gd, E_res+Gd].
231    // If not, add uniformly-spaced points with spacing Eg = FRACTN * Gd,
232    // plus exponentially-graded tail/transition points.
233    // SAMMY Ref: dat/mdat4.f90 Fspken lines 243-284, Add_Pnts lines 333-532
234    if !resonances.is_empty() {
235        let mut fine_pts: Vec<f64> = Vec::new();
236        for &(eres, gd) in resonances {
237            let pts = fine_structure_points(&grid, eres, gd);
238            fine_pts.extend(pts);
239        }
240        if !fine_pts.is_empty() {
241            grid.extend(fine_pts);
242            grid.sort_unstable_by(|a, b| a.total_cmp(b));
243            dedup(&mut grid);
244        }
245    }
246
247    // Filter to positive energies.
248    grid.retain(|&e| e > 0.0);
249
250    // Build data_indices.
251    let data_indices = build_data_indices(&grid, data_energies);
252
253    (grid, data_indices)
254}
255
256/// Generate fine-structure points around a single resonance.
257///
258/// SAMMY's `Fspken` identifies resonances where the existing grid has fewer
259/// than `IPTDOP+1` (=10) points within [E_res−Gd, E_res+Gd].  For each such
260/// resonance, `Add_Pnts` inserts:
261/// - Uniform points across [E_res−Gd, E_res+Gd] with spacing `Eg = FRACTN * Gd`
262/// - Exponentially graded transition points beyond ±Gd (spacing doubles each step)
263///   up to ±3·Gd, preventing abrupt density jumps at the fine-structure boundary.
264///
265/// SAMMY Ref: `dat/mdat4.f90` Fspken lines 243-284, Add_Pnts lines 333-532,
266///            DgradV/UgradV (graded transition)
267fn fine_structure_points(grid: &[f64], eres: f64, gd: f64) -> Vec<f64> {
268    if gd < 1e-30 || eres <= 0.0 {
269        return vec![];
270    }
271
272    let xmin = (eres - gd).max(1e-6);
273    let xmax = eres + gd;
274
275    // Skip resonances outside the grid range.
276    // SAMMY Ref: Fspken line 253: `IF (eres.LT.el_energb .OR. eres.GT.eh_energb) cycle`
277    if grid.is_empty() || eres < grid[0] || eres > *grid.last().unwrap() {
278        return vec![];
279    }
280
281    // Count existing grid points in [xmin, xmax].
282    // SAMMY Ref: Fspken lines 269-279 (Pointr + K+iptdop+1 check)
283    let lo = grid.partition_point(|&e| e < xmin);
284    let hi = grid.partition_point(|&e| e <= xmax);
285    let count = hi - lo;
286
287    if count >= MIN_POINTS_PER_WIDTH {
288        return vec![];
289    }
290
291    let eg = FRACTN * gd;
292    if eg < 1e-30 {
293        return vec![];
294    }
295
296    let mut new_points = Vec::new();
297
298    // Uniform points across [xmin, xmax] with spacing eg.
299    // SAMMY Ref: Add_Pnts — uniform fill within resonance width
300    let n_pts = ((xmax - xmin) / eg).ceil() as usize;
301    for i in 0..=n_pts {
302        let e = xmin + eg * i as f64;
303        if e > 0.0 && e <= xmax + eg * 0.01 {
304            new_points.push(e);
305        }
306    }
307
308    // Exponentially graded transition points beyond ±Gd.
309    // Bridge from fine-structure spacing to the surrounding grid spacing
310    // with doubling steps, preventing the abrupt spacing jumps that cause
311    // Xcoef quadrature weight instability.
312    // SAMMY Ref: Add_Pnts — DgradV/UgradV calls at lines 551-553, 659-661
313
314    // Down-side: bridge from xmin to the nearest grid point below.
315    let idx_below = lo; // lo is the first grid index >= xmin
316    if idx_below > 0 {
317        let e_below = grid[idx_below - 1];
318        let gap = xmin - e_below;
319        if gap > eg * 2.0 {
320            let mut spacing = eg;
321            let mut e = xmin;
322            for _ in 0..20 {
323                spacing *= 2.0;
324                e -= spacing;
325                if e <= e_below + MERGE_RELATIVE_TOL * e_below.abs().max(1e-30) {
326                    break;
327                }
328                new_points.push(e);
329            }
330        }
331    }
332
333    // Up-side: bridge from xmax to the nearest grid point above.
334    if hi < grid.len() {
335        let e_above = grid[hi];
336        let gap = e_above - xmax;
337        if gap > eg * 2.0 {
338            let mut spacing = eg;
339            let mut e = xmax;
340            for _ in 0..20 {
341                spacing *= 2.0;
342                e += spacing;
343                if e >= e_above - MERGE_RELATIVE_TOL * e_above.abs().max(1e-30) {
344                    break;
345                }
346                new_points.push(e);
347            }
348        }
349    }
350
351    new_points
352}
353
354/// Sort and deduplicate within tolerance.
355fn dedup(grid: &mut Vec<f64>) {
356    if grid.len() < 2 {
357        return;
358    }
359    let mut deduped = Vec::with_capacity(grid.len());
360    deduped.push(grid[0]);
361    for &val in grid.iter().skip(1) {
362        let prev = *deduped.last().unwrap();
363        let tol = MERGE_RELATIVE_TOL * prev.abs().max(1e-30);
364        if (val - prev).abs() > tol {
365            deduped.push(val);
366        }
367    }
368    *grid = deduped;
369}
370
371/// Build mapping from data energies to their indices in the extended grid.
372///
373/// Each data energy must appear exactly in the grid (guaranteed by
374/// construction — data points are always included and never dropped by dedup).
375///
376/// Uses binary search for O(N log M) where N = data points, M = grid size.
377fn build_data_indices(grid: &[f64], data_energies: &[f64]) -> Vec<usize> {
378    data_energies
379        .iter()
380        .map(|&e| {
381            let idx = grid.partition_point(|&ae| ae < e);
382            // Search nearby for exact match (floating-point tolerance).
383            let search_range = idx.saturating_sub(1)..grid.len().min(idx + 2);
384            let mut best_idx = idx.min(grid.len() - 1);
385            let mut best_dist = (grid[best_idx] - e).abs();
386            for j in search_range {
387                let dist = (grid[j] - e).abs();
388                if dist < best_dist {
389                    best_dist = dist;
390                    best_idx = j;
391                }
392            }
393            best_idx
394        })
395        .collect()
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_empty_grid() {
404        let (ext, indices) = build_extended_grid(&[], None, &[]);
405        assert!(ext.is_empty());
406        assert!(indices.is_empty());
407    }
408
409    #[test]
410    fn test_single_point() {
411        let energies = vec![100.0];
412        let (ext, indices) = build_extended_grid(&energies, None, &[]);
413        assert_eq!(ext, vec![100.0]);
414        assert_eq!(indices, vec![0]);
415    }
416
417    #[test]
418    fn test_no_resolution_identity() {
419        let data = vec![1.0, 5.0, 10.0];
420        let (ext, indices) = build_extended_grid(&data, None, &[]);
421        assert_eq!(ext, data);
422        assert_eq!(indices, vec![0, 1, 2]);
423    }
424
425    #[test]
426    fn test_data_indices_roundtrip() {
427        let data = vec![1.0, 5.0, 10.0, 100.0];
428        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
429        let (ext, indices) = build_extended_grid(&data, Some(&res), &[]);
430        assert!(ext.len() >= data.len());
431        for (i, &e) in data.iter().enumerate() {
432            assert!(
433                (ext[indices[i]] - e).abs() < 1e-10,
434                "data[{i}]={e} not at ext[{}]={}",
435                indices[i],
436                ext[indices[i]]
437            );
438        }
439    }
440
441    #[test]
442    fn test_extension_covers_5sigma() {
443        let data: Vec<f64> = (0..20).map(|i| 100.0 + i as f64 * 5.0).collect();
444        let res = ResolutionParams::new(10.0, 0.1, 0.01, 0.0).unwrap();
445        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
446
447        assert!(
448            ext[0] < data[0],
449            "expected extension below data[0]={}, got ext[0]={}",
450            data[0],
451            ext[0]
452        );
453        assert!(
454            *ext.last().unwrap() > *data.last().unwrap(),
455            "expected extension above data max"
456        );
457    }
458
459    #[test]
460    fn test_grid_is_sorted() {
461        let data: Vec<f64> = (0..10).map(|i| 1000.0 + i as f64 * 100.0).collect();
462        let res = ResolutionParams::new(50.0, 0.05, 0.01, 0.0).unwrap();
463        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
464        for pair in ext.windows(2) {
465            assert!(
466                pair[0] < pair[1],
467                "grid not sorted: {} >= {}",
468                pair[0],
469                pair[1]
470            );
471        }
472    }
473
474    #[test]
475    fn test_grid_all_positive() {
476        let data = vec![1.0, 2.0, 3.0];
477        let res = ResolutionParams::new(10.0, 0.1, 0.01, 0.0).unwrap();
478        let (ext, _) = build_extended_grid(&data, Some(&res), &[]);
479        for &e in &ext {
480            assert!(e > 0.0, "non-positive energy: {e}");
481        }
482    }
483
484    #[test]
485    fn test_fine_structure_adds_points() {
486        // Test fine-structure in isolation (no intermediate points) by calling
487        // build_extended_grid_inner directly.
488        // Sparse grid with a narrow resonance at 500 eV, Gd = 1 eV.
489        // Grid has ~5 eV spacing → only ~0-1 point in [499, 501].
490        let data: Vec<f64> = (0..20).map(|i| 490.0 + i as f64 * 5.0).collect();
491        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
492        let resonances = vec![(500.0, 1.0)]; // E_res=500 eV, Gd=1 eV
493
494        // Without fine-structure, boundary-only:
495        let (ext_without, _) = build_extended_grid_inner(&data, Some(&res), &[], false);
496        // With fine-structure, still no intermediates:
497        let (ext_with, _) = build_extended_grid_inner(&data, Some(&res), &resonances, false);
498
499        assert!(
500            ext_with.len() > ext_without.len(),
501            "fine-structure should add points: {} vs {}",
502            ext_with.len(),
503            ext_without.len()
504        );
505
506        // Check that there are now ≥10 points in [499, 501].
507        let lo = ext_with.partition_point(|&e| e < 499.0);
508        let hi = ext_with.partition_point(|&e| e <= 501.0);
509        assert!(
510            hi - lo >= MIN_POINTS_PER_WIDTH,
511            "expected ≥{MIN_POINTS_PER_WIDTH} points in resonance width, got {}",
512            hi - lo
513        );
514    }
515
516    #[test]
517    fn test_fine_structure_skips_dense_grid() {
518        // Dense grid: 0.1 eV spacing around a resonance with Gd=1.0 eV.
519        // Already has ~20 points in [499, 501] → no fine-structure needed.
520        let data: Vec<f64> = (0..100).map(|i| 495.0 + i as f64 * 0.1).collect();
521        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
522        let resonances = vec![(500.0, 1.0)];
523
524        let (ext_without, _) = build_extended_grid(&data, Some(&res), &[]);
525        let (ext_with, _) = build_extended_grid(&data, Some(&res), &resonances);
526
527        assert_eq!(
528            ext_without.len(),
529            ext_with.len(),
530            "dense grid should not get extra fine-structure points"
531        );
532    }
533
534    #[test]
535    fn test_fine_structure_data_indices_valid() {
536        // Verify data points are still found correctly after fine-structure insertion.
537        let data: Vec<f64> = (0..20).map(|i| 490.0 + i as f64 * 5.0).collect();
538        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
539        let resonances = vec![(500.0, 1.0), (520.0, 0.5)];
540
541        let (ext, indices) = build_extended_grid(&data, Some(&res), &resonances);
542        assert_eq!(indices.len(), data.len());
543        for (i, &e) in data.iter().enumerate() {
544            assert!(
545                (ext[indices[i]] - e).abs() < 1e-10,
546                "data[{i}]={e} not at ext[{}]={}",
547                indices[i],
548                ext[indices[i]]
549            );
550        }
551    }
552
553    #[test]
554    fn test_fine_structure_outside_range_ignored() {
555        // Resonance outside data range should not add points.
556        let data: Vec<f64> = (0..10).map(|i| 100.0 + i as f64 * 10.0).collect();
557        let res = ResolutionParams::new(10.0, 0.01, 0.001, 0.0).unwrap();
558        let resonances = vec![(50.0, 1.0), (300.0, 1.0)]; // Both outside [100, 190]
559
560        let (ext_without, _) = build_extended_grid(&data, Some(&res), &[]);
561        let (ext_with, _) = build_extended_grid(&data, Some(&res), &resonances);
562
563        // May differ slightly due to boundary extension, but the resonance
564        // outside the extended range should not add fine-structure.
565        // Just verify the grid is valid.
566        assert!(ext_with.len() >= data.len());
567        for pair in ext_with.windows(2) {
568            assert!(
569                pair[0] < pair[1],
570                "grid not sorted: {} >= {}",
571                pair[0],
572                pair[1]
573            );
574        }
575        // Both resonances are far outside data range, should have same grid.
576        assert_eq!(ext_without.len(), ext_with.len());
577    }
578}