fusion_core/
kernel.rs

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