spectroxide/
grid.rs

1//! Frequency grid for the photon Boltzmann equation.
2//!
3//! Non-uniform grid in dimensionless frequency x = hν/(kT_z), with
4//! logarithmic spacing at low x (where DC/BR are important) and
5//! linear or log spacing at higher x.
6
7/// A localized region of extra grid points for resolving narrow features.
8#[derive(Debug, Clone)]
9pub struct RefinementZone {
10    /// Center of the refinement region
11    pub x_center: f64,
12    /// Half-width of the refinement region
13    pub x_width: f64,
14    /// Number of extra points to add in this zone
15    pub n_points: usize,
16}
17
18/// Configuration for the frequency grid.
19///
20/// Presets [`Self::fast`] (500 points) and [`Self::production`] (4000 points)
21/// cover the common cases. Hand-rolled configurations must satisfy
22/// [`Self::validate`]: in particular, `x_max ≥ 30` for accurate G₃ integrals.
23#[derive(Debug, Clone)]
24pub struct GridConfig {
25    /// Lower grid bound in dimensionless frequency `x = hν/(kT_z)`.
26    /// Must be positive (log spacing). Default 1e-4.
27    pub x_min: f64,
28    /// Upper grid bound. Must be ≥ 30 for accurate spectral integrals.
29    pub x_max: f64,
30    /// Total number of grid points.
31    pub n_points: usize,
32    /// Transition frequency from log to linear spacing.
33    pub x_transition: f64,
34    /// Fraction of points allocated to the log region (must be in `[0, 1)`).
35    pub log_fraction: f64,
36    /// Optional refinement zones for extra resolution near injection features.
37    pub refinement_zones: Vec<RefinementZone>,
38}
39
40impl Default for GridConfig {
41    fn default() -> Self {
42        GridConfig {
43            x_min: 1e-4,
44            x_max: 50.0,
45            n_points: 2000,
46            x_transition: 0.1,
47            log_fraction: 0.3,
48            refinement_zones: Vec::new(),
49        }
50    }
51}
52
53impl GridConfig {
54    /// Production-quality grid: 4000 points, `x ∈ [1e-5, 60]`. Used for all
55    /// paper runs.
56    pub fn production() -> Self {
57        GridConfig {
58            x_min: 1e-5,
59            x_max: 60.0,
60            n_points: 4000,
61            x_transition: 0.5,
62            log_fraction: 0.35,
63            refinement_zones: Vec::new(),
64        }
65    }
66
67    /// Fast/testing grid: 500 points, `x ∈ [1e-4, 40]`. Suitable for quick
68    /// exploratory runs; distortion amplitudes are accurate to a few percent.
69    pub fn fast() -> Self {
70        GridConfig {
71            x_min: 1e-4,
72            x_max: 40.0,
73            n_points: 500,
74            x_transition: 0.1,
75            log_fraction: 0.3,
76            refinement_zones: Vec::new(),
77        }
78    }
79
80    /// Validate grid configuration parameters.
81    ///
82    /// Returns `Err` with a descriptive message if any parameter would cause
83    /// numerical failure or produce meaningless results.
84    pub fn validate(&self) -> Result<(), String> {
85        if !self.x_min.is_finite() || self.x_min <= 0.0 {
86            return Err(format!(
87                "x_min must be positive (log grid needs ln(x_min)), got {}",
88                self.x_min
89            ));
90        }
91        if !self.x_max.is_finite() || self.x_max <= self.x_min {
92            return Err(format!(
93                "x_max must be > x_min, got x_min={}, x_max={}",
94                self.x_min, self.x_max
95            ));
96        }
97        if self.x_max < 30.0 {
98            return Err(format!(
99                "x_max must be >= 30 for accurate G3 integrals (per CLAUDE.md pitfall #7: \
100                 the high-x tail ∫_{{x_max}}^∞ x³/(e^x−1) dx drops below ~1e-11 only for \
101                 x_max ≳ 30), got {}",
102                self.x_max
103            ));
104        }
105        if self.n_points < 10 {
106            return Err(format!("n_points must be >= 10, got {}", self.n_points));
107        }
108        if !self.log_fraction.is_finite() || self.log_fraction < 0.0 || self.log_fraction >= 1.0 {
109            return Err(format!(
110                "log_fraction must be in [0, 1), got {} (1.0 would place all points below x_transition)",
111                self.log_fraction
112            ));
113        }
114        if !self.x_transition.is_finite()
115            || self.x_transition <= self.x_min
116            || self.x_transition >= self.x_max
117        {
118            return Err(format!(
119                "x_transition must be in ({}, {}), got {}",
120                self.x_min, self.x_max, self.x_transition
121            ));
122        }
123        for (i, zone) in self.refinement_zones.iter().enumerate() {
124            if zone.x_width <= 0.0 {
125                return Err(format!(
126                    "refinement_zones[{}].x_width must be positive, got {}",
127                    i, zone.x_width
128                ));
129            }
130            if zone.n_points == 0 {
131                return Err(format!("refinement_zones[{}].n_points must be > 0", i));
132            }
133            if zone.x_center < self.x_min || zone.x_center > self.x_max {
134                return Err(format!(
135                    "refinement_zones[{}].x_center={} is outside grid range [{}, {}]",
136                    i, zone.x_center, self.x_min, self.x_max
137                ));
138            }
139        }
140        Ok(())
141    }
142
143    /// Add a refinement zone to this grid configuration.
144    pub fn with_refinement(mut self, zone: RefinementZone) -> Self {
145        self.refinement_zones.push(zone);
146        self
147    }
148}
149
150/// The frequency grid with precomputed helper arrays.
151#[derive(Debug, Clone)]
152pub struct FrequencyGrid {
153    /// Grid points x_i
154    pub x: Vec<f64>,
155    /// Grid spacing dx_i = x_{i+1} - x_i (length n-1)
156    pub dx: Vec<f64>,
157    /// Cell-center values: x_{i+1/2} = (x_i + x_{i+1}) / 2  (length n-1)
158    pub x_half: Vec<f64>,
159    /// Precomputed `x_half[j]³` (length n-1).
160    pub x_half_cubed: Vec<f64>,
161    /// Number of points
162    pub n: usize,
163}
164
165impl FrequencyGrid {
166    /// Build a FrequencyGrid from a sorted vector of grid points.
167    ///
168    /// # Panics
169    /// Panics if `x` has fewer than 2 elements.
170    pub fn from_points(x: Vec<f64>) -> Self {
171        assert!(
172            x.len() >= 2,
173            "FrequencyGrid::from_points requires at least 2 grid points, got {}",
174            x.len()
175        );
176        let n = x.len();
177        let dx: Vec<f64> = (0..n - 1).map(|i| x[i + 1] - x[i]).collect();
178        let x_half: Vec<f64> = (0..n - 1).map(|i| 0.5 * (x[i] + x[i + 1])).collect();
179        let x_half_cubed: Vec<f64> = x_half.iter().map(|&xh| xh * xh * xh).collect();
180        FrequencyGrid {
181            x,
182            dx,
183            x_half,
184            x_half_cubed,
185            n,
186        }
187    }
188
189    /// Create a frequency grid from configuration.
190    ///
191    /// Uses logarithmic spacing for x < x_transition and linear spacing above,
192    /// with a smooth blending zone around x_transition. The blending uses a
193    /// cubic Hermite (smoothstep) interpolation of the local spacing, avoiding
194    /// the discontinuous 30× jump in dx that a hard log-to-linear transition
195    /// would produce. This improves local truncation error near x_transition.
196    pub fn new(config: &GridConfig) -> Self {
197        let n = config.n_points;
198        let n_log = (config.log_fraction * n as f64) as usize;
199        let n_lin = n - n_log;
200
201        let mut x = Vec::with_capacity(n + 2);
202
203        let log_min = config.x_min.ln();
204        let log_max = config.x_transition.ln();
205        let dx_lin = (config.x_max - config.x_transition) / (n_lin - 1).max(1) as f64;
206        let dln = if n_log > 0 {
207            (log_max - log_min) / n_log as f64
208        } else {
209            0.0
210        };
211        // Log spacing at the transition point (in x-space)
212        let dx_log_at_tr = config.x_transition * dln;
213
214        // Number of blending points on each side: ~10% of each region
215        let n_blend_log = if n_log > 4 { (n_log / 10).max(2) } else { 0 };
216        let n_blend_lin = if n_lin > 4 { (n_lin / 10).max(2) } else { 0 };
217
218        // Phase 1: Pure log region [x_min, x_blend_start)
219        let n_pure_log = n_log.saturating_sub(n_blend_log);
220        for i in 0..n_pure_log {
221            let t = i as f64 / n_log as f64;
222            x.push((log_min + t * (log_max - log_min)).exp());
223        }
224
225        // Phase 2: Smooth blending zone (n_blend_log + n_blend_lin points)
226        let n_blend = n_blend_log + n_blend_lin;
227        if n_blend > 0 && dx_log_at_tr > 0.0 {
228            // If we already placed pure log points, advance from the last one
229            let mut x_curr = if !x.is_empty() {
230                // Step one log-spacing forward from last pure-log point
231                let t = n_pure_log as f64 / n_log as f64;
232                let dt = 1.0 / n_log as f64;
233                (log_min + (t + dt) * (log_max - log_min)).exp()
234            } else {
235                config.x_min
236            };
237            // Override: start from where we left off
238            if n_pure_log > 0 {
239                // Replace: first blend point is the next log point
240                x.push(x_curr);
241                for j in 1..n_blend {
242                    let s = j as f64 / n_blend as f64;
243                    // smoothstep: 3s² - 2s³
244                    let h = s * s * (3.0 - 2.0 * s);
245                    let dx_local = dx_log_at_tr * (1.0 - h) + dx_lin * h;
246                    x_curr += dx_local;
247                    x.push(x_curr);
248                }
249            } else {
250                for j in 0..n_blend {
251                    if j > 0 {
252                        let s = j as f64 / n_blend as f64;
253                        let h = s * s * (3.0 - 2.0 * s);
254                        let dx_local = dx_log_at_tr * (1.0 - h) + dx_lin * h;
255                        x_curr += dx_local;
256                    }
257                    x.push(x_curr);
258                }
259            }
260        }
261
262        // Phase 3: Pure linear region — fill remaining n_lin - n_blend_lin points
263        let n_pure_lin = n_lin.saturating_sub(n_blend_lin);
264        // The blend ended somewhere past x_transition. Fill remaining linear
265        // points from the blend endpoint to x_max.
266        let x_after_blend = x.last().copied().unwrap_or(config.x_transition);
267        if n_pure_lin > 0 {
268            let dx_remaining = (config.x_max - x_after_blend) / n_pure_lin as f64;
269            for i in 1..=n_pure_lin {
270                x.push(x_after_blend + i as f64 * dx_remaining);
271            }
272        }
273
274        // Sort and deduplicate
275        x.sort_by(|a, b| a.total_cmp(b));
276        x.dedup_by(|a, b| {
277            let avg = 0.5 * (*a + *b);
278            if avg < 1e-30 {
279                return (*a - *b).abs() < 1e-30;
280            }
281            (*a - *b).abs() / avg < 1e-10
282        });
283
284        // Merge refinement zone points
285        for zone in &config.refinement_zones {
286            let lo = (zone.x_center - zone.x_width).max(config.x_min);
287            let hi = (zone.x_center + zone.x_width).min(config.x_max);
288            if lo >= hi || zone.n_points == 0 {
289                continue;
290            }
291            let log_lo = lo.ln();
292            let log_hi = hi.ln();
293            for i in 0..zone.n_points {
294                let t = i as f64 / (zone.n_points - 1).max(1) as f64;
295                x.push((log_lo + t * (log_hi - log_lo)).exp());
296            }
297        }
298
299        // Sort and deduplicate with relative tolerance
300        if !config.refinement_zones.is_empty() {
301            x.sort_by(|a, b| a.total_cmp(b));
302            x.dedup_by(|a, b| {
303                let avg = 0.5 * (*a + *b);
304                if avg < 1e-30 {
305                    return (*a - *b).abs() < 1e-30;
306                }
307                (*a - *b).abs() / avg < 1e-10
308            });
309        }
310
311        Self::from_points(x)
312    }
313
314    /// Create a purely logarithmic grid (useful for testing).
315    ///
316    /// # Panics
317    /// Panics if `n < 2`.
318    pub fn log_uniform(x_min: f64, x_max: f64, n: usize) -> Self {
319        assert!(n >= 2, "log_uniform requires n >= 2, got {n}");
320        let log_min = x_min.ln();
321        let log_max = x_max.ln();
322        let x: Vec<f64> = (0..n)
323            .map(|i| (log_min + (log_max - log_min) * i as f64 / (n - 1) as f64).exp())
324            .collect();
325        Self::from_points(x)
326    }
327
328    /// Create a uniform grid (useful for testing).
329    ///
330    /// # Panics
331    /// Panics if `n < 2`.
332    pub fn uniform(x_min: f64, x_max: f64, n: usize) -> Self {
333        assert!(n >= 2, "uniform requires n >= 2, got {n}");
334        let x: Vec<f64> = (0..n)
335            .map(|i| x_min + (x_max - x_min) * i as f64 / (n - 1) as f64)
336            .collect();
337        Self::from_points(x)
338    }
339
340    /// Find the index of the grid point closest to a given x value.
341    pub fn find_index(&self, x_target: f64) -> usize {
342        match self.x.binary_search_by(|a| a.total_cmp(&x_target)) {
343            Ok(i) => i,
344            Err(i) => {
345                if i == 0 {
346                    0
347                } else if i >= self.n {
348                    self.n - 1
349                } else if (self.x[i] - x_target).abs() < (self.x[i - 1] - x_target).abs() {
350                    i
351                } else {
352                    i - 1
353                }
354            }
355        }
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn test_grid_dx_consistency() {
365        let grid = FrequencyGrid::new(&GridConfig::default());
366        for i in 0..grid.dx.len() {
367            let dx_expected = grid.x[i + 1] - grid.x[i];
368            assert!((grid.dx[i] - dx_expected).abs() < 1e-14);
369        }
370    }
371
372    #[test]
373    fn test_grid_log_region() {
374        // In the log region, dx/x should be approximately constant
375        let grid = FrequencyGrid::new(&GridConfig::default());
376        let n_log =
377            (GridConfig::default().log_fraction * GridConfig::default().n_points as f64) as usize;
378        if n_log > 2 {
379            let ratio_first = grid.dx[0] / grid.x[0];
380            let ratio_mid = grid.dx[n_log / 2] / grid.x[n_log / 2];
381            assert!(
382                (ratio_first - ratio_mid).abs() / ratio_first < 0.1,
383                "Log region should have ~constant dx/x"
384            );
385        }
386    }
387
388    #[test]
389    fn test_refinement_zone_adds_points() {
390        let base = GridConfig::default();
391        let base_grid = FrequencyGrid::new(&base);
392        let refined = GridConfig {
393            refinement_zones: vec![RefinementZone {
394                x_center: 0.1,
395                x_width: 0.05,
396                n_points: 200,
397            }],
398            ..GridConfig::default()
399        };
400        let refined_grid = FrequencyGrid::new(&refined);
401        assert!(
402            refined_grid.n > base_grid.n,
403            "Refined grid ({}) should have more points than base ({})",
404            refined_grid.n,
405            base_grid.n
406        );
407        // Check monotonicity
408        for i in 1..refined_grid.n {
409            assert!(
410                refined_grid.x[i] > refined_grid.x[i - 1],
411                "Refined grid not monotonic at i={i}: x[{i}]={} <= x[{}]={}",
412                refined_grid.x[i],
413                i - 1,
414                refined_grid.x[i - 1]
415            );
416        }
417    }
418
419    #[test]
420    fn test_refinement_zone_no_duplicates() {
421        let config = GridConfig {
422            refinement_zones: vec![RefinementZone {
423                x_center: 0.1,
424                x_width: 0.05,
425                n_points: 300,
426            }],
427            ..GridConfig::default()
428        };
429        let grid = FrequencyGrid::new(&config);
430        // No near-duplicate points (relative spacing > 1e-10)
431        for i in 1..grid.n {
432            let rel = (grid.x[i] - grid.x[i - 1]) / (0.5 * (grid.x[i] + grid.x[i - 1]));
433            assert!(
434                rel > 1e-10,
435                "Near-duplicate at i={i}: x[{i}]={:.6e}, x[{}]={:.6e}, rel={:.2e}",
436                grid.x[i],
437                i - 1,
438                grid.x[i - 1],
439                rel
440            );
441        }
442    }
443
444    #[test]
445    fn test_refinement_zone_density() {
446        // Points in the refinement zone should be denser than outside
447        let config = GridConfig {
448            refinement_zones: vec![RefinementZone {
449                x_center: 5.0,
450                x_width: 1.0,
451                n_points: 200,
452            }],
453            ..GridConfig::default()
454        };
455        let grid = FrequencyGrid::new(&config);
456        // Count points in [4,6] vs [6,8] (same width, no refinement)
457        let in_zone = grid.x.iter().filter(|&&x| x >= 4.0 && x <= 6.0).count();
458        let out_zone = grid.x.iter().filter(|&&x| x >= 6.0 && x <= 8.0).count();
459        assert!(
460            in_zone > out_zone * 2,
461            "Refinement zone should be at least 2x denser: {} in zone vs {} outside",
462            in_zone,
463            out_zone
464        );
465    }
466
467    #[test]
468    fn test_empty_refinement_unchanged() {
469        let base = GridConfig::default();
470        let base_grid = FrequencyGrid::new(&base);
471        let with_empty = GridConfig {
472            refinement_zones: Vec::new(),
473            ..GridConfig::default()
474        };
475        let empty_grid = FrequencyGrid::new(&with_empty);
476        assert_eq!(base_grid.n, empty_grid.n);
477    }
478}