1use 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
25const SEED_GAUSSIAN_SIGMA: f64 = 1.0;
27
28const INITIAL_JACOBI_ITERS: usize = 50;
30const INNER_SOLVE_ITERS: usize = 50;
32
33const MIN_PSI_AXIS: f64 = 1e-6;
35
36const MIN_PSI_SEPARATION: f64 = 0.1;
38
39const LIMITER_FALLBACK_FACTOR: f64 = 0.1;
41const MIN_CURRENT_INTEGRAL: f64 = 1e-9;
42
43fn 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
58fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum SolverMethod {
91 PicardSor,
93 PicardMultigrid,
95}
96
97pub 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 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 pub fn from_file(path: &str) -> FusionResult<Self> {
131 let config = ReactorConfig::from_file(path)?;
132 Ok(Self::new(config))
133 }
134
135 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(¶ms_p, "params_p")?;
252 Self::validate_profile_params(¶ms_ff, "params_ff")?;
253 Some((params_p, params_ff))
254 } else {
255 None
256 };
257 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 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 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 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 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 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 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 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 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 if (psi_axis_val - psi_boundary_val).abs() < MIN_PSI_SEPARATION {
356 psi_boundary_val = psi_axis_val * LIMITER_FALLBACK_FACTOR;
357 }
358
359 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 ¶ms_p,
371 ¶ms_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 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 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 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 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 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 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 if diff < tol {
476 converged = true;
477 break;
478 }
479 }
480
481 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 pub fn psi(&self) -> &Array2<f64> {
506 &self.state.psi
507 }
508
509 pub fn j_phi(&self) -> &Array2<f64> {
511 &self.state.j_phi
512 }
513
514 pub fn state(&self) -> &PlasmaState {
516 &self.state
517 }
518
519 pub fn grid(&self) -> &Grid2D {
521 &self.grid
522 }
523
524 pub fn set_solver_method(&mut self, method: SolverMethod) {
526 self.solver_method = method;
527 }
528
529 pub fn solver_method(&self) -> SolverMethod {
531 self.solver_method
532 }
533
534 pub fn config(&self) -> &ReactorConfig {
536 &self.config
537 }
538
539 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 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 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 pub fn clear_particle_current_feedback(&mut self) {
651 self.particle_current_feedback = None;
652 self.particle_feedback_coupling = 0.0;
653 }
654
655 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 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(¶ms_p, "params_p")?;
708 Self::validate_profile_params(¶ms_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 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 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 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 assert!(
905 result.converged || result.iterations == 1000,
906 "Should either converge or exhaust iterations"
907 );
908
909 assert!(
911 !kernel.psi().iter().any(|v| v.is_nan()),
912 "Ψ contains NaN after solve"
913 );
914
915 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 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 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}