1use crate::constants::*;
23
24#[inline]
26pub fn planck(x: f64) -> f64 {
27 if x < 1e-6 {
28 1.0 / x - 0.5 + x / 12.0
30 } else if x > 500.0 {
31 (-x).exp()
32 } else {
33 1.0 / x.exp_m1()
36 }
37}
38
39#[inline]
41pub fn bose_einstein(x: f64, mu: f64) -> f64 {
42 let y = x + mu;
43 if y < 1e-6 {
44 1.0 / y - 0.5 + y / 12.0
45 } else if y > 500.0 {
46 (-y).exp()
47 } else {
48 1.0 / y.exp_m1()
49 }
50}
51
52#[inline]
55pub fn g_bb(x: f64) -> f64 {
56 if x < 1e-6 {
57 1.0 / x - 1.0 / 12.0 * x
58 } else if x > 500.0 {
59 x * (-x).exp()
60 } else {
61 let em1 = x.exp_m1();
64 x * (1.0 + em1) / (em1 * em1)
65 }
66}
67
68#[inline]
74pub fn mu_shape(x: f64) -> f64 {
75 (x / BETA_MU - 1.0) * g_bb(x) / x
76}
77
78#[inline]
84pub fn y_shape(x: f64) -> f64 {
85 if x < 1e-6 {
86 -2.0 / x + x / 3.0
89 } else {
90 let coth_half = (x / 2.0).cosh() / (x / 2.0).sinh();
91 g_bb(x) * (x * coth_half - 4.0)
92 }
93}
94
95pub fn spectral_integral(n: i32, x_min: f64, x_max: f64, num_points: usize) -> f64 {
98 let log_min = x_min.ln();
100 let log_max = x_max.ln();
101 let dlog = (log_max - log_min) / (num_points as f64 - 1.0);
102
103 let mut result = 0.0;
104 let mut prev_f = 0.0;
105 let mut prev_x = 0.0;
106
107 for i in 0..num_points {
108 let log_x = log_min + i as f64 * dlog;
109 let x = log_x.exp();
110 let f = x.powi(n) * planck(x);
111
112 if i > 0 {
113 result += 0.5 * (prev_f + f) * (x - prev_x);
114 }
115 prev_f = f;
116 prev_x = x;
117 }
118 result
119}
120
121pub fn compton_equilibrium_ratio(x_grid: &[f64], n: &[f64]) -> f64 {
127 let mut g3 = 0.0;
128 let mut i4 = 0.0;
129
130 for i in 1..x_grid.len() {
131 let dx = x_grid[i] - x_grid[i - 1];
132 let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
133 let n_mid = 0.5 * (n[i] + n[i - 1]);
134
135 let n_l = n[i - 1];
136 let n_r = n[i];
137 let nn1_mid = 0.5 * (n_l * (1.0 + n_l) + n_r * (1.0 + n_r));
138 g3 += x_mid.powi(3) * n_mid * dx;
139 i4 += x_mid.powi(4) * nn1_mid * dx;
140 }
141
142 if g3.abs() < 1e-300 {
143 return 1.0;
144 }
145 i4 / (4.0 * g3)
146}
147
148fn weighted_integral(x_grid: &[f64], delta_n: &[f64], power: i32, norm: f64) -> f64 {
150 let mut integral = 0.0;
151 for i in 1..x_grid.len() {
152 let dx = x_grid[i] - x_grid[i - 1];
153 let x_mid = 0.5 * (x_grid[i] + x_grid[i - 1]);
154 let dn_mid = 0.5 * (delta_n[i] + delta_n[i - 1]);
155 integral += x_mid.powi(power) * dn_mid * dx;
156 }
157 integral / norm
158}
159
160pub fn delta_rho_over_rho(x_grid: &[f64], delta_n: &[f64]) -> f64 {
162 weighted_integral(x_grid, delta_n, 3, G3_PLANCK)
163}
164
165pub fn delta_n_over_n(x_grid: &[f64], delta_n: &[f64]) -> f64 {
167 weighted_integral(x_grid, delta_n, 2, G2_PLANCK)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
184 fn test_planck_identity_analytical() {
185 for &x in &[0.01, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0] {
186 let n = planck(x);
187 let ex = x.exp();
188 let dn_dx = -ex / (ex - 1.0).powi(2);
190 let rhs = n * (1.0 + n);
191 let residual = dn_dx + rhs;
193 let rel_err = residual.abs() / rhs.abs().max(f64::MIN_POSITIVE);
198 assert!(
199 rel_err < 1e-13,
200 "Planck identity violated at x={x}: dn_dx={dn_dx:.6e}, \
201 n(1+n)={rhs:.6e}, residual={residual:.6e}, rel_err={rel_err:.3e}"
202 );
203 }
204 }
205
206 #[test]
207 fn test_planck_low_x() {
208 let x = 1e-8;
210 let n = planck(x);
211 assert!((n - 1.0 / x).abs() / (1.0 / x) < 1e-6);
212 }
213
214 #[test]
215 fn test_planck_moderate_x() {
216 let x = 1.0;
217 let expected = 1.0 / (1.0_f64.exp() - 1.0);
218 assert!((planck(x) - expected).abs() < 1e-14);
219 }
220
221 #[test]
222 fn test_bose_einstein_reduces_to_planck() {
223 let x = 3.0;
224 assert!((bose_einstein(x, 0.0) - planck(x)).abs() < 1e-14);
225 }
226
227 #[test]
228 fn test_mu_shape_sign_change_at_beta_mu() {
229 let eps = 1e-3;
235 let m_below = mu_shape(BETA_MU - eps);
236 let m_above = mu_shape(BETA_MU + eps);
237 assert!(
238 m_below < 0.0 && m_above > 0.0,
239 "M should flip sign at β_μ: M({:.4})={m_below:.3e}, M({:.4})={m_above:.3e}",
240 BETA_MU - eps,
241 BETA_MU + eps
242 );
243 }
244
245 #[test]
246 fn test_y_shape_zero_crossing() {
247 let mut x_lo = 3.5_f64;
250 let mut x_hi = 4.2_f64;
251 for _ in 0..100 {
252 let x_mid = (x_lo + x_hi) / 2.0;
253 if y_shape(x_mid) > 0.0 {
254 x_hi = x_mid;
255 } else {
256 x_lo = x_mid;
257 }
258 }
259 let x_zero = (x_lo + x_hi) / 2.0;
260 assert!(
261 (x_zero - 3.83).abs() < 0.01,
262 "Y_SZ zero at x = {x_zero}, expected ~3.83"
263 );
264 }
265
266 #[test]
267 fn test_spectral_integral_g3() {
268 let g3 = spectral_integral(3, 1e-6, 80.0, 100_000);
270 let expected = std::f64::consts::PI.powi(4) / 15.0;
271 let rel_err = (g3 - expected).abs() / expected;
272 assert!(
273 rel_err < 1e-6,
274 "G₃ = {g3}, expected {expected}, rel_err = {rel_err}"
275 );
276 }
277
278 #[test]
279 fn test_spectral_integral_g2() {
280 let g2 = spectral_integral(2, 1e-6, 80.0, 100_000);
282 let expected = G2_PLANCK;
283 let rel_err = (g2 - expected).abs() / expected;
284 assert!(
285 rel_err < 1e-5,
286 "G₂ = {g2}, expected {expected}, rel_err = {rel_err}"
287 );
288 }
289
290 #[test]
291 fn test_g_bb_all_branches() {
292 let x_low = 1e-8;
294 let g_low = g_bb(x_low);
295 assert!(
296 (g_low - 1.0 / x_low).abs() / (1.0 / x_low) < 1e-2,
297 "g_bb({x_low}) = {g_low}, expected ~1/x"
298 );
299
300 let x_high = 600.0;
302 let g_high = g_bb(x_high);
303 let expected = x_high * (-x_high).exp();
304 assert!(
305 (g_high - expected).abs() < 1e-250,
306 "g_bb({x_high}) = {g_high}, expected {expected}"
307 );
308
309 let x_mid = 3.0;
311 let g_mid = g_bb(x_mid);
312 let ex = x_mid.exp();
313 let expected_mid = x_mid * ex / (ex - 1.0).powi(2);
314 assert!(
315 (g_mid - expected_mid).abs() < 1e-14,
316 "g_bb({x_mid}) = {g_mid}, expected {expected_mid}"
317 );
318 }
319
320 #[test]
321 fn test_planck_high_x_branch() {
322 let x = 600.0;
323 let n = planck(x);
324 assert!((n - (-x).exp()).abs() < 1e-250, "planck({x}) = {n}");
325 }
326
327 #[test]
328 fn test_bose_einstein_branches() {
329 let x_low = 1e-8;
331 let mu = 0.0;
332 let n = bose_einstein(x_low, mu);
333 assert!((n - planck(x_low)).abs() < 1e-6, "BE ≈ Planck at mu=0");
334
335 let n_high = bose_einstein(500.0, 1.0);
337 assert!((n_high - (-501.0_f64).exp()).abs() < 1e-200);
338 }
339
340 #[test]
341 fn test_delta_rho_over_rho_and_delta_n_over_n() {
342 let n_pts = 5000;
344 let x_min: f64 = 1e-4;
345 let x_max: f64 = 50.0;
346 let log_min = x_min.ln();
347 let log_max = x_max.ln();
348 let x_grid: Vec<f64> = (0..n_pts)
349 .map(|i| (log_min + (log_max - log_min) * i as f64 / (n_pts - 1) as f64).exp())
350 .collect();
351
352 let dt_over_t = 1e-5;
354 let dn: Vec<f64> = x_grid.iter().map(|&x| dt_over_t * g_bb(x)).collect();
355
356 let drho = delta_rho_over_rho(&x_grid, &dn);
357 assert!(
359 (drho - 4.0 * dt_over_t).abs() / (4.0 * dt_over_t) < 0.01,
360 "Δρ/ρ = {drho:.4e}, expected {:.4e}",
361 4.0 * dt_over_t
362 );
363
364 let dn_n = delta_n_over_n(&x_grid, &dn);
365 assert!(
367 (dn_n - 3.0 * dt_over_t).abs() / (3.0 * dt_over_t) < 0.01,
368 "ΔN/N = {dn_n:.4e}, expected {:.4e}",
369 3.0 * dt_over_t
370 );
371 }
372
373 #[test]
374 fn test_compton_equilibrium_planck() {
375 let n_points = 5000;
377 let x_min = 1e-4_f64;
378 let x_max = 50.0_f64;
379 let log_min = x_min.ln();
380 let log_max = x_max.ln();
381 let x_grid: Vec<f64> = (0..n_points)
382 .map(|i| (log_min + (log_max - log_min) * i as f64 / (n_points - 1) as f64).exp())
383 .collect();
384 let n_vals: Vec<f64> = x_grid.iter().map(|&x| planck(x)).collect();
385
386 let ratio = compton_equilibrium_ratio(&x_grid, &n_vals);
387 assert!(
388 (ratio - 1.0).abs() < 1e-3,
389 "T_e^eq/T_z = {ratio}, expected 1.0 for Planck"
390 );
391 }
392}