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