1#[derive(Debug, Clone)]
9pub struct RefinementZone {
10 pub x_center: f64,
12 pub x_width: f64,
14 pub n_points: usize,
16}
17
18#[derive(Debug, Clone)]
24pub struct GridConfig {
25 pub x_min: f64,
28 pub x_max: f64,
30 pub n_points: usize,
32 pub x_transition: f64,
34 pub log_fraction: f64,
36 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 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 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 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 pub fn with_refinement(mut self, zone: RefinementZone) -> Self {
145 self.refinement_zones.push(zone);
146 self
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct FrequencyGrid {
153 pub x: Vec<f64>,
155 pub dx: Vec<f64>,
157 pub x_half: Vec<f64>,
159 pub x_half_cubed: Vec<f64>,
161 pub n: usize,
163}
164
165impl FrequencyGrid {
166 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 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 let dx_log_at_tr = config.x_transition * dln;
213
214 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 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 let n_blend = n_blend_log + n_blend_lin;
227 if n_blend > 0 && dx_log_at_tr > 0.0 {
228 let mut x_curr = if !x.is_empty() {
230 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 if n_pure_log > 0 {
239 x.push(x_curr);
241 for j in 1..n_blend {
242 let s = j as f64 / n_blend as f64;
243 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 let n_pure_lin = n_lin.saturating_sub(n_blend_lin);
264 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 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 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 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 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 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 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 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 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 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 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 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}