fusion_core/
kernel.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//! FusionKernel — The Grad-Shafranov equilibrium solver.
7//!
8//! Port of fusion_kernel.py `FusionKernel` class (lines 173-313).
9//! Implements the Picard iteration loop with nonlinear source term,
10//! X-point detection, and convergence tracking.
11
12use crate::bfield::compute_b_field;
13use crate::particles::{
14    blend_particle_current, deposit_toroidal_current_density, summarize_particle_population,
15    ChargedParticle, ParticlePopulationSummary,
16};
17use crate::source::{
18    update_plasma_source_nonlinear, update_plasma_source_with_profiles, ProfileParams,
19    SourceProfileContext,
20};
21use crate::vacuum::calculate_vacuum_field;
22use crate::xpoint::find_x_point;
23use fusion_math::multigrid::{multigrid_solve, MultigridConfig};
24use fusion_math::sor::sor_solve;
25use fusion_types::config::ReactorConfig;
26use fusion_types::error::{FusionError, FusionResult};
27use fusion_types::state::{EquilibriumResult, Grid2D, PlasmaState};
28use ndarray::{Array1, Array2};
29
30/// Gaussian sigma for seed current distribution (Python line 193: `sigma = 1.0`).
31const SEED_GAUSSIAN_SIGMA: f64 = 1.0;
32
33/// Number of Jacobi iterations for initial elliptic solve (Python line 204: `range(50)`).
34const INITIAL_JACOBI_ITERS: usize = 50;
35/// Number of inner iterations for profile-sensitive solves (inverse workflows).
36const INNER_SOLVE_ITERS: usize = 50;
37
38/// Minimum Ψ_axis to avoid normalization singularity (Python line 218: `1e-6`).
39const MIN_PSI_AXIS: f64 = 1e-6;
40
41/// Minimum separation between Ψ_axis and Ψ_boundary (Python line 225: `0.1`).
42const MIN_PSI_SEPARATION: f64 = 0.1;
43
44/// Fallback factor when axis/boundary too close (Python line 226: `0.1`).
45const LIMITER_FALLBACK_FACTOR: f64 = 0.1;
46const MIN_CURRENT_INTEGRAL: f64 = 1e-9;
47
48/// Single Jacobi sweep matching Python fusion_kernel._jacobi_step().
49fn jacobi_step_python(psi: &Array2<f64>, source: &Array2<f64>, dr: f64) -> Array2<f64> {
50    let (nz, nr) = psi.dim();
51    let mut psi_new = psi.clone();
52    let dr2 = dr * dr;
53    for iz in 1..nz - 1 {
54        for ir in 1..nr - 1 {
55            psi_new[[iz, ir]] = 0.25
56                * (psi[[iz - 1, ir]] + psi[[iz + 1, ir]] + psi[[iz, ir - 1]] + psi[[iz, ir + 1]]
57                    - dr2 * source[[iz, ir]]);
58        }
59    }
60    psi_new
61}
62
63/// One red-black SOR sweep matching Python fusion_kernel._sor_step().
64fn sor_step_python(psi: &mut Array2<f64>, source: &Array2<f64>, grid: &Grid2D, omega: f64) {
65    let (nz, nr) = psi.dim();
66    let dr2 = grid.dr * grid.dr;
67    let dz2 = grid.dz * grid.dz;
68    let a_ns = 1.0 / dz2;
69    let a_c = 2.0 / dr2 + 2.0 / dz2;
70
71    for parity in [0_usize, 1_usize] {
72        for iz in 1..nz - 1 {
73            for ir in 1..nr - 1 {
74                if (iz + ir) % 2 != parity {
75                    continue;
76                }
77                let r = grid.rr[[iz, ir]].max(1e-10);
78                let a_e = 1.0 / dr2 - 1.0 / (2.0 * r * grid.dr);
79                let a_w = 1.0 / dr2 + 1.0 / (2.0 * r * grid.dr);
80                let gs_update = (a_e * psi[[iz, ir + 1]]
81                    + a_w * psi[[iz, ir - 1]]
82                    + a_ns * psi[[iz - 1, ir]]
83                    + a_ns * psi[[iz + 1, ir]]
84                    - source[[iz, ir]])
85                    / a_c;
86                let old = psi[[iz, ir]];
87                psi[[iz, ir]] = (1.0 - omega) * old + omega * gs_update;
88            }
89        }
90    }
91}
92
93/// Selects the inner linear solver used in Picard iteration.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SolverMethod {
96    /// Red-Black SOR inner solve (default, ω = 1.8).
97    PicardSor,
98    /// Geometric multigrid V-cycle inner solve.
99    PicardMultigrid,
100}
101
102/// The Grad-Shafranov equilibrium solver.
103pub struct FusionKernel {
104    config: ReactorConfig,
105    grid: Grid2D,
106    state: PlasmaState,
107    external_profile_mode: bool,
108    profile_params_p: Option<ProfileParams>,
109    profile_params_ff: Option<ProfileParams>,
110    particle_current_feedback: Option<Array2<f64>>,
111    particle_feedback_coupling: f64,
112    solver_method: SolverMethod,
113}
114
115impl FusionKernel {
116    /// Create a new kernel from configuration.
117    pub fn new(config: ReactorConfig) -> Self {
118        let grid = config.create_grid();
119        let state = PlasmaState::new(grid.nz, grid.nr);
120
121        FusionKernel {
122            config,
123            grid,
124            state,
125            external_profile_mode: false,
126            profile_params_p: None,
127            profile_params_ff: None,
128            particle_current_feedback: None,
129            particle_feedback_coupling: 0.0,
130            solver_method: SolverMethod::PicardSor,
131        }
132    }
133
134    /// Create a new kernel from a JSON config file.
135    pub fn from_file(path: &str) -> FusionResult<Self> {
136        let config = ReactorConfig::from_file(path)?;
137        Ok(Self::new(config))
138    }
139
140    /// Main equilibrium solver. Port of solve_equilibrium().
141    ///
142    /// Algorithm:
143    /// 1. Compute vacuum field from coils
144    /// 2. Seed Gaussian current distribution
145    /// 3. Initial Jacobi solve
146    /// 4. Picard iteration loop:
147    ///    a. Find O-point (axis) and X-point
148    ///    b. Update nonlinear source term
149    ///    c. Jacobi elliptic solve
150    ///    d. Apply vacuum boundary conditions
151    ///    e. Relax: Ψ = (1-α)Ψ_old + α Ψ_new
152    ///    f. Check convergence
153    pub fn solve_equilibrium(&mut self) -> FusionResult<EquilibriumResult> {
154        let start = std::time::Instant::now();
155        let mu0 = self.config.physics.vacuum_permeability;
156        let i_target = self.config.physics.plasma_current_target;
157        let max_iter = self.config.solver.max_iterations;
158        let tol = self.config.solver.convergence_threshold;
159        let alpha = self.config.solver.relaxation_factor;
160        let sor_omega = self.config.solver.sor_omega;
161
162        let nz = self.grid.nz;
163        let nr = self.grid.nr;
164        let dr = self.grid.dr;
165        let dz = self.grid.dz;
166
167        if !mu0.is_finite() || mu0 <= 0.0 {
168            return Err(FusionError::ConfigError(
169                "physics.vacuum_permeability must be finite and > 0".to_string(),
170            ));
171        }
172        if !i_target.is_finite() {
173            return Err(FusionError::PhysicsViolation(
174                "physics.plasma_current_target must be finite".to_string(),
175            ));
176        }
177        if max_iter == 0 {
178            return Err(FusionError::ConfigError(
179                "solver.max_iterations must be >= 1".to_string(),
180            ));
181        }
182        if !tol.is_finite() || tol <= 0.0 {
183            return Err(FusionError::ConfigError(
184                "solver.convergence_threshold must be finite and > 0".to_string(),
185            ));
186        }
187        if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
188            return Err(FusionError::ConfigError(format!(
189                "solver.relaxation_factor must be finite and in (0,1], got {alpha}"
190            )));
191        }
192        if !sor_omega.is_finite() || sor_omega <= 0.0 || sor_omega >= 2.0 {
193            return Err(FusionError::ConfigError(format!(
194                "solver.sor_omega must be finite and in (0,2), got {sor_omega}"
195            )));
196        }
197        if nz < 2 || nr < 2 {
198            return Err(FusionError::ConfigError(format!(
199                "grid dimensions must be >= 2 in both axes, got nz={nz}, nr={nr}"
200            )));
201        }
202        if self.grid.r.len() != nr || self.grid.z.len() != nz {
203            return Err(FusionError::ConfigError(format!(
204                "grid axis lengths must match metadata, got r_len={}, nr={}, z_len={}, nz={}",
205                self.grid.r.len(),
206                nr,
207                self.grid.z.len(),
208                nz
209            )));
210        }
211        if self.grid.rr.dim() != (nz, nr) || self.grid.zz.dim() != (nz, nr) {
212            return Err(FusionError::ConfigError(format!(
213                "grid mesh shape mismatch: expected ({nz}, {nr}), rr={:?}, zz={:?}",
214                self.grid.rr.dim(),
215                self.grid.zz.dim()
216            )));
217        }
218        if self.grid.r.iter().any(|v| !v.is_finite()) || self.grid.z.iter().any(|v| !v.is_finite())
219        {
220            return Err(FusionError::ConfigError(
221                "grid axes must contain only finite coordinates".to_string(),
222            ));
223        }
224        if self.grid.rr.iter().any(|v| !v.is_finite())
225            || self.grid.zz.iter().any(|v| !v.is_finite())
226        {
227            return Err(FusionError::ConfigError(
228                "grid mesh coordinates must be finite".to_string(),
229            ));
230        }
231        if !dr.is_finite() || !dz.is_finite() || dr <= 0.0 || dz <= 0.0 {
232            return Err(FusionError::ConfigError(format!(
233                "grid spacing must be finite and > 0, got dr={dr}, dz={dz}"
234            )));
235        }
236        if self.state.j_phi.dim() != (nz, nr) {
237            return Err(FusionError::ConfigError(format!(
238                "state j_phi shape mismatch: j_phi={:?}, grid=({}, {})",
239                self.state.j_phi.dim(),
240                nz,
241                nr
242            )));
243        }
244        let external_profiles: Option<(ProfileParams, ProfileParams)> =
245            if self.external_profile_mode {
246                let params_p = self.profile_params_p.ok_or_else(|| {
247                    FusionError::ConfigError(
248                        "external profile mode requires params_p to be set".to_string(),
249                    )
250                })?;
251                let params_ff = self.profile_params_ff.ok_or_else(|| {
252                    FusionError::ConfigError(
253                        "external profile mode requires params_ff to be set".to_string(),
254                    )
255                })?;
256                Self::validate_profile_params(&params_p, "params_p")?;
257                Self::validate_profile_params(&params_ff, "params_ff")?;
258                Some((params_p, params_ff))
259            } else {
260                None
261            };
262        // In inverse/profile workflows we need a tighter inner solve so local
263        // sensitivities are informative; standard runtime keeps Python parity.
264        let inner_sweeps = if external_profiles.is_some() {
265            INNER_SOLVE_ITERS
266        } else {
267            1
268        };
269        let inner_omega = if inner_sweeps > 1 { 1.8 } else { sor_omega };
270
271        // 1. Compute vacuum field
272        let psi_vac = calculate_vacuum_field(&self.grid, &self.config.coils, mu0)?;
273        self.state.psi = psi_vac.clone();
274        let psi_vac_boundary = psi_vac;
275
276        // 2. Seed Gaussian current
277        let r_center = (self.config.dimensions.r_min + self.config.dimensions.r_max) / 2.0;
278        let z_center = 0.0;
279
280        for iz in 0..nz {
281            for ir in 0..nr {
282                let r = self.grid.rr[[iz, ir]];
283                let z = self.grid.zz[[iz, ir]];
284                let dist_sq = (r - r_center).powi(2) + (z - z_center).powi(2);
285                self.state.j_phi[[iz, ir]] =
286                    (-dist_sq / (2.0 * SEED_GAUSSIAN_SIGMA * SEED_GAUSSIAN_SIGMA)).exp();
287            }
288        }
289
290        // Normalize seed current
291        let i_seed: f64 = self.state.j_phi.iter().sum::<f64>() * dr * dz;
292        if i_seed > 0.0 {
293            let scale = i_target / i_seed;
294            self.state.j_phi.mapv_inplace(|v| v * scale);
295        }
296
297        // 3. Source = -μ₀ R J_phi
298        let mut source = Array2::zeros((nz, nr));
299        for iz in 0..nz {
300            for ir in 0..nr {
301                source[[iz, ir]] = -mu0 * self.grid.rr[[iz, ir]] * self.state.j_phi[[iz, ir]];
302            }
303        }
304
305        // Initial warm-up:
306        // - Python-parity path uses Jacobi seed sweeps.
307        // - Inverse/profile path keeps stronger SOR warm-up for sensitivity quality.
308        if inner_sweeps > 1 {
309            sor_solve(
310                &mut self.state.psi,
311                &source,
312                &self.grid,
313                inner_omega,
314                INITIAL_JACOBI_ITERS,
315            );
316        } else {
317            for _ in 0..INITIAL_JACOBI_ITERS {
318                self.state.psi = jacobi_step_python(&self.state.psi, &source, dr);
319            }
320        }
321
322        // 4. Picard iteration
323        let mut x_point_pos = (0.0_f64, 0.0_f64);
324        let mut psi_best = self.state.psi.clone();
325        let mut diff_best = f64::MAX;
326        let mut converged = false;
327        let mut final_iter = 0;
328        let mut final_residual = f64::MAX;
329        let mut psi_axis_val = 0.0_f64;
330        let mut psi_boundary_val = 0.0_f64;
331        let mut axis_position = (0.0_f64, 0.0_f64);
332
333        for k in 0..max_iter {
334            // 4a. Find O-point (axis) — maximum of Ψ
335            let mut max_psi = f64::NEG_INFINITY;
336            let mut ir_ax = 0;
337            let mut iz_ax = 0;
338            for iz in 0..nz {
339                for ir in 0..nr {
340                    if self.state.psi[[iz, ir]] > max_psi {
341                        max_psi = self.state.psi[[iz, ir]];
342                        iz_ax = iz;
343                        ir_ax = ir;
344                    }
345                }
346            }
347            psi_axis_val = max_psi;
348            if psi_axis_val.abs() < MIN_PSI_AXIS {
349                psi_axis_val = MIN_PSI_AXIS;
350            }
351            axis_position = (self.grid.r[ir_ax], self.grid.z[iz_ax]);
352
353            // 4a. Find X-point
354            let ((r_x, z_x), psi_x) =
355                find_x_point(&self.state.psi, &self.grid, self.config.dimensions.z_min)?;
356            x_point_pos = (r_x, z_x);
357            psi_boundary_val = psi_x;
358
359            // Safety: if axis and boundary too close, use limiter mode
360            if (psi_axis_val - psi_boundary_val).abs() < MIN_PSI_SEPARATION {
361                psi_boundary_val = psi_axis_val * LIMITER_FALLBACK_FACTOR;
362            }
363
364            // 4b. Update nonlinear source
365            if let Some((params_p, params_ff)) = external_profiles {
366                self.state.j_phi = update_plasma_source_with_profiles(
367                    SourceProfileContext {
368                        psi: &self.state.psi,
369                        grid: &self.grid,
370                        psi_axis: psi_axis_val,
371                        psi_boundary: psi_boundary_val,
372                        mu0,
373                        i_target,
374                    },
375                    &params_p,
376                    &params_ff,
377                )?;
378            } else {
379                self.state.j_phi = update_plasma_source_nonlinear(
380                    &self.state.psi,
381                    &self.grid,
382                    psi_axis_val,
383                    psi_boundary_val,
384                    mu0,
385                    i_target,
386                )?;
387            }
388            if let Some(particle_j_phi) = self.particle_current_feedback.as_ref() {
389                self.state.j_phi = blend_particle_current(
390                    &self.state.j_phi,
391                    particle_j_phi,
392                    &self.grid,
393                    i_target,
394                    self.particle_feedback_coupling,
395                )?;
396            }
397
398            // Source = -μ₀ R J_phi
399            for iz in 0..nz {
400                for ir in 0..nr {
401                    source[[iz, ir]] = -mu0 * self.grid.rr[[iz, ir]] * self.state.j_phi[[iz, ir]];
402                }
403            }
404
405            // 4c. Elliptic solve (SOR or multigrid)
406            let mut psi_new = self.state.psi.clone();
407            match self.solver_method {
408                SolverMethod::PicardSor => {
409                    if inner_sweeps > 1 {
410                        sor_solve(&mut psi_new, &source, &self.grid, inner_omega, inner_sweeps);
411                    } else {
412                        sor_step_python(&mut psi_new, &source, &self.grid, inner_omega);
413                    }
414                }
415                SolverMethod::PicardMultigrid => {
416                    let mg_cfg = MultigridConfig {
417                        omega: inner_omega,
418                        ..MultigridConfig::default()
419                    };
420                    if inner_sweeps > 1 {
421                        let mg_result = multigrid_solve(
422                            &mut psi_new,
423                            &source,
424                            &self.grid,
425                            &mg_cfg,
426                            inner_sweeps,
427                            1e-8,
428                        );
429                        if !mg_result.converged {
430                            sor_solve(&mut psi_new, &source, &self.grid, inner_omega, inner_sweeps);
431                        }
432                    } else {
433                        let _ = multigrid_solve(&mut psi_new, &source, &self.grid, &mg_cfg, 1, 0.0);
434                    }
435                }
436            }
437
438            // 4d. Apply vacuum boundary conditions
439            for ir in 0..nr {
440                psi_new[[0, ir]] = psi_vac_boundary[[0, ir]];
441                psi_new[[nz - 1, ir]] = psi_vac_boundary[[nz - 1, ir]];
442            }
443            for iz in 0..nz {
444                psi_new[[iz, 0]] = psi_vac_boundary[[iz, 0]];
445                psi_new[[iz, nr - 1]] = psi_vac_boundary[[iz, nr - 1]];
446            }
447
448            // Robustness check
449            if psi_new.iter().any(|v| v.is_nan() || v.is_infinite()) {
450                self.state.psi = psi_best;
451                return Err(FusionError::SolverDiverged {
452                    iteration: k,
453                    message: "NaN or Inf detected in solution".to_string(),
454                });
455            }
456
457            // 4e. Relax
458            let mut diff = 0.0_f64;
459            let mut count = 0;
460            for iz in 0..nz {
461                for ir in 0..nr {
462                    diff += (psi_new[[iz, ir]] - self.state.psi[[iz, ir]]).abs();
463                    count += 1;
464                    self.state.psi[[iz, ir]] =
465                        (1.0 - alpha) * self.state.psi[[iz, ir]] + alpha * psi_new[[iz, ir]];
466                }
467            }
468            diff /= count as f64;
469
470            // Save best
471            if diff < diff_best {
472                diff_best = diff;
473                psi_best = self.state.psi.clone();
474            }
475
476            final_iter = k;
477            final_residual = diff;
478
479            // 4f. Check convergence
480            if diff < tol {
481                converged = true;
482                break;
483            }
484        }
485
486        // Finalize: compute B-field
487        let (b_r, b_z) = compute_b_field(&self.state.psi, &self.grid)?;
488        self.state.b_r = Some(b_r);
489        self.state.b_z = Some(b_z);
490        self.state.axis = Some(axis_position);
491        self.state.x_point = Some(x_point_pos);
492        self.state.psi_axis = psi_axis_val;
493        self.state.psi_boundary = psi_boundary_val;
494
495        let elapsed = start.elapsed().as_secs_f64() * 1000.0;
496
497        Ok(EquilibriumResult {
498            converged,
499            iterations: final_iter + 1,
500            residual: final_residual,
501            axis_position,
502            x_point_position: x_point_pos,
503            psi_axis: psi_axis_val,
504            psi_boundary: psi_boundary_val,
505            solve_time_ms: elapsed,
506        })
507    }
508
509    /// Read-only access to flux.
510    pub fn psi(&self) -> &Array2<f64> {
511        &self.state.psi
512    }
513
514    /// Read-only access to current density.
515    pub fn j_phi(&self) -> &Array2<f64> {
516        &self.state.j_phi
517    }
518
519    /// Read-only access to the plasma state.
520    pub fn state(&self) -> &PlasmaState {
521        &self.state
522    }
523
524    /// Read-only access to the grid.
525    pub fn grid(&self) -> &Grid2D {
526        &self.grid
527    }
528
529    /// Set the inner linear solver method.
530    pub fn set_solver_method(&mut self, method: SolverMethod) {
531        self.solver_method = method;
532    }
533
534    /// Get the current inner linear solver method.
535    pub fn solver_method(&self) -> SolverMethod {
536        self.solver_method
537    }
538
539    /// Read-only access to the configuration.
540    pub fn config(&self) -> &ReactorConfig {
541        &self.config
542    }
543
544    /// Enable externally specified mTanh profiles for source update.
545    pub fn set_external_profiles(&mut self, params_p: ProfileParams, params_ff: ProfileParams) {
546        self.external_profile_mode = true;
547        self.profile_params_p = Some(params_p);
548        self.profile_params_ff = Some(params_ff);
549    }
550
551    /// Disable external profile mode and revert to default nonlinear source update.
552    pub fn clear_external_profiles(&mut self) {
553        self.external_profile_mode = false;
554        self.profile_params_p = None;
555        self.profile_params_ff = None;
556    }
557
558    /// Inject a static particle-current map blended into fluid J_phi each Picard step.
559    pub fn set_particle_current_feedback(
560        &mut self,
561        particle_j_phi: Array2<f64>,
562        coupling: f64,
563    ) -> FusionResult<()> {
564        if self.grid.nz == 0 || self.grid.nr == 0 {
565            return Err(FusionError::ConfigError(format!(
566                "particle feedback requires non-empty grid dimensions, got nz={}, nr={}",
567                self.grid.nz, self.grid.nr
568            )));
569        }
570        let expected_shape = (self.grid.nz, self.grid.nr);
571        if self.grid.r.len() != self.grid.nr || self.grid.z.len() != self.grid.nz {
572            return Err(FusionError::ConfigError(format!(
573                "particle feedback requires grid axis lengths to match metadata, got r_len={}, nr={}, z_len={}, nz={}",
574                self.grid.r.len(),
575                self.grid.nr,
576                self.grid.z.len(),
577                self.grid.nz
578            )));
579        }
580        if self.grid.rr.dim() != expected_shape || self.grid.zz.dim() != expected_shape {
581            return Err(FusionError::ConfigError(format!(
582                "particle feedback requires grid mesh shape {:?}, got rr={:?}, zz={:?}",
583                expected_shape,
584                self.grid.rr.dim(),
585                self.grid.zz.dim()
586            )));
587        }
588        if self.grid.r.iter().any(|v| !v.is_finite()) || self.grid.z.iter().any(|v| !v.is_finite())
589        {
590            return Err(FusionError::ConfigError(
591                "particle feedback requires finite grid axis coordinates".to_string(),
592            ));
593        }
594        if self.grid.rr.iter().any(|v| !v.is_finite())
595            || self.grid.zz.iter().any(|v| !v.is_finite())
596        {
597            return Err(FusionError::ConfigError(
598                "particle feedback requires finite grid mesh coordinates".to_string(),
599            ));
600        }
601        let i_target = self.config.physics.plasma_current_target;
602        if !i_target.is_finite() {
603            return Err(FusionError::ConfigError(
604                "particle feedback requires finite plasma_current_target".to_string(),
605            ));
606        }
607        if !self.grid.dr.is_finite()
608            || !self.grid.dz.is_finite()
609            || self.grid.dr <= 0.0
610            || self.grid.dz <= 0.0
611        {
612            return Err(FusionError::ConfigError(format!(
613                "particle feedback requires finite grid spacing > 0, got dr={}, dz={}",
614                self.grid.dr, self.grid.dz
615            )));
616        }
617        if particle_j_phi.dim() != expected_shape {
618            return Err(FusionError::PhysicsViolation(format!(
619                "Particle feedback shape mismatch: expected ({}, {}), got {:?}",
620                expected_shape.0,
621                expected_shape.1,
622                particle_j_phi.dim(),
623            )));
624        }
625        if !coupling.is_finite() || !(0.0..=1.0).contains(&coupling) {
626            return Err(FusionError::PhysicsViolation(
627                "particle feedback coupling must be finite and in [0, 1]".to_string(),
628            ));
629        }
630        if particle_j_phi.iter().any(|v| !v.is_finite()) {
631            return Err(FusionError::PhysicsViolation(
632                "particle feedback map must contain only finite values".to_string(),
633            ));
634        }
635        let particle_integral = particle_j_phi.iter().sum::<f64>() * self.grid.dr * self.grid.dz;
636        if !particle_integral.is_finite() {
637            return Err(FusionError::PhysicsViolation(
638                "particle feedback integral became non-finite".to_string(),
639            ));
640        }
641        if coupling >= 1.0 - f64::EPSILON
642            && i_target.abs() > MIN_CURRENT_INTEGRAL
643            && particle_integral.abs() <= MIN_CURRENT_INTEGRAL
644        {
645            return Err(FusionError::PhysicsViolation(
646                "particle feedback integral is near zero for coupling=1 with non-zero plasma current target".to_string(),
647            ));
648        }
649        self.particle_current_feedback = Some(particle_j_phi);
650        self.particle_feedback_coupling = coupling;
651        Ok(())
652    }
653
654    /// Disable particle-current feedback for subsequent solves.
655    pub fn clear_particle_current_feedback(&mut self) {
656        self.particle_current_feedback = None;
657        self.particle_feedback_coupling = 0.0;
658    }
659
660    /// Build particle feedback map from macro-particle population and enable coupling.
661    pub fn set_particle_feedback_from_population(
662        &mut self,
663        particles: &[ChargedParticle],
664        coupling: f64,
665        runaway_threshold_mev: f64,
666    ) -> FusionResult<ParticlePopulationSummary> {
667        if particles.is_empty() {
668            return Err(FusionError::PhysicsViolation(
669                "particle population must be non-empty".to_string(),
670            ));
671        }
672        let summary = summarize_particle_population(particles, runaway_threshold_mev).map_err(
673            |err| match err {
674                FusionError::PhysicsViolation(msg) => FusionError::PhysicsViolation(format!(
675                    "particle population summary failed: {msg}"
676                )),
677                FusionError::ConfigError(msg) => {
678                    FusionError::ConfigError(format!("particle population summary failed: {msg}"))
679                }
680                other => other,
681            },
682        )?;
683        let particle_j_phi =
684            deposit_toroidal_current_density(particles, &self.grid).map_err(|err| match err {
685                FusionError::PhysicsViolation(msg) => FusionError::PhysicsViolation(format!(
686                    "particle current deposition failed: {msg}"
687                )),
688                FusionError::ConfigError(msg) => {
689                    FusionError::ConfigError(format!("particle current deposition failed: {msg}"))
690                }
691                other => other,
692            })?;
693        self.set_particle_current_feedback(particle_j_phi, coupling)
694            .map_err(|err| match err {
695                FusionError::PhysicsViolation(msg) => FusionError::PhysicsViolation(format!(
696                    "particle population feedback setup failed: {msg}"
697                )),
698                FusionError::ConfigError(msg) => FusionError::ConfigError(format!(
699                    "particle population feedback setup failed: {msg}"
700                )),
701                other => other,
702            })?;
703        Ok(summary)
704    }
705
706    /// Convenience wrapper to solve with temporary external profile parameters.
707    pub fn solve_equilibrium_with_profiles(
708        &mut self,
709        params_p: ProfileParams,
710        params_ff: ProfileParams,
711    ) -> FusionResult<EquilibriumResult> {
712        Self::validate_profile_params(&params_p, "params_p")?;
713        Self::validate_profile_params(&params_ff, "params_ff")?;
714        let prev_mode = self.external_profile_mode;
715        let prev_params_p = self.profile_params_p;
716        let prev_params_ff = self.profile_params_ff;
717        self.set_external_profiles(params_p, params_ff);
718        let result = self.solve_equilibrium();
719        self.external_profile_mode = prev_mode;
720        self.profile_params_p = prev_params_p;
721        self.profile_params_ff = prev_params_ff;
722        result
723    }
724
725    fn nearest_index(axis: &Array1<f64>, value: f64) -> FusionResult<usize> {
726        if axis.is_empty() {
727            return Err(FusionError::ConfigError(
728                "sample axis must contain at least one coordinate".to_string(),
729            ));
730        }
731        if !value.is_finite() {
732            return Err(FusionError::ConfigError(format!(
733                "sample coordinate must be finite, got {value}"
734            )));
735        }
736
737        let mut best_idx = 0usize;
738        let mut best_dist = f64::INFINITY;
739        for (idx, &x) in axis.iter().enumerate() {
740            if !x.is_finite() {
741                return Err(FusionError::ConfigError(format!(
742                    "sample axis contains non-finite coordinate at index {idx}"
743                )));
744            }
745            let d = (x - value).abs();
746            if d < best_dist {
747                best_dist = d;
748                best_idx = idx;
749            }
750        }
751        Ok(best_idx)
752    }
753
754    fn axis_bounds(axis: &Array1<f64>, label: &str) -> FusionResult<(f64, f64)> {
755        if axis.is_empty() {
756            return Err(FusionError::ConfigError(format!(
757                "{label} axis must contain at least one coordinate"
758            )));
759        }
760        let mut min_x = f64::INFINITY;
761        let mut max_x = f64::NEG_INFINITY;
762        for (idx, &x) in axis.iter().enumerate() {
763            if !x.is_finite() {
764                return Err(FusionError::ConfigError(format!(
765                    "{label} axis contains non-finite coordinate at index {idx}"
766                )));
767            }
768            min_x = min_x.min(x);
769            max_x = max_x.max(x);
770        }
771        Ok((min_x, max_x))
772    }
773
774    fn validate_profile_params(params: &ProfileParams, label: &str) -> FusionResult<()> {
775        if !params.ped_top.is_finite() || params.ped_top <= 0.0 {
776            return Err(FusionError::ConfigError(format!(
777                "{label}.ped_top must be finite and > 0, got {}",
778                params.ped_top
779            )));
780        }
781        if !params.ped_width.is_finite() || params.ped_width <= 0.0 {
782            return Err(FusionError::ConfigError(format!(
783                "{label}.ped_width must be finite and > 0, got {}",
784                params.ped_width
785            )));
786        }
787        if !params.ped_height.is_finite() || !params.core_alpha.is_finite() {
788            return Err(FusionError::ConfigError(format!(
789                "{label}.ped_height/core_alpha must be finite"
790            )));
791        }
792        Ok(())
793    }
794
795    /// Sample solved flux at nearest grid point to (R, Z).
796    pub fn sample_psi_at(&self, r: f64, z: f64) -> FusionResult<f64> {
797        if self.state.psi.dim() != (self.grid.nz, self.grid.nr) {
798            return Err(FusionError::ConfigError(format!(
799                "sample psi grid/state shape mismatch: psi={:?}, grid=({}, {})",
800                self.state.psi.dim(),
801                self.grid.nz,
802                self.grid.nr
803            )));
804        }
805        if self.grid.r.len() != self.grid.nr || self.grid.z.len() != self.grid.nz {
806            return Err(FusionError::ConfigError(format!(
807                "sample psi requires grid axis lengths to match metadata, got r_len={}, nr={}, z_len={}, nz={}",
808                self.grid.r.len(),
809                self.grid.nr,
810                self.grid.z.len(),
811                self.grid.nz
812            )));
813        }
814        let (r_min, r_max) = Self::axis_bounds(&self.grid.r, "sample R")?;
815        let (z_min, z_max) = Self::axis_bounds(&self.grid.z, "sample Z")?;
816        if r < r_min || r > r_max || z < z_min || z > z_max {
817            return Err(FusionError::ConfigError(format!(
818                "sample coordinate outside grid domain: (R={r}, Z={z}) not in R[{r_min}, {r_max}] Z[{z_min}, {z_max}]"
819            )));
820        }
821        let ir = Self::nearest_index(&self.grid.r, r)?;
822        let iz = Self::nearest_index(&self.grid.z, z)?;
823        let psi = self.state.psi[[iz, ir]];
824        if !psi.is_finite() {
825            return Err(FusionError::ConfigError(
826                "sampled psi value is non-finite".to_string(),
827            ));
828        }
829        Ok(psi)
830    }
831
832    /// Sample solved flux at multiple probe coordinates.
833    pub fn sample_psi_at_probes(&self, probes: &[(f64, f64)]) -> FusionResult<Vec<f64>> {
834        if probes.is_empty() {
835            return Err(FusionError::ConfigError(
836                "sample probes list must be non-empty".to_string(),
837            ));
838        }
839        probes
840            .iter()
841            .enumerate()
842            .map(|(idx, &(r, z))| {
843                self.sample_psi_at(r, z).map_err(|err| match err {
844                    FusionError::ConfigError(msg) => {
845                        FusionError::ConfigError(format!("probe[{idx}] {msg}"))
846                    }
847                    other => other,
848                })
849            })
850            .collect()
851    }
852}
853
854#[cfg(test)]
855mod tests {
856    use super::*;
857    use ndarray::Array2;
858    use std::path::PathBuf;
859
860    fn project_root() -> PathBuf {
861        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
862            .join("..")
863            .join("..")
864            .join("..")
865    }
866
867    fn config_path(relative: &str) -> String {
868        project_root().join(relative).to_string_lossy().to_string()
869    }
870
871    #[test]
872    fn test_kernel_creation() {
873        let kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
874        assert_eq!(kernel.grid().nr, 128);
875        assert_eq!(kernel.grid().nz, 128);
876        assert_eq!(kernel.config().coils.len(), 7);
877
878        // Can access state
879        assert_eq!(kernel.psi().shape(), &[128, 128]);
880    }
881
882    #[test]
883    fn test_solver_method_default_and_setter() {
884        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
885        assert_eq!(kernel.solver_method(), SolverMethod::PicardSor);
886        kernel.set_solver_method(SolverMethod::PicardMultigrid);
887        assert_eq!(kernel.solver_method(), SolverMethod::PicardMultigrid);
888        kernel.set_solver_method(SolverMethod::PicardSor);
889        assert_eq!(kernel.solver_method(), SolverMethod::PicardSor);
890    }
891
892    #[test]
893    fn test_multigrid_solver_mode_smoke() {
894        let mut kernel =
895            FusionKernel::from_file(&config_path("validation/iter_validated_config.json")).unwrap();
896        kernel.set_solver_method(SolverMethod::PicardMultigrid);
897        let result = kernel.solve_equilibrium().unwrap();
898        assert!(result.iterations > 0);
899        assert!(result.residual.is_finite());
900        assert!(!kernel.psi().iter().any(|v| !v.is_finite()));
901    }
902
903    #[test]
904    fn test_full_equilibrium_iter_config() {
905        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
906        let result = kernel.solve_equilibrium().unwrap();
907
908        // Solver should complete without NaN regardless of convergence
909        assert!(
910            !kernel.psi().iter().any(|v| v.is_nan()),
911            "Ψ contains NaN after solve"
912        );
913        assert!(
914            kernel.psi().iter().all(|v| v.is_finite()),
915            "Ψ contains Inf after solve"
916        );
917        // Record convergence for diagnostics (known: ITER config may not
918        // converge with the current SOR solver — see HONEST_SCOPE.md)
919        if !result.converged {
920            eprintln!(
921                "ITER config did not converge in {} iterations (residual: {:.2e})",
922                result.iterations, result.residual
923            );
924        }
925    }
926
927    #[test]
928    fn test_b_field_computed_after_solve() {
929        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
930        kernel.solve_equilibrium().unwrap();
931
932        assert!(kernel.state().b_r.is_some(), "B_R should be computed");
933        assert!(kernel.state().b_z.is_some(), "B_Z should be computed");
934
935        // B-field should have no NaN
936        let b_r = kernel.state().b_r.as_ref().unwrap();
937        let b_z = kernel.state().b_z.as_ref().unwrap();
938        assert!(!b_r.iter().any(|v| v.is_nan()), "B_R contains NaN");
939        assert!(!b_z.iter().any(|v| v.is_nan()), "B_Z contains NaN");
940    }
941
942    #[test]
943    fn test_validated_config_equilibrium() {
944        let mut kernel =
945            FusionKernel::from_file(&config_path("validation/iter_validated_config.json")).unwrap();
946        let result = kernel.solve_equilibrium().unwrap();
947
948        // 65x65 grid should be faster
949        assert!(result.iterations > 0, "Should run at least 1 iteration");
950        assert!(
951            !kernel.psi().iter().any(|v| v.is_nan()),
952            "No NaN in validated config solve"
953        );
954    }
955
956    #[test]
957    fn test_sample_psi_rejects_non_finite_probe_coordinates() {
958        let kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
959        assert!(kernel.sample_psi_at(f64::NAN, 0.0).is_err());
960        assert!(kernel.sample_psi_at(6.2, f64::INFINITY).is_err());
961        assert!(kernel
962            .sample_psi_at_probes(&[(6.2, 0.0), (f64::NAN, 0.1)])
963            .is_err());
964    }
965
966    #[test]
967    fn test_sample_psi_rejects_non_finite_axis_coordinates() {
968        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
969        kernel.grid.r[10] = f64::NAN;
970        let err = kernel
971            .sample_psi_at(6.2, 0.0)
972            .expect_err("non-finite axis coordinate must fail");
973        match err {
974            FusionError::ConfigError(msg) => {
975                assert!(msg.contains("non-finite coordinate"));
976            }
977            other => panic!("Unexpected error: {other:?}"),
978        }
979    }
980
981    #[test]
982    fn test_sample_psi_rejects_out_of_domain_probe_coordinates() {
983        let kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
984        let r_min = kernel.grid().r[0].min(kernel.grid().r[kernel.grid().nr - 1]);
985        let r_max = kernel.grid().r[0].max(kernel.grid().r[kernel.grid().nr - 1]);
986        let z_min = kernel.grid().z[0].min(kernel.grid().z[kernel.grid().nz - 1]);
987        let z_max = kernel.grid().z[0].max(kernel.grid().z[kernel.grid().nz - 1]);
988
989        assert!(kernel.sample_psi_at(r_min - 1.0e-6, 0.0).is_err());
990        assert!(kernel.sample_psi_at(r_max + 1.0e-6, 0.0).is_err());
991        assert!(kernel.sample_psi_at(6.2, z_min - 1.0e-6).is_err());
992        assert!(kernel.sample_psi_at(6.2, z_max + 1.0e-6).is_err());
993        assert!(kernel
994            .sample_psi_at_probes(&[(6.2, 0.0), (r_max + 1.0e-6, 0.1)])
995            .is_err());
996    }
997
998    #[test]
999    fn test_sample_psi_probe_errors_include_probe_index() {
1000        let kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1001        let r_max = kernel.grid().r[0].max(kernel.grid().r[kernel.grid().nr - 1]);
1002        let err = kernel
1003            .sample_psi_at_probes(&[(6.2, 0.0), (r_max + 1.0e-6, 0.1)])
1004            .expect_err("out-of-domain probe list must fail");
1005        match err {
1006            FusionError::ConfigError(msg) => {
1007                assert!(msg.contains("probe[1]"));
1008            }
1009            other => panic!("Unexpected error: {other:?}"),
1010        }
1011    }
1012
1013    #[test]
1014    fn test_sample_psi_rejects_empty_probe_list() {
1015        let kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1016        let err = kernel
1017            .sample_psi_at_probes(&[])
1018            .expect_err("empty probe list must error");
1019        match err {
1020            FusionError::ConfigError(msg) => {
1021                assert!(msg.contains("non-empty"));
1022            }
1023            other => panic!("Unexpected error: {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn test_sample_psi_rejects_state_shape_mismatch() {
1029        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1030        kernel.state.psi = Array2::zeros((kernel.grid().nz - 1, kernel.grid().nr));
1031        let err = kernel
1032            .sample_psi_at(6.2, 0.0)
1033            .expect_err("mismatched psi shape must error");
1034        match err {
1035            FusionError::ConfigError(msg) => {
1036                assert!(msg.contains("shape mismatch"));
1037            }
1038            other => panic!("Unexpected error: {other:?}"),
1039        }
1040    }
1041
1042    #[test]
1043    fn test_sample_psi_rejects_grid_axis_length_mismatch() {
1044        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1045        kernel.grid.nr -= 1;
1046        kernel.state.psi = Array2::zeros((kernel.grid().nz, kernel.grid().nr));
1047        let err = kernel
1048            .sample_psi_at(6.2, 0.0)
1049            .expect_err("grid axis-length mismatch must fail");
1050        match err {
1051            FusionError::ConfigError(msg) => {
1052                assert!(msg.contains("axis lengths"));
1053            }
1054            other => panic!("Unexpected error: {other:?}"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_particle_feedback_shape_guard() {
1060        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1061        let bad_shape = Array2::zeros((8, 8));
1062        let err = kernel
1063            .set_particle_current_feedback(bad_shape, 0.4)
1064            .unwrap_err();
1065        match err {
1066            FusionError::PhysicsViolation(msg) => {
1067                assert!(msg.contains("shape mismatch"));
1068            }
1069            other => panic!("Unexpected error: {other:?}"),
1070        }
1071    }
1072
1073    #[test]
1074    fn test_particle_feedback_set_and_clear() {
1075        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1076        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1077        kernel
1078            .set_particle_current_feedback(feedback, 1.0)
1079            .expect("feedback set should succeed");
1080        assert!(kernel.particle_current_feedback.is_some());
1081        assert!((kernel.particle_feedback_coupling - 1.0).abs() < 1e-12);
1082        kernel.clear_particle_current_feedback();
1083        assert!(kernel.particle_current_feedback.is_none());
1084        assert_eq!(kernel.particle_feedback_coupling, 0.0);
1085    }
1086
1087    #[test]
1088    fn test_particle_feedback_rejects_invalid_coupling() {
1089        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1090        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1091        for coupling in [f64::NAN, -0.2, 1.2] {
1092            let err = kernel
1093                .set_particle_current_feedback(feedback.clone(), coupling)
1094                .expect_err("invalid coupling must error");
1095            match err {
1096                FusionError::PhysicsViolation(msg) => {
1097                    assert!(msg.contains("coupling"));
1098                }
1099                other => panic!("Unexpected error: {other:?}"),
1100            }
1101        }
1102    }
1103
1104    #[test]
1105    fn test_particle_feedback_rejects_non_finite_map() {
1106        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1107        let mut feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1108        feedback[[0, 0]] = f64::NAN;
1109        let err = kernel
1110            .set_particle_current_feedback(feedback, 0.2)
1111            .expect_err("non-finite feedback map must error");
1112        match err {
1113            FusionError::PhysicsViolation(msg) => {
1114                assert!(msg.contains("finite values"));
1115            }
1116            other => panic!("Unexpected error: {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn test_particle_feedback_rejects_invalid_grid_spacing() {
1122        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1123        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1124        kernel.grid.dr = 0.0;
1125        let err = kernel
1126            .set_particle_current_feedback(feedback, 0.2)
1127            .expect_err("invalid grid spacing must fail");
1128        match err {
1129            FusionError::ConfigError(msg) => {
1130                assert!(msg.contains("grid spacing"));
1131            }
1132            other => panic!("Unexpected error: {other:?}"),
1133        }
1134
1135        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1136        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1137        kernel.grid.dz = -kernel.grid.dz.abs();
1138        let err = kernel
1139            .set_particle_current_feedback(feedback, 0.2)
1140            .expect_err("negative grid spacing must fail");
1141        match err {
1142            FusionError::ConfigError(msg) => {
1143                assert!(msg.contains("grid spacing"));
1144            }
1145            other => panic!("Unexpected error: {other:?}"),
1146        }
1147    }
1148
1149    #[test]
1150    fn test_particle_feedback_rejects_grid_axis_length_mismatch() {
1151        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1152        kernel.grid.nr -= 1;
1153        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1154        let err = kernel
1155            .set_particle_current_feedback(feedback, 0.2)
1156            .expect_err("grid axis-length mismatch must fail");
1157        match err {
1158            FusionError::ConfigError(msg) => {
1159                assert!(msg.contains("axis lengths"));
1160            }
1161            other => panic!("Unexpected error: {other:?}"),
1162        }
1163    }
1164
1165    #[test]
1166    fn test_particle_feedback_rejects_grid_mesh_shape_mismatch() {
1167        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1168        kernel.grid.rr = Array2::zeros((kernel.grid().nz - 1, kernel.grid().nr));
1169        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1170        let err = kernel
1171            .set_particle_current_feedback(feedback, 0.2)
1172            .expect_err("grid mesh-shape mismatch must fail");
1173        match err {
1174            FusionError::ConfigError(msg) => {
1175                assert!(msg.contains("mesh shape"));
1176            }
1177            other => panic!("Unexpected error: {other:?}"),
1178        }
1179    }
1180
1181    #[test]
1182    fn test_particle_feedback_rejects_non_finite_grid_axes() {
1183        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1184        kernel.grid.r[7] = f64::NAN;
1185        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1186        let err = kernel
1187            .set_particle_current_feedback(feedback, 0.2)
1188            .expect_err("non-finite grid-axis coordinates must fail");
1189        match err {
1190            FusionError::ConfigError(msg) => {
1191                assert!(msg.contains("axis"));
1192                assert!(msg.contains("finite"));
1193            }
1194            other => panic!("Unexpected error: {other:?}"),
1195        }
1196    }
1197
1198    #[test]
1199    fn test_particle_feedback_rejects_non_finite_grid_mesh() {
1200        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1201        kernel.grid.zz[[0, 0]] = f64::INFINITY;
1202        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1203        let err = kernel
1204            .set_particle_current_feedback(feedback, 0.2)
1205            .expect_err("non-finite grid-mesh coordinates must fail");
1206        match err {
1207            FusionError::ConfigError(msg) => {
1208                assert!(msg.contains("mesh"));
1209                assert!(msg.contains("finite"));
1210            }
1211            other => panic!("Unexpected error: {other:?}"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_particle_feedback_rejects_empty_grid_dimensions() {
1217        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1218        kernel.grid.nz = 0;
1219        let feedback = Array2::zeros((0, kernel.grid().nr));
1220        let err = kernel
1221            .set_particle_current_feedback(feedback, 0.2)
1222            .expect_err("empty grid dimensions must fail");
1223        match err {
1224            FusionError::ConfigError(msg) => {
1225                assert!(msg.contains("non-empty grid dimensions"));
1226            }
1227            other => panic!("Unexpected error: {other:?}"),
1228        }
1229    }
1230
1231    #[test]
1232    fn test_particle_feedback_rejects_non_finite_plasma_current_target() {
1233        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1234        let feedback = Array2::from_elem((kernel.grid().nz, kernel.grid().nr), 1.0);
1235        kernel.config.physics.plasma_current_target = f64::NAN;
1236        let err = kernel
1237            .set_particle_current_feedback(feedback, 0.3)
1238            .expect_err("non-finite plasma_current_target must fail");
1239        match err {
1240            FusionError::ConfigError(msg) => {
1241                assert!(msg.contains("plasma_current_target"));
1242            }
1243            other => panic!("Unexpected error: {other:?}"),
1244        }
1245    }
1246
1247    #[test]
1248    fn test_particle_feedback_rejects_zero_integral_with_full_coupling() {
1249        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1250        let zero_feedback = Array2::zeros((kernel.grid().nz, kernel.grid().nr));
1251        let err = kernel
1252            .set_particle_current_feedback(zero_feedback.clone(), 1.0)
1253            .expect_err("zero-integral full-coupling feedback must fail");
1254        match err {
1255            FusionError::PhysicsViolation(msg) => {
1256                assert!(msg.contains("coupling=1") || msg.contains("near zero"));
1257            }
1258            other => panic!("Unexpected error: {other:?}"),
1259        }
1260
1261        kernel
1262            .set_particle_current_feedback(zero_feedback, 0.5)
1263            .expect("partial-coupling zero feedback should remain valid");
1264    }
1265
1266    #[test]
1267    fn test_particle_feedback_from_population_builds_summary() {
1268        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1269        let particles = vec![ChargedParticle {
1270            x_m: 6.1,
1271            y_m: 0.0,
1272            z_m: 0.0,
1273            vx_m_s: 0.0,
1274            vy_m_s: 2.0e7,
1275            vz_m_s: 0.0,
1276            charge_c: 1.602_176_634e-19,
1277            mass_kg: 1.672_621_923_69e-27,
1278            weight: 3.0e16,
1279        }];
1280
1281        let summary = kernel
1282            .set_particle_feedback_from_population(&particles, 0.35, 0.5)
1283            .expect("population feedback should be set");
1284        assert_eq!(summary.count, 1);
1285        assert!(summary.max_energy_mev > 0.5);
1286        assert!(kernel.particle_current_feedback.is_some());
1287        assert!((kernel.particle_feedback_coupling - 0.35).abs() < 1e-12);
1288    }
1289
1290    #[test]
1291    fn test_particle_feedback_from_population_rejects_invalid_threshold() {
1292        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1293        let particles = vec![ChargedParticle {
1294            x_m: 6.1,
1295            y_m: 0.0,
1296            z_m: 0.0,
1297            vx_m_s: 0.0,
1298            vy_m_s: 2.0e7,
1299            vz_m_s: 0.0,
1300            charge_c: 1.602_176_634e-19,
1301            mass_kg: 1.672_621_923_69e-27,
1302            weight: 3.0e16,
1303        }];
1304        let err = kernel
1305            .set_particle_feedback_from_population(&particles, 0.2, -0.5)
1306            .expect_err("invalid runaway threshold must error");
1307        match err {
1308            FusionError::PhysicsViolation(msg) => {
1309                assert!(msg.contains("summary failed"));
1310                assert!(msg.contains("runaway_threshold_mev"));
1311            }
1312            other => panic!("Unexpected error: {other:?}"),
1313        }
1314    }
1315
1316    #[test]
1317    fn test_particle_feedback_from_population_rejects_empty_population() {
1318        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1319        let err = kernel
1320            .set_particle_feedback_from_population(&[], 0.2, 0.5)
1321            .expect_err("empty particle population must error");
1322        match err {
1323            FusionError::PhysicsViolation(msg) => {
1324                assert!(msg.contains("non-empty"));
1325            }
1326            other => panic!("Unexpected error: {other:?}"),
1327        }
1328    }
1329
1330    #[test]
1331    fn test_particle_feedback_from_population_rejects_out_of_domain_full_coupling() {
1332        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1333        let particles = vec![ChargedParticle {
1334            x_m: 100.0,
1335            y_m: 0.0,
1336            z_m: 100.0,
1337            vx_m_s: 0.0,
1338            vy_m_s: 2.0e7,
1339            vz_m_s: 0.0,
1340            charge_c: 1.602_176_634e-19,
1341            mass_kg: 1.672_621_923_69e-27,
1342            weight: 3.0e16,
1343        }];
1344        let err = kernel
1345            .set_particle_feedback_from_population(&particles, 1.0, 0.5)
1346            .expect_err("out-of-domain population with full coupling must fail");
1347        match err {
1348            FusionError::PhysicsViolation(msg) => {
1349                assert!(msg.contains("feedback setup failed"));
1350                assert!(msg.contains("coupling=1") || msg.contains("near zero"));
1351            }
1352            other => panic!("Unexpected error: {other:?}"),
1353        }
1354    }
1355
1356    #[test]
1357    fn test_particle_feedback_from_population_wraps_deposition_errors() {
1358        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1359        kernel.grid.dr = 0.0;
1360        let particles = vec![ChargedParticle {
1361            x_m: 6.1,
1362            y_m: 0.0,
1363            z_m: 0.0,
1364            vx_m_s: 0.0,
1365            vy_m_s: 2.0e7,
1366            vz_m_s: 0.0,
1367            charge_c: 1.602_176_634e-19,
1368            mass_kg: 1.672_621_923_69e-27,
1369            weight: 3.0e16,
1370        }];
1371        let err = kernel
1372            .set_particle_feedback_from_population(&particles, 0.3, 0.5)
1373            .expect_err("invalid grid spacing must fail during deposition");
1374        match err {
1375            FusionError::PhysicsViolation(msg) => {
1376                assert!(msg.contains("deposition failed"));
1377                assert!(msg.contains("grid spacing"));
1378            }
1379            other => panic!("Unexpected error: {other:?}"),
1380        }
1381    }
1382
1383    #[test]
1384    fn test_particle_feedback_from_population_wraps_setup_config_errors() {
1385        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1386        kernel.config.physics.plasma_current_target = f64::NAN;
1387        let particles = vec![ChargedParticle {
1388            x_m: 6.1,
1389            y_m: 0.0,
1390            z_m: 0.0,
1391            vx_m_s: 0.0,
1392            vy_m_s: 2.0e7,
1393            vz_m_s: 0.0,
1394            charge_c: 1.602_176_634e-19,
1395            mass_kg: 1.672_621_923_69e-27,
1396            weight: 3.0e16,
1397        }];
1398        let err = kernel
1399            .set_particle_feedback_from_population(&particles, 0.3, 0.5)
1400            .expect_err("non-finite plasma_current_target must fail during setup");
1401        match err {
1402            FusionError::ConfigError(msg) => {
1403                assert!(msg.contains("feedback setup failed"));
1404                assert!(msg.contains("plasma_current_target"));
1405            }
1406            other => panic!("Unexpected error: {other:?}"),
1407        }
1408    }
1409
1410    #[test]
1411    fn test_solve_equilibrium_rejects_invalid_runtime_controls() {
1412        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1413        kernel.config.solver.max_iterations = 0;
1414        let err = kernel
1415            .solve_equilibrium()
1416            .expect_err("zero max_iterations must fail");
1417        match err {
1418            FusionError::ConfigError(msg) => {
1419                assert!(msg.contains("max_iterations"));
1420            }
1421            other => panic!("Unexpected error: {other:?}"),
1422        }
1423
1424        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1425        kernel.config.solver.convergence_threshold = 0.0;
1426        let err = kernel
1427            .solve_equilibrium()
1428            .expect_err("non-positive convergence_threshold must fail");
1429        match err {
1430            FusionError::ConfigError(msg) => {
1431                assert!(msg.contains("convergence_threshold"));
1432            }
1433            other => panic!("Unexpected error: {other:?}"),
1434        }
1435
1436        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1437        kernel.grid.dr = 0.0;
1438        let err = kernel
1439            .solve_equilibrium()
1440            .expect_err("zero grid spacing must fail");
1441        match err {
1442            FusionError::ConfigError(msg) => {
1443                assert!(msg.contains("grid spacing"));
1444            }
1445            other => panic!("Unexpected error: {other:?}"),
1446        }
1447
1448        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1449        kernel.grid.dz = -kernel.grid.dz.abs();
1450        let err = kernel
1451            .solve_equilibrium()
1452            .expect_err("negative grid spacing must fail");
1453        match err {
1454            FusionError::ConfigError(msg) => {
1455                assert!(msg.contains("grid spacing"));
1456            }
1457            other => panic!("Unexpected error: {other:?}"),
1458        }
1459    }
1460
1461    #[test]
1462    fn test_solve_equilibrium_rejects_grid_axis_length_mismatch() {
1463        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1464        kernel.grid.nr -= 1;
1465        let err = kernel
1466            .solve_equilibrium()
1467            .expect_err("grid axis-length mismatch must fail");
1468        match err {
1469            FusionError::ConfigError(msg) => {
1470                assert!(msg.contains("axis lengths"));
1471            }
1472            other => panic!("Unexpected error: {other:?}"),
1473        }
1474    }
1475
1476    #[test]
1477    fn test_solve_equilibrium_rejects_grid_mesh_shape_mismatch() {
1478        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1479        kernel.grid.rr = Array2::zeros((kernel.grid().nz - 1, kernel.grid().nr));
1480        let err = kernel
1481            .solve_equilibrium()
1482            .expect_err("grid mesh-shape mismatch must fail");
1483        match err {
1484            FusionError::ConfigError(msg) => {
1485                assert!(msg.contains("mesh shape"));
1486            }
1487            other => panic!("Unexpected error: {other:?}"),
1488        }
1489    }
1490
1491    #[test]
1492    fn test_solve_equilibrium_rejects_non_finite_grid_axes() {
1493        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1494        kernel.grid.r[3] = f64::NAN;
1495        let err = kernel
1496            .solve_equilibrium()
1497            .expect_err("non-finite grid-axis coordinates must fail");
1498        match err {
1499            FusionError::ConfigError(msg) => {
1500                assert!(msg.contains("axes"));
1501                assert!(msg.contains("finite"));
1502            }
1503            other => panic!("Unexpected error: {other:?}"),
1504        }
1505    }
1506
1507    #[test]
1508    fn test_solve_equilibrium_rejects_non_finite_grid_mesh() {
1509        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1510        kernel.grid.rr[[0, 0]] = f64::INFINITY;
1511        let err = kernel
1512            .solve_equilibrium()
1513            .expect_err("non-finite grid-mesh coordinates must fail");
1514        match err {
1515            FusionError::ConfigError(msg) => {
1516                assert!(msg.contains("mesh"));
1517                assert!(msg.contains("finite"));
1518            }
1519            other => panic!("Unexpected error: {other:?}"),
1520        }
1521    }
1522
1523    #[test]
1524    fn test_solve_equilibrium_rejects_state_j_phi_shape_mismatch() {
1525        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1526        kernel.state.j_phi = Array2::zeros((kernel.grid().nz - 1, kernel.grid().nr));
1527        let err = kernel
1528            .solve_equilibrium()
1529            .expect_err("state j_phi shape mismatch must fail");
1530        match err {
1531            FusionError::ConfigError(msg) => {
1532                assert!(msg.contains("j_phi"));
1533                assert!(msg.contains("shape mismatch"));
1534            }
1535            other => panic!("Unexpected error: {other:?}"),
1536        }
1537    }
1538
1539    #[test]
1540    fn test_solve_equilibrium_rejects_external_profile_mode_without_params() {
1541        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1542        kernel.external_profile_mode = true;
1543        kernel.profile_params_p = None;
1544        kernel.profile_params_ff = None;
1545        let err = kernel
1546            .solve_equilibrium()
1547            .expect_err("external profile mode without params must fail");
1548        match err {
1549            FusionError::ConfigError(msg) => {
1550                assert!(msg.contains("requires params_p"));
1551            }
1552            other => panic!("Unexpected error: {other:?}"),
1553        }
1554    }
1555
1556    #[test]
1557    fn test_solve_equilibrium_rejects_invalid_external_profile_params() {
1558        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1559        let bad_p = ProfileParams {
1560            ped_top: 0.9,
1561            ped_width: 0.0,
1562            ped_height: 1.0,
1563            core_alpha: 0.2,
1564        };
1565        kernel.set_external_profiles(bad_p, ProfileParams::default());
1566        let err = kernel
1567            .solve_equilibrium()
1568            .expect_err("invalid external profile params must fail");
1569        match err {
1570            FusionError::ConfigError(msg) => {
1571                assert!(msg.contains("ped_width"));
1572            }
1573            other => panic!("Unexpected error: {other:?}"),
1574        }
1575    }
1576
1577    #[test]
1578    fn test_solve_with_profiles_restores_profile_state_on_error() {
1579        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1580        kernel.config.solver.max_iterations = 0;
1581        assert!(!kernel.external_profile_mode);
1582        assert!(kernel.profile_params_p.is_none());
1583        assert!(kernel.profile_params_ff.is_none());
1584
1585        let err = kernel
1586            .solve_equilibrium_with_profiles(ProfileParams::default(), ProfileParams::default())
1587            .expect_err("invalid runtime control must fail");
1588        match err {
1589            FusionError::ConfigError(msg) => {
1590                assert!(msg.contains("max_iterations"));
1591            }
1592            other => panic!("Unexpected error: {other:?}"),
1593        }
1594
1595        assert!(!kernel.external_profile_mode);
1596        assert!(kernel.profile_params_p.is_none());
1597        assert!(kernel.profile_params_ff.is_none());
1598    }
1599
1600    #[test]
1601    fn test_solve_with_profiles_restores_previous_external_profile_state() {
1602        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1603        let prev_p = ProfileParams {
1604            ped_top: 0.85,
1605            ped_width: 0.07,
1606            ped_height: 0.95,
1607            core_alpha: 0.22,
1608        };
1609        let prev_ff = ProfileParams {
1610            ped_top: 0.92,
1611            ped_width: 0.09,
1612            ped_height: 1.05,
1613            core_alpha: 0.18,
1614        };
1615        kernel.set_external_profiles(prev_p, prev_ff);
1616        kernel.config.solver.max_iterations = 0;
1617
1618        let next_p = ProfileParams {
1619            ped_top: 0.80,
1620            ped_width: 0.06,
1621            ped_height: 1.10,
1622            core_alpha: 0.25,
1623        };
1624        let next_ff = ProfileParams {
1625            ped_top: 0.95,
1626            ped_width: 0.10,
1627            ped_height: 0.90,
1628            core_alpha: 0.15,
1629        };
1630        let err = kernel
1631            .solve_equilibrium_with_profiles(next_p, next_ff)
1632            .expect_err("invalid runtime control must fail");
1633        match err {
1634            FusionError::ConfigError(msg) => {
1635                assert!(msg.contains("max_iterations"));
1636            }
1637            other => panic!("Unexpected error: {other:?}"),
1638        }
1639
1640        assert!(kernel.external_profile_mode);
1641        assert_eq!(kernel.profile_params_p, Some(prev_p));
1642        assert_eq!(kernel.profile_params_ff, Some(prev_ff));
1643    }
1644
1645    #[test]
1646    fn test_solve_with_profiles_rejects_invalid_params_without_state_mutation() {
1647        let mut kernel = FusionKernel::from_file(&config_path("iter_config.json")).unwrap();
1648        let prev_p = ProfileParams {
1649            ped_top: 0.85,
1650            ped_width: 0.07,
1651            ped_height: 0.95,
1652            core_alpha: 0.22,
1653        };
1654        let prev_ff = ProfileParams {
1655            ped_top: 0.92,
1656            ped_width: 0.09,
1657            ped_height: 1.05,
1658            core_alpha: 0.18,
1659        };
1660        kernel.set_external_profiles(prev_p, prev_ff);
1661
1662        let bad_p = ProfileParams {
1663            ped_top: 0.8,
1664            ped_width: 0.0,
1665            ped_height: 1.1,
1666            core_alpha: 0.25,
1667        };
1668        let err = kernel
1669            .solve_equilibrium_with_profiles(bad_p, ProfileParams::default())
1670            .expect_err("invalid profile params must fail before state mutation");
1671        match err {
1672            FusionError::ConfigError(msg) => {
1673                assert!(msg.contains("ped_width"));
1674            }
1675            other => panic!("Unexpected error: {other:?}"),
1676        }
1677
1678        assert!(kernel.external_profile_mode);
1679        assert_eq!(kernel.profile_params_p, Some(prev_p));
1680        assert_eq!(kernel.profile_params_ff, Some(prev_ff));
1681    }
1682}