fusion_core/
stability.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later | Commercial license available
2// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
3// © Code 2020–2026 Miroslav Šotek. All rights reserved.
4// ORCID: 0009-0009-3560-0851
5// Contact: www.anulum.li | protoscience@anulum.li
6// SCPN Fusion Core — Stability
7//! Stability analysis: decay index, force balance, eigenvalue analysis.
8//!
9//! Port of `stability_analyzer.py` (192 lines) and `force_balance.py` (103 lines).
10
11use 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
20/// Minor radius approximation: a = R/3. Python line 66.
21const MINOR_RADIUS_RATIO: f64 = 3.0;
22
23/// Internal inductance. Python line 67.
24const INTERNAL_INDUCTANCE: f64 = 0.8;
25
26/// Poloidal beta. Python line 68.
27const BETA_POLOIDAL: f64 = 0.5;
28
29/// Finite difference perturbation for Jacobian [m]. Python line 106.
30const FORCE_PERTURBATION: f64 = 0.01;
31
32/// Maximum decay index for stability. Python line 139.
33const MAX_STABLE_DECAY_INDEX: f64 = 1.5;
34
35/// Force balance convergence tolerance [N]. Python line 39.
36const FORCE_TOLERANCE: f64 = 1e4;
37
38/// Maximum Newton iterations. Python line 34.
39const MAX_NEWTON_ITER: usize = 10;
40
41/// Coil current perturbation for Jacobian [MA]. Python line 45.
42const COIL_PERTURBATION_MA: f64 = 0.1;
43
44/// Maximum current correction per step [MA]. Python line 72.
45const 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
77/// Calculate the decay index at a given position.
78///
79/// n = -(R/B_Z) · dB_Z/dR
80///
81/// Stable if 0 < n < 1.5.
82pub 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    // B_Z = (1/R) dΨ/dR at the target point
88    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    // dB_Z/dR via finite difference
102    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
131/// Calculate forces acting on a current-carrying plasma ring.
132///
133/// Returns (F_radial [N], F_vertical [N]).
134///
135/// F_hoop = (μ₀ I²/2) · [ln(8R/a) + β_p + l_i/2 - 1.5] / R
136/// F_Lorentz_R = I · B_Z · 2πR
137/// F_Lorentz_Z = -I · B_R · 2πR
138pub 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    // B-field at target point
161    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    // Shafranov hoop force (outward)
170    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    // Lorentz forces
175    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
189/// Full stability analysis: eigenvalue decomposition of the stiffness matrix.
190///
191/// Builds Jacobian K = [[dFr/dR, dFr/dZ], [dFz/dR, dFz/dZ]] via finite differences.
192/// Eigenvalues > 0 → stable, < 0 → unstable.
193pub 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    // Force at equilibrium
209    let (fr0, fz0) = calculate_forces(psi, grid, r_eq, z_eq, i_plasma_ma)?;
210
211    // Perturb R
212    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    // Perturb Z
216    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    // Stiffness matrix K = -dF/dx (negative sign: restoring force convention)
220    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
253/// Newton-Raphson force balance solver.
254///
255/// Adjusts outer coil currents (PF3, PF4) to achieve zero radial force
256/// at the target position.
257///
258/// Returns the number of iterations and final force residual.
259pub 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        // Compute vacuum field and force
287        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        // Compute Jacobian numerically for each control coil
295        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        // Newton correction: ΔI = -F_r / J (distribute equally among control coils)
313        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    // Final force evaluation
328    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        // Use PF3 (index 2) and PF4 (index 3) as control coils
401        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        // Should run without panic (may converge on first iteration or exhaust max_iter)
405        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}