fusion_core/
ignition.rs

1// ─────────────────────────────────────────────────────────────────────
2// SCPN Fusion Core — Ignition
3// © 1998–2026 Miroslav Šotek. All rights reserved.
4// Contact: www.anulum.li | protoscience@anulum.li
5// ORCID: https://orcid.org/0009-0009-3560-0851
6// License: GNU AGPL v3 | Commercial licensing available
7// ─────────────────────────────────────────────────────────────────────
8//! Ignition physics: Bosch-Hale D-T reaction rate and thermodynamics.
9//!
10//! Port of `fusion_ignition_sim.py` lines 14-107.
11//! Calculates fusion power, alpha heating, and Q-factor.
12
13use crate::kernel::FusionKernel;
14use fusion_types::constants::E_FUSION_DT;
15use fusion_types::error::{FusionError, FusionResult};
16use fusion_types::state::ThermodynamicsResult;
17
18/// Peak density [m⁻³]. Python line 56.
19const N_PEAK: f64 = 1.0e20;
20
21/// Peak temperature [keV]. Python line 57.
22const T_PEAK_KEV: f64 = 20.0;
23
24/// Density profile exponent. Python line 63.
25const DENSITY_EXPONENT: f64 = 0.5;
26
27/// Temperature profile exponent. Python line 64.
28const TEMPERATURE_EXPONENT: f64 = 1.0;
29
30/// Alpha particle energy fraction (3.5/17.6 MeV ≈ 0.199).
31/// Python line 82: `P_alpha = P_fusion * 0.2`
32const ALPHA_FRACTION: f64 = 0.2;
33
34/// Energy confinement time [s]. Python line 88.
35const TAU_E: f64 = 3.0;
36
37/// keV to Joules conversion: 1 keV = 1000 eV × 1.602e-19 J/eV
38const KEV_TO_JOULES: f64 = 1.602e-16;
39
40/// Bosch-Hale D-T fusion reaction rate ⟨σv⟩ in m³/s.
41///
42/// Uses NRL Plasma Formulary approximation:
43///   σv = 3.68e-18 / T^(2/3) × exp(-19.94 / T^(1/3))
44///
45/// Valid for T < 100 keV. Caller must provide finite T > 0.
46pub fn bosch_hale_dt(t_kev: f64) -> FusionResult<f64> {
47    if !t_kev.is_finite() || t_kev <= 0.0 {
48        return Err(FusionError::ConfigError(
49            "ignition temperature must be finite and > 0 keV".to_string(),
50        ));
51    }
52    let rate = 3.68e-18 / t_kev.powf(2.0 / 3.0) * (-19.94 / t_kev.powf(1.0 / 3.0)).exp();
53    if !rate.is_finite() {
54        return Err(FusionError::ConfigError(
55            "ignition Bosch-Hale rate became non-finite".to_string(),
56        ));
57    }
58    Ok(rate)
59}
60
61/// Calculate full thermodynamics from equilibrium state.
62///
63/// Algorithm:
64/// 1. Normalize flux: ψ_norm = (Ψ - Ψ_axis) / (Ψ_boundary - Ψ_axis)
65/// 2. Profiles: n(ψ) = n_peak·(1 - ψ²)^0.5, T(ψ) = T_peak·(1 - ψ²)^1.0
66/// 3. Fusion power: P_fus = ∫ nD·nT·⟨σv⟩·E_fus dV, dV = dR·dZ·2πR
67/// 4. Alpha heating: P_alpha = 0.2·P_fusion
68/// 5. Thermal energy: W_th = ∫ 3·n·T dV
69/// 6. Losses: P_loss = W_th / τ_E
70/// 7. Q = P_fusion / P_aux
71pub fn calculate_thermodynamics(
72    kernel: &FusionKernel,
73    p_aux_mw: f64,
74) -> FusionResult<ThermodynamicsResult> {
75    if !p_aux_mw.is_finite() || p_aux_mw < 0.0 {
76        return Err(FusionError::ConfigError(
77            "ignition p_aux_mw must be finite and >= 0".to_string(),
78        ));
79    }
80
81    let grid = kernel.grid();
82    let psi = kernel.psi();
83    let state = kernel.state();
84    let nz = grid.nz;
85    let nr = grid.nr;
86    let dr = grid.dr;
87    let dz = grid.dz;
88
89    let psi_axis = state.psi_axis;
90    let psi_boundary = state.psi_boundary;
91    let denom = psi_boundary - psi_axis;
92    if !denom.is_finite() || denom.abs() < 1e-9 {
93        return Err(FusionError::ConfigError(
94            "ignition flux normalization denominator must be finite and non-zero".to_string(),
95        ));
96    }
97
98    let mut p_fusion_w = 0.0_f64;
99    let mut w_thermal_j = 0.0_f64;
100    let mut t_peak_actual = 0.0_f64;
101
102    for iz in 0..nz {
103        for ir in 0..nr {
104            let psi_norm = (psi[[iz, ir]] - psi_axis) / denom;
105
106            if (0.0..1.0).contains(&psi_norm) {
107                let psi_norm_sq = psi_norm * psi_norm;
108                let one_minus = (1.0 - psi_norm_sq).max(0.0);
109
110                // Profiles
111                let n_e = N_PEAK * one_minus.powf(DENSITY_EXPONENT); // m⁻³
112                let t_kev = T_PEAK_KEV * one_minus.powf(TEMPERATURE_EXPONENT); // keV
113
114                if t_kev > t_peak_actual {
115                    t_peak_actual = t_kev;
116                }
117
118                // D-T: assume 50/50 mix → nD = nT = n_e / 2
119                let n_d = n_e / 2.0;
120                let n_t = n_e / 2.0;
121
122                let sigma_v = bosch_hale_dt(t_kev)?;
123
124                let r = grid.rr[[iz, ir]];
125                let dv = dr * dz * 2.0 * std::f64::consts::PI * r; // toroidal volume element
126
127                // Fusion power density × volume
128                p_fusion_w += n_d * n_t * sigma_v * E_FUSION_DT * dv;
129
130                // Thermal energy: W = 3 n T (using keV→J conversion)
131                w_thermal_j += 3.0 * n_e * t_kev * KEV_TO_JOULES * dv;
132            }
133        }
134    }
135
136    let p_fusion_mw = p_fusion_w / 1e6;
137    let p_alpha_mw = p_fusion_mw * ALPHA_FRACTION;
138    let w_thermal_mj = w_thermal_j / 1e6;
139    let p_loss_mw = w_thermal_mj / TAU_E; // MW = MJ/s
140
141    let q_factor = if p_aux_mw > 1e-9 {
142        p_fusion_mw / p_aux_mw
143    } else {
144        0.0
145    };
146
147    let net_mw = p_alpha_mw + p_aux_mw - p_loss_mw;
148
149    Ok(ThermodynamicsResult {
150        p_fusion_mw,
151        p_alpha_mw,
152        p_loss_mw,
153        p_aux_mw,
154        net_mw,
155        q_factor,
156        t_peak_kev: t_peak_actual,
157        w_thermal_mj,
158    })
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_bosch_hale_zero_temp() {
167        // At T=0.1 keV, rate should be extremely small
168        assert!(
169            bosch_hale_dt(0.1).expect("valid keV input") < 1e-30,
170            "Rate at T=0.1 keV should be tiny"
171        );
172    }
173
174    #[test]
175    fn test_bosch_hale_20kev() {
176        let rate = bosch_hale_dt(20.0).expect("valid keV input");
177        assert!(
178            rate > 1e-22 && rate < 1e-21,
179            "Rate at 20keV: {rate}, expected O(1e-22)"
180        );
181    }
182
183    #[test]
184    fn test_bosch_hale_peak_higher() {
185        let rate_60 = bosch_hale_dt(60.0).expect("valid keV input");
186        let rate_1 = bosch_hale_dt(1.0).expect("valid keV input");
187        assert!(
188            rate_60 > rate_1 * 1000.0,
189            "Rate at 60keV ({rate_60}) should be >> rate at 1keV ({rate_1})"
190        );
191    }
192
193    #[test]
194    fn test_bosch_hale_monotonic_rise() {
195        // Rate should increase monotonically from 1 to ~60 keV
196        let mut prev = bosch_hale_dt(1.0).expect("valid keV input");
197        for t in [5.0, 10.0, 20.0, 40.0, 60.0] {
198            let rate = bosch_hale_dt(t).expect("valid keV input");
199            assert!(
200                rate > prev,
201                "Rate should increase: T={t}, rate={rate}, prev={prev}"
202            );
203            prev = rate;
204        }
205    }
206
207    #[test]
208    fn test_thermodynamics_after_solve() {
209        use std::path::PathBuf;
210
211        fn project_root() -> PathBuf {
212            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
213                .join("..")
214                .join("..")
215                .join("..")
216        }
217        fn config_path(relative: &str) -> String {
218            project_root().join(relative).to_string_lossy().to_string()
219        }
220
221        // Use smaller grid for faster test
222        let mut kernel =
223            FusionKernel::from_file(&config_path("validation/iter_validated_config.json")).unwrap();
224        kernel.solve_equilibrium().unwrap();
225
226        let result = calculate_thermodynamics(&kernel, 50.0).expect("valid thermodynamics input");
227
228        // Fusion power should be positive and finite
229        assert!(
230            result.p_fusion_mw > 0.0,
231            "P_fusion = {}",
232            result.p_fusion_mw
233        );
234        assert!(result.p_fusion_mw.is_finite(), "P_fusion must be finite");
235
236        // Q-factor should be positive
237        assert!(result.q_factor > 0.0, "Q = {}", result.q_factor);
238
239        // Alpha power should be 20% of fusion
240        let alpha_ratio = result.p_alpha_mw / result.p_fusion_mw;
241        assert!(
242            (alpha_ratio - 0.2).abs() < 1e-10,
243            "Alpha ratio = {alpha_ratio}, expected 0.2"
244        );
245    }
246
247    #[test]
248    fn test_ignition_rejects_invalid_inputs() {
249        assert!(bosch_hale_dt(0.0).is_err());
250        assert!(bosch_hale_dt(f64::NAN).is_err());
251
252        use std::path::PathBuf;
253        fn project_root() -> PathBuf {
254            PathBuf::from(env!("CARGO_MANIFEST_DIR"))
255                .join("..")
256                .join("..")
257                .join("..")
258        }
259        fn config_path(relative: &str) -> String {
260            project_root().join(relative).to_string_lossy().to_string()
261        }
262
263        let kernel =
264            FusionKernel::from_file(&config_path("validation/iter_validated_config.json")).unwrap();
265        assert!(calculate_thermodynamics(&kernel, f64::NAN).is_err());
266    }
267}