1use crate::vacuum::calculate_vacuum_field;
12use fusion_math::interp::{gradient_2d, interp2d};
13use fusion_math::linalg::eig_2x2;
14use fusion_types::config::ReactorConfig;
15use fusion_types::constants::MU0_SI;
16use fusion_types::error::{FusionError, FusionResult};
17use fusion_types::state::{Grid2D, StabilityResult};
18use ndarray::Array2;
19
20const MINOR_RADIUS_RATIO: f64 = 3.0;
22
23const INTERNAL_INDUCTANCE: f64 = 0.8;
25
26const BETA_POLOIDAL: f64 = 0.5;
28
29const FORCE_PERTURBATION: f64 = 0.01;
31
32const MAX_STABLE_DECAY_INDEX: f64 = 1.5;
34
35const FORCE_TOLERANCE: f64 = 1e4;
37
38const MAX_NEWTON_ITER: usize = 10;
40
41const COIL_PERTURBATION_MA: f64 = 0.1;
43
44const MAX_CURRENT_CORRECTION_MA: f64 = 5.0;
46
47const MIN_RADIUS_M: f64 = 1e-6;
48
49fn validate_stability_inputs(psi: &Array2<f64>, grid: &Grid2D, r: f64, z: f64) -> FusionResult<()> {
50 if psi.nrows() != grid.nz || psi.ncols() != grid.nr {
51 return Err(FusionError::ConfigError(format!(
52 "stability psi shape mismatch: expected ({}, {}), got ({}, {})",
53 grid.nz,
54 grid.nr,
55 psi.nrows(),
56 psi.ncols()
57 )));
58 }
59 if psi.iter().any(|v| !v.is_finite()) {
60 return Err(FusionError::ConfigError(
61 "stability psi must contain only finite values".to_string(),
62 ));
63 }
64 if !r.is_finite() || r <= MIN_RADIUS_M {
65 return Err(FusionError::ConfigError(format!(
66 "stability radius r must be finite and > {MIN_RADIUS_M}, got {r}"
67 )));
68 }
69 if !z.is_finite() {
70 return Err(FusionError::ConfigError(format!(
71 "stability vertical coordinate z must be finite, got {z}"
72 )));
73 }
74 Ok(())
75}
76
77pub fn decay_index(psi: &Array2<f64>, grid: &Grid2D, r: f64, z: f64) -> FusionResult<f64> {
83 validate_stability_inputs(psi, grid, r, z)?;
84
85 let (_dpsi_dz, dpsi_dr) = gradient_2d(psi, grid);
86
87 let bz = interp2d(&dpsi_dr, grid, r, z) / r;
89 if !bz.is_finite() {
90 return Err(FusionError::ConfigError(
91 "decay_index computed non-finite B_Z".to_string(),
92 ));
93 }
94
95 if bz.abs() < 1e-12 {
96 return Err(FusionError::ConfigError(
97 "decay_index undefined when |B_Z| is near zero".to_string(),
98 ));
99 }
100
101 let eps = FORCE_PERTURBATION;
103 if r <= eps {
104 return Err(FusionError::ConfigError(format!(
105 "decay_index requires r > perturbation epsilon ({eps})"
106 )));
107 }
108 let bz_plus = interp2d(&dpsi_dr, grid, r + eps, z) / (r + eps);
109 let bz_minus = interp2d(&dpsi_dr, grid, r - eps, z) / (r - eps);
110 if !bz_plus.is_finite() || !bz_minus.is_finite() {
111 return Err(FusionError::ConfigError(
112 "decay_index finite-difference B_Z samples became non-finite".to_string(),
113 ));
114 }
115 let dbz_dr = (bz_plus - bz_minus) / (2.0 * eps);
116 if !dbz_dr.is_finite() {
117 return Err(FusionError::ConfigError(
118 "decay_index dB_Z/dR became non-finite".to_string(),
119 ));
120 }
121
122 let n = -(r / bz) * dbz_dr;
123 if !n.is_finite() {
124 return Err(FusionError::ConfigError(
125 "decay_index result became non-finite".to_string(),
126 ));
127 }
128 Ok(n)
129}
130
131pub fn calculate_forces(
139 psi: &Array2<f64>,
140 grid: &Grid2D,
141 r: f64,
142 z: f64,
143 i_plasma_ma: f64,
144) -> FusionResult<(f64, f64)> {
145 validate_stability_inputs(psi, grid, r, z)?;
146 if !i_plasma_ma.is_finite() {
147 return Err(FusionError::ConfigError(format!(
148 "i_plasma_ma must be finite, got {i_plasma_ma}"
149 )));
150 }
151
152 let i_amp = i_plasma_ma * 1e6;
153 if !i_amp.is_finite() {
154 return Err(FusionError::ConfigError(
155 "plasma current conversion to amperes became non-finite".to_string(),
156 ));
157 }
158 let (dpsi_dz, dpsi_dr) = gradient_2d(psi, grid);
159
160 let bz = interp2d(&dpsi_dr, grid, r, z) / r;
162 let br = -interp2d(&dpsi_dz, grid, r, z) / r;
163 if !bz.is_finite() || !br.is_finite() {
164 return Err(FusionError::ConfigError(
165 "calculate_forces computed non-finite B-field components".to_string(),
166 ));
167 }
168
169 let a = r / MINOR_RADIUS_RATIO;
171 let shafranov_term = (8.0 * r / a).ln() + BETA_POLOIDAL + INTERNAL_INDUCTANCE / 2.0 - 1.5;
172 let f_hoop = (MU0_SI * i_amp * i_amp / 2.0) * shafranov_term / r;
173
174 let f_lorentz_r = i_amp * bz * 2.0 * std::f64::consts::PI * r;
176 let f_lorentz_z = -i_amp * br * 2.0 * std::f64::consts::PI * r;
177
178 let f_radial = f_hoop + f_lorentz_r;
179 let f_vertical = f_lorentz_z;
180 if !f_radial.is_finite() || !f_vertical.is_finite() {
181 return Err(FusionError::ConfigError(
182 "calculate_forces produced non-finite force values".to_string(),
183 ));
184 }
185
186 Ok((f_radial, f_vertical))
187}
188
189pub fn analyze_stability(
194 psi: &Array2<f64>,
195 grid: &Grid2D,
196 r_eq: f64,
197 z_eq: f64,
198 i_plasma_ma: f64,
199) -> FusionResult<StabilityResult> {
200 validate_stability_inputs(psi, grid, r_eq, z_eq)?;
201 if !i_plasma_ma.is_finite() {
202 return Err(FusionError::ConfigError(format!(
203 "i_plasma_ma must be finite, got {i_plasma_ma}"
204 )));
205 }
206 let eps = FORCE_PERTURBATION;
207
208 let (fr0, fz0) = calculate_forces(psi, grid, r_eq, z_eq, i_plasma_ma)?;
210
211 let (fr_rp, fz_rp) = calculate_forces(psi, grid, r_eq + eps, z_eq, i_plasma_ma)?;
213 let (fr_rm, fz_rm) = calculate_forces(psi, grid, r_eq - eps, z_eq, i_plasma_ma)?;
214
215 let (fr_zp, fz_zp) = calculate_forces(psi, grid, r_eq, z_eq + eps, i_plasma_ma)?;
217 let (fr_zm, fz_zm) = calculate_forces(psi, grid, r_eq, z_eq - eps, i_plasma_ma)?;
218
219 let k_rr = -(fr_rp - fr_rm) / (2.0 * eps);
221 let k_rz = -(fr_zp - fr_zm) / (2.0 * eps);
222 let k_zr = -(fz_rp - fz_rm) / (2.0 * eps);
223 let k_zz = -(fz_zp - fz_zm) / (2.0 * eps);
224
225 let k_matrix = [[k_rr, k_rz], [k_zr, k_zz]];
226 let (eigenvalues, eigenvectors) = eig_2x2(&k_matrix);
227
228 let n = decay_index(psi, grid, r_eq, z_eq)?;
229 let is_stable = n > 0.0 && n < MAX_STABLE_DECAY_INDEX;
230
231 let result = StabilityResult {
232 eigenvalues,
233 eigenvectors,
234 decay_index: n,
235 radial_force_mn: fr0 / 1e6,
236 vertical_force_mn: fz0 / 1e6,
237 is_stable,
238 };
239 if !result.eigenvalues[0].is_finite()
240 || !result.eigenvalues[1].is_finite()
241 || !result.decay_index.is_finite()
242 || !result.radial_force_mn.is_finite()
243 || !result.vertical_force_mn.is_finite()
244 {
245 return Err(FusionError::ConfigError(
246 "analyze_stability produced non-finite outputs".to_string(),
247 ));
248 }
249
250 Ok(result)
251}
252
253pub fn solve_force_balance(
260 config: &mut ReactorConfig,
261 grid: &Grid2D,
262 r_target: f64,
263 z_target: f64,
264 control_coil_indices: &[usize],
265) -> FusionResult<(usize, f64)> {
266 if !r_target.is_finite() || !z_target.is_finite() {
267 return Err(FusionError::ConfigError(
268 "force-balance target coordinates must be finite".to_string(),
269 ));
270 }
271 if control_coil_indices.is_empty() {
272 return Err(FusionError::ConfigError(
273 "force-balance requires at least one control coil index".to_string(),
274 ));
275 }
276
277 let mu0 = config.physics.vacuum_permeability;
278 let i_plasma_ma = config.physics.plasma_current_target / 1e6;
279 if !mu0.is_finite() || !i_plasma_ma.is_finite() {
280 return Err(FusionError::ConfigError(
281 "force-balance physics constants must be finite".to_string(),
282 ));
283 }
284
285 for iter in 0..MAX_NEWTON_ITER {
286 let psi = calculate_vacuum_field(grid, &config.coils, mu0)?;
288 let (f_r, _f_z) = calculate_forces(&psi, grid, r_target, z_target, i_plasma_ma)?;
289
290 if f_r.abs() < FORCE_TOLERANCE {
291 return Ok((iter, f_r.abs()));
292 }
293
294 let n_controls = control_coil_indices.len();
296 let mut jacobian = vec![0.0; n_controls];
297
298 for (j, &coil_idx) in control_coil_indices.iter().enumerate() {
299 if coil_idx < config.coils.len() {
300 let original = config.coils[coil_idx].current;
301 config.coils[coil_idx].current = original + COIL_PERTURBATION_MA;
302
303 let psi_pert = calculate_vacuum_field(grid, &config.coils, mu0)?;
304 let (f_r_pert, _) =
305 calculate_forces(&psi_pert, grid, r_target, z_target, i_plasma_ma)?;
306
307 jacobian[j] = (f_r_pert - f_r) / COIL_PERTURBATION_MA;
308 config.coils[coil_idx].current = original;
309 }
310 }
311
312 let j_total: f64 = jacobian.iter().sum();
314 if j_total.abs() < 1e-10 {
315 break;
316 }
317
318 let delta_i = (-f_r / j_total).clamp(-MAX_CURRENT_CORRECTION_MA, MAX_CURRENT_CORRECTION_MA);
319
320 for &coil_idx in control_coil_indices {
321 if coil_idx < config.coils.len() {
322 config.coils[coil_idx].current += delta_i;
323 }
324 }
325 }
326
327 let psi = calculate_vacuum_field(grid, &config.coils, mu0)?;
329 let (f_r, _) = calculate_forces(&psi, grid, r_target, z_target, i_plasma_ma)?;
330 Ok((MAX_NEWTON_ITER, f_r.abs()))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use fusion_types::config::ReactorConfig;
337 use std::path::PathBuf;
338
339 fn project_root() -> PathBuf {
340 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
341 .join("..")
342 .join("..")
343 .join("..")
344 }
345
346 fn config_path(relative: &str) -> String {
347 project_root().join(relative).to_string_lossy().to_string()
348 }
349
350 #[test]
351 fn test_decay_index_finite() {
352 let cfg = ReactorConfig::from_file(&config_path("iter_config.json")).unwrap();
353 let grid = cfg.create_grid();
354 let psi = calculate_vacuum_field(&grid, &cfg.coils, cfg.physics.vacuum_permeability)
355 .expect("valid vacuum-field inputs");
356
357 let n = decay_index(&psi, &grid, 6.2, 0.0).expect("valid finite decay-index inputs");
358 assert!(n.is_finite(), "Decay index should be finite: {n}");
359 }
360
361 #[test]
362 fn test_forces_finite() {
363 let cfg = ReactorConfig::from_file(&config_path("iter_config.json")).unwrap();
364 let grid = cfg.create_grid();
365 let psi = calculate_vacuum_field(&grid, &cfg.coils, cfg.physics.vacuum_permeability)
366 .expect("valid vacuum-field inputs");
367
368 let (fr, fz) =
369 calculate_forces(&psi, &grid, 6.2, 0.0, 15.0).expect("valid finite force inputs");
370 assert!(fr.is_finite(), "Radial force should be finite: {fr}");
371 assert!(fz.is_finite(), "Vertical force should be finite: {fz}");
372 }
373
374 #[test]
375 fn test_stability_analysis() {
376 let cfg = ReactorConfig::from_file(&config_path("iter_config.json")).unwrap();
377 let grid = cfg.create_grid();
378 let psi = calculate_vacuum_field(&grid, &cfg.coils, cfg.physics.vacuum_permeability)
379 .expect("valid vacuum-field inputs");
380
381 let result = analyze_stability(&psi, &grid, 6.2, 0.0, 15.0)
382 .expect("valid finite stability-analysis inputs");
383 assert!(
384 result.eigenvalues[0].is_finite() && result.eigenvalues[1].is_finite(),
385 "Eigenvalues should be finite"
386 );
387 assert!(
388 result.decay_index.is_finite(),
389 "Decay index should be finite"
390 );
391 }
392
393 #[test]
394 fn test_force_balance_runs() {
395 let mut cfg =
396 ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
397 .unwrap();
398 let grid = cfg.create_grid();
399
400 let (iters, final_force) = solve_force_balance(&mut cfg, &grid, 6.2, 0.0, &[2, 3])
402 .expect("valid finite force-balance inputs");
403
404 assert!(iters <= 10, "Should not exceed max iterations");
406 assert!(final_force.is_finite(), "Final force should be finite");
407 }
408
409 #[test]
410 fn test_stability_rejects_invalid_runtime_inputs() {
411 let cfg = ReactorConfig::from_file(&config_path("iter_config.json")).unwrap();
412 let grid = cfg.create_grid();
413 let psi = calculate_vacuum_field(&grid, &cfg.coils, cfg.physics.vacuum_permeability)
414 .expect("valid vacuum-field inputs");
415
416 assert!(decay_index(&psi, &grid, f64::NAN, 0.0).is_err());
417 assert!(calculate_forces(&psi, &grid, 0.0, 0.0, 15.0).is_err());
418 assert!(analyze_stability(&psi, &grid, 0.0, 0.0, 15.0).is_err());
419
420 let mut cfg2 = ReactorConfig::from_file(&config_path("iter_config.json")).unwrap();
421 assert!(solve_force_balance(&mut cfg2, &grid, 6.2, 0.0, &[]).is_err());
422 }
423}