1use crate::kernel::FusionKernel;
14use fusion_types::constants::E_FUSION_DT;
15use fusion_types::error::{FusionError, FusionResult};
16use fusion_types::state::ThermodynamicsResult;
17
18const N_PEAK: f64 = 1.0e20;
20
21const T_PEAK_KEV: f64 = 20.0;
23
24const DENSITY_EXPONENT: f64 = 0.5;
26
27const TEMPERATURE_EXPONENT: f64 = 1.0;
29
30const ALPHA_FRACTION: f64 = 0.2;
33
34const TAU_E: f64 = 3.0;
36
37const KEV_TO_JOULES: f64 = 1.602e-16;
39
40pub 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
61pub 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 let n_e = N_PEAK * one_minus.powf(DENSITY_EXPONENT); let t_kev = T_PEAK_KEV * one_minus.powf(TEMPERATURE_EXPONENT); if t_kev > t_peak_actual {
115 t_peak_actual = t_kev;
116 }
117
118 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; p_fusion_w += n_d * n_t * sigma_v * E_FUSION_DT * dv;
129
130 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; 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 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 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 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 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 assert!(result.q_factor > 0.0, "Q = {}", result.q_factor);
238
239 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}