1use crate::jacobian::{compute_analytical_jacobian, compute_fd_jacobian, forward_model_response};
10use crate::kernel::FusionKernel;
11use crate::source::{mtanh_profile, mtanh_profile_derivatives, ProfileParams};
12use fusion_math::linalg::pinv_svd;
13use fusion_types::config::ReactorConfig;
14use fusion_types::error::{FusionError, FusionResult};
15use fusion_types::state::Grid2D;
16use ndarray::{Array1, Array2};
17
18const N_PARAMS: usize = 8;
19const SOURCE_BETA_MIX: f64 = 0.5;
20const MIN_FLUX_DENOMINATOR: f64 = 1e-9;
21const MIN_CURRENT_INTEGRAL: f64 = 1e-9;
22const MIN_RADIUS: f64 = 1e-9;
23const SENSITIVITY_RELAXATION: f64 = 0.8;
24
25#[derive(Debug, Clone, Copy, PartialEq, Default)]
26pub enum JacobianMode {
27 #[default]
28 FiniteDifference,
29 Analytical,
30}
31
32#[derive(Debug, Clone)]
33pub struct InverseConfig {
34 pub max_iterations: usize,
35 pub tolerance: f64,
36 pub damping: f64,
37 pub fd_step: f64,
38 pub tikhonov: f64,
39 pub jacobian_mode: JacobianMode,
40}
41
42impl Default for InverseConfig {
43 fn default() -> Self {
44 Self {
45 max_iterations: 40,
46 tolerance: 1e-6,
47 damping: 0.6,
48 fd_step: 1e-6,
49 tikhonov: 1e-4,
50 jacobian_mode: JacobianMode::FiniteDifference,
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
56pub struct InverseResult {
57 pub params_p: ProfileParams,
58 pub params_ff: ProfileParams,
59 pub converged: bool,
60 pub iterations: usize,
61 pub residual: f64,
62 pub residual_history: Vec<f64>,
63}
64
65#[derive(Debug, Clone)]
66pub struct KernelInverseConfig {
67 pub inverse: InverseConfig,
68 pub kernel_max_iterations: usize,
69 pub require_kernel_converged: bool,
70}
71
72impl Default for KernelInverseConfig {
73 fn default() -> Self {
74 Self {
75 inverse: InverseConfig::default(),
76 kernel_max_iterations: 80,
77 require_kernel_converged: false,
78 }
79 }
80}
81
82fn pack_params(p: &ProfileParams, ff: &ProfileParams) -> [f64; N_PARAMS] {
83 [
84 p.ped_height,
85 p.ped_top,
86 p.ped_width,
87 p.core_alpha,
88 ff.ped_height,
89 ff.ped_top,
90 ff.ped_width,
91 ff.core_alpha,
92 ]
93}
94
95fn validate_profile_params(params: &ProfileParams, label: &str) -> FusionResult<()> {
96 if !params.ped_top.is_finite() || params.ped_top <= 0.0 {
97 return Err(FusionError::ConfigError(format!(
98 "{label}.ped_top must be finite and > 0, got {}",
99 params.ped_top
100 )));
101 }
102 if !params.ped_width.is_finite() || params.ped_width <= 0.0 {
103 return Err(FusionError::ConfigError(format!(
104 "{label}.ped_width must be finite and > 0, got {}",
105 params.ped_width
106 )));
107 }
108 if !params.ped_height.is_finite() || !params.core_alpha.is_finite() {
109 return Err(FusionError::ConfigError(format!(
110 "{label}.ped_height/core_alpha must be finite"
111 )));
112 }
113 Ok(())
114}
115
116fn unpack_params(x: &[f64; N_PARAMS]) -> (ProfileParams, ProfileParams) {
117 let p = ProfileParams {
118 ped_height: x[0],
119 ped_top: x[1],
120 ped_width: x[2],
121 core_alpha: x[3],
122 };
123 let ff = ProfileParams {
124 ped_height: x[4],
125 ped_top: x[5],
126 ped_width: x[6],
127 core_alpha: x[7],
128 };
129 (p, ff)
130}
131
132fn to_array2(jac: &[Vec<f64>], expected_cols: usize, label: &str) -> FusionResult<Array2<f64>> {
133 if jac.is_empty() {
134 return Err(FusionError::ConfigError(format!(
135 "{label} must contain at least one row"
136 )));
137 }
138 if jac.iter().any(|row| row.len() != expected_cols) {
139 return Err(FusionError::ConfigError(format!(
140 "{label} column count mismatch: expected {expected_cols}"
141 )));
142 }
143 if jac.iter().flatten().any(|v| !v.is_finite()) {
144 return Err(FusionError::ConfigError(format!(
145 "{label} contains non-finite values"
146 )));
147 }
148 let rows = jac.len();
149 let mut out: Array2<f64> = Array2::zeros((rows, expected_cols));
150 for i in 0..rows {
151 for j in 0..expected_cols {
152 out[[i, j]] = jac[i][j];
153 }
154 }
155 Ok(out)
156}
157
158fn validate_flux_denom(flux_denom: f64) -> FusionResult<f64> {
159 if !flux_denom.is_finite() || flux_denom.abs() <= MIN_FLUX_DENOMINATOR {
160 return Err(FusionError::ConfigError(format!(
161 "kernel inverse flux denominator must be finite and |psi_boundary - psi_axis| > {MIN_FLUX_DENOMINATOR}, got {flux_denom}"
162 )));
163 }
164 Ok(flux_denom)
165}
166
167fn validate_radius(radius_m: f64, label: &str) -> FusionResult<f64> {
168 if !radius_m.is_finite() || radius_m <= MIN_RADIUS {
169 return Err(FusionError::ConfigError(format!(
170 "{label} must be finite and > {MIN_RADIUS}, got {radius_m}"
171 )));
172 }
173 Ok(radius_m)
174}
175
176fn validate_probe_psi_norm(probe_psi_norm: &[f64]) -> FusionResult<()> {
177 if probe_psi_norm
178 .iter()
179 .any(|psi| !psi.is_finite() || *psi < 0.0 || *psi > 1.0)
180 {
181 return Err(FusionError::ConfigError(
182 "probe_psi_norm values must be finite and within [0, 1]".to_string(),
183 ));
184 }
185 Ok(())
186}
187
188fn validate_probe_coordinates(probes_rz: &[(f64, f64)]) -> FusionResult<()> {
189 if probes_rz
190 .iter()
191 .any(|(r, z)| !r.is_finite() || !z.is_finite())
192 {
193 return Err(FusionError::ConfigError(
194 "probes_rz coordinates must be finite".to_string(),
195 ));
196 }
197 Ok(())
198}
199
200fn validate_probe_domain(probes_rz: &[(f64, f64)], grid: &Grid2D) -> FusionResult<()> {
201 if grid.nr == 0 || grid.nz == 0 {
202 return Err(FusionError::ConfigError(
203 "probe-domain validation requires non-empty grid".to_string(),
204 ));
205 }
206 let r_min = grid.r[0].min(grid.r[grid.nr - 1]);
207 let r_max = grid.r[0].max(grid.r[grid.nr - 1]);
208 let z_min = grid.z[0].min(grid.z[grid.nz - 1]);
209 let z_max = grid.z[0].max(grid.z[grid.nz - 1]);
210 for (idx, (r, z)) in probes_rz.iter().enumerate() {
211 if *r < r_min || *r > r_max || *z < z_min || *z > z_max {
212 return Err(FusionError::ConfigError(format!(
213 "probe {idx} is outside grid bounds: (R={r}, Z={z}) not in R[{r_min}, {r_max}] Z[{z_min}, {z_max}]"
214 )));
215 }
216 }
217 Ok(())
218}
219
220fn validate_measurements(measurements: &[f64]) -> FusionResult<()> {
221 if measurements.iter().any(|m| !m.is_finite()) {
222 return Err(FusionError::ConfigError(
223 "measurements must be finite".to_string(),
224 ));
225 }
226 Ok(())
227}
228
229fn validate_observables(observables: &[f64], expected_len: usize, label: &str) -> FusionResult<()> {
230 if observables.len() != expected_len {
231 return Err(FusionError::ConfigError(format!(
232 "{label} length mismatch: got {}, expected {expected_len}",
233 observables.len()
234 )));
235 }
236 if observables.iter().any(|v| !v.is_finite()) {
237 return Err(FusionError::ConfigError(format!("{label} must be finite")));
238 }
239 Ok(())
240}
241
242fn validate_jacobian_matrix(
243 jac: &[Vec<f64>],
244 expected_rows: usize,
245 expected_cols: usize,
246 label: &str,
247) -> FusionResult<()> {
248 if jac.len() != expected_rows {
249 return Err(FusionError::ConfigError(format!(
250 "{label} row count mismatch: got {}, expected {expected_rows}",
251 jac.len()
252 )));
253 }
254 if jac.iter().any(|row| row.len() != expected_cols) {
255 return Err(FusionError::ConfigError(format!(
256 "{label} column count mismatch: expected {expected_cols}"
257 )));
258 }
259 if jac.iter().flatten().any(|v| !v.is_finite()) {
260 return Err(FusionError::ConfigError(format!(
261 "{label} contains non-finite values"
262 )));
263 }
264 Ok(())
265}
266
267fn validate_update_vector(delta: &Array1<f64>, label: &str) -> FusionResult<()> {
268 if delta.len() != N_PARAMS {
269 return Err(FusionError::LinAlg(format!(
270 "{label} has unexpected dimension: got {}, expected {N_PARAMS}",
271 delta.len()
272 )));
273 }
274 if delta.iter().any(|v| !v.is_finite()) {
275 return Err(FusionError::LinAlg(format!(
276 "{label} contains non-finite values"
277 )));
278 }
279 Ok(())
280}
281
282fn compute_rmse(prediction: &[f64], measurements: &[f64], label: &str) -> FusionResult<f64> {
283 if prediction.is_empty() || measurements.is_empty() {
284 return Err(FusionError::ConfigError(format!(
285 "{label} vectors must be non-empty"
286 )));
287 }
288 if prediction.len() != measurements.len() {
289 return Err(FusionError::ConfigError(format!(
290 "{label} length mismatch: prediction={}, measurements={}",
291 prediction.len(),
292 measurements.len()
293 )));
294 }
295 if prediction.iter().any(|v| !v.is_finite()) || measurements.iter().any(|v| !v.is_finite()) {
296 return Err(FusionError::ConfigError(format!(
297 "{label} inputs must be finite"
298 )));
299 }
300 let rmse = (prediction
301 .iter()
302 .zip(measurements.iter())
303 .map(|(p, m)| (p - m).powi(2))
304 .sum::<f64>()
305 / measurements.len() as f64)
306 .sqrt();
307 if !rmse.is_finite() {
308 return Err(FusionError::LinAlg(format!(
309 "{label} rmse became non-finite"
310 )));
311 }
312 Ok(rmse)
313}
314
315fn compute_lm_delta(
316 j: &Array2<f64>,
317 residual_vec: &[f64],
318 lambda: f64,
319 label: &str,
320) -> FusionResult<Array1<f64>> {
321 if j.nrows() == 0 || j.ncols() != N_PARAMS {
322 return Err(FusionError::LinAlg(format!(
323 "{label} jacobian shape mismatch: got ({}, {}), expected (_, {N_PARAMS})",
324 j.nrows(),
325 j.ncols()
326 )));
327 }
328 if residual_vec.len() != j.nrows() {
329 return Err(FusionError::LinAlg(format!(
330 "{label} residual length mismatch: residual={}, jacobian_rows={}",
331 residual_vec.len(),
332 j.nrows()
333 )));
334 }
335 if !lambda.is_finite() || lambda <= 0.0 {
336 return Err(FusionError::LinAlg(format!(
337 "{label} lambda must be finite and > 0, got {lambda}"
338 )));
339 }
340 if j.iter().any(|v| !v.is_finite()) || residual_vec.iter().any(|v| !v.is_finite()) {
341 return Err(FusionError::LinAlg(format!(
342 "{label} jacobian/residual inputs must be finite"
343 )));
344 }
345
346 let sqrt_lambda = lambda.sqrt();
347 if !sqrt_lambda.is_finite() || sqrt_lambda <= 0.0 {
348 return Err(FusionError::LinAlg(format!(
349 "{label} sqrt(lambda) must be finite and > 0, got {sqrt_lambda}"
350 )));
351 }
352
353 let mut j_aug: Array2<f64> = Array2::zeros((j.nrows() + N_PARAMS, N_PARAMS));
354 for i in 0..j.nrows() {
355 for k in 0..N_PARAMS {
356 j_aug[[i, k]] = j[[i, k]];
357 }
358 }
359 for k in 0..N_PARAMS {
360 j_aug[[j.nrows() + k, k]] = sqrt_lambda;
361 }
362 if j_aug.iter().any(|v| !v.is_finite()) {
363 return Err(FusionError::LinAlg(format!(
364 "{label} augmented jacobian contains non-finite values"
365 )));
366 }
367
368 let mut r_aug: Array1<f64> = Array1::zeros(j.nrows() + N_PARAMS);
369 for i in 0..j.nrows() {
370 r_aug[i] = residual_vec[i];
371 }
372 if r_aug.iter().any(|v| !v.is_finite()) {
373 return Err(FusionError::LinAlg(format!(
374 "{label} augmented residual contains non-finite values"
375 )));
376 }
377
378 let pinv = pinv_svd(&j_aug, 1e-12);
379 if pinv.iter().any(|v| !v.is_finite()) {
380 return Err(FusionError::LinAlg(format!(
381 "{label} pseudo-inverse contains non-finite values"
382 )));
383 }
384
385 let delta_raw = pinv.dot(&r_aug).mapv(|v| -v);
386 if delta_raw.iter().any(|v| !v.is_finite()) {
387 return Err(FusionError::LinAlg(format!(
388 "{label} raw update vector contains non-finite values"
389 )));
390 }
391 let delta = delta_raw.mapv(|v| v.clamp(-0.5, 0.5));
392 validate_update_vector(&delta, label)?;
393 Ok(delta)
394}
395
396fn nearest_index(axis: &Array1<f64>, value: f64) -> usize {
397 let mut best_idx = 0usize;
398 let mut best_dist = f64::INFINITY;
399 for (idx, &x) in axis.iter().enumerate() {
400 let d = (x - value).abs();
401 if d < best_dist {
402 best_dist = d;
403 best_idx = idx;
404 }
405 }
406 best_idx
407}
408
409fn probe_indices(grid: &Grid2D, probes_rz: &[(f64, f64)]) -> Vec<(usize, usize)> {
410 probes_rz
411 .iter()
412 .map(|&(r, z)| (nearest_index(&grid.z, z), nearest_index(&grid.r, r)))
413 .collect()
414}
415
416fn mtanh_profile_dpsi_norm(
417 psi_norm: f64,
418 params: &ProfileParams,
419 label: &str,
420) -> FusionResult<f64> {
421 if !psi_norm.is_finite() {
422 return Err(FusionError::ConfigError(format!(
423 "{label}.psi_norm must be finite, got {psi_norm}"
424 )));
425 }
426 validate_profile_params(params, label)?;
427 let w = params.ped_width;
428 let ped_top = params.ped_top;
429 let y = (params.ped_top - psi_norm) / w;
430 let tanh_y = y.tanh();
431 let sech2 = 1.0 - tanh_y * tanh_y;
432
433 let d_edge = -0.5 * params.ped_height * sech2 / w;
434 let d_core = if psi_norm.abs() < ped_top {
435 -2.0 * params.core_alpha * psi_norm / ped_top.powi(2)
436 } else {
437 0.0
438 };
439 let d = d_edge + d_core;
440 if !d.is_finite() {
441 return Err(FusionError::ConfigError(format!(
442 "{label}.d_profile_dpsi_norm became non-finite"
443 )));
444 }
445 Ok(d)
446}
447
448fn validate_inverse_config(config: &InverseConfig) -> FusionResult<()> {
449 if config.max_iterations == 0 {
450 return Err(FusionError::ConfigError(
451 "inverse.max_iterations must be >= 1".to_string(),
452 ));
453 }
454 if !config.tolerance.is_finite() || config.tolerance <= 0.0 {
455 return Err(FusionError::ConfigError(
456 "inverse.tolerance must be finite and > 0".to_string(),
457 ));
458 }
459 if !config.damping.is_finite()
460 || !(0.0..=1.0).contains(&config.damping)
461 || config.damping == 0.0
462 {
463 return Err(FusionError::ConfigError(
464 "inverse.damping must be finite and in (0, 1]".to_string(),
465 ));
466 }
467 if !config.fd_step.is_finite() || config.fd_step <= 0.0 {
468 return Err(FusionError::ConfigError(
469 "inverse.fd_step must be finite and > 0".to_string(),
470 ));
471 }
472 if !config.tikhonov.is_finite() || config.tikhonov <= 0.0 {
473 return Err(FusionError::ConfigError(
474 "inverse.tikhonov must be finite and > 0".to_string(),
475 ));
476 }
477 Ok(())
478}
479
480fn validate_kernel_inverse_config(kernel_cfg: &KernelInverseConfig) -> FusionResult<()> {
481 validate_inverse_config(&kernel_cfg.inverse)?;
482 if kernel_cfg.kernel_max_iterations == 0 {
483 return Err(FusionError::ConfigError(
484 "kernel_max_iterations must be >= 1".to_string(),
485 ));
486 }
487 Ok(())
488}
489
490fn kernel_forward_observables(
491 reactor_config: &ReactorConfig,
492 probes_rz: &[(f64, f64)],
493 params_p: ProfileParams,
494 params_ff: ProfileParams,
495 kernel_cfg: &KernelInverseConfig,
496) -> FusionResult<Vec<f64>> {
497 validate_kernel_inverse_config(kernel_cfg)?;
498 validate_profile_params(¶ms_p, "params_p")?;
499 validate_profile_params(¶ms_ff, "params_ff")?;
500 validate_probe_coordinates(probes_rz)?;
501 let mut cfg = reactor_config.clone();
502 cfg.solver.max_iterations = kernel_cfg.kernel_max_iterations;
503
504 let mut kernel = FusionKernel::new(cfg);
505 let result = kernel.solve_equilibrium_with_profiles(params_p, params_ff)?;
506 if kernel_cfg.require_kernel_converged && !result.converged {
507 return Err(FusionError::SolverDiverged {
508 iteration: result.iterations,
509 message: "Kernel forward solve did not converge under inverse constraints".to_string(),
510 });
511 }
512
513 validate_probe_domain(probes_rz, kernel.grid())?;
514 let observables = kernel.sample_psi_at_probes(probes_rz)?;
515 validate_observables(&observables, probes_rz.len(), "kernel forward observables")?;
516 Ok(observables)
517}
518
519fn solve_linearized_sensitivity(
520 grid: &Grid2D,
521 ds_dx: &Array2<f64>,
522 ds_dpsi: &Array2<f64>,
523 iterations: usize,
524) -> FusionResult<Array2<f64>> {
525 if grid.nz < 3 || grid.nr < 3 {
526 return Err(FusionError::ConfigError(format!(
527 "sensitivity grid must be at least 3x3, got nz={}, nr={}",
528 grid.nz, grid.nr
529 )));
530 }
531 if iterations == 0 {
532 return Err(FusionError::ConfigError(
533 "sensitivity iterations must be >= 1".to_string(),
534 ));
535 }
536 if !grid.dr.is_finite() || !grid.dz.is_finite() || grid.dr <= 0.0 || grid.dz <= 0.0 {
537 return Err(FusionError::ConfigError(format!(
538 "sensitivity grid spacing must be finite and > 0, got dr={}, dz={}",
539 grid.dr, grid.dz
540 )));
541 }
542 if ds_dx.dim() != (grid.nz, grid.nr) || ds_dpsi.dim() != (grid.nz, grid.nr) {
543 return Err(FusionError::ConfigError(format!(
544 "sensitivity source shape mismatch: ds_dx={:?}, ds_dpsi={:?}, expected=({}, {})",
545 ds_dx.dim(),
546 ds_dpsi.dim(),
547 grid.nz,
548 grid.nr
549 )));
550 }
551 if ds_dx.iter().any(|v| !v.is_finite()) || ds_dpsi.iter().any(|v| !v.is_finite()) {
552 return Err(FusionError::ConfigError(
553 "sensitivity sources must be finite".to_string(),
554 ));
555 }
556
557 let mut delta: Array2<f64> = Array2::zeros((grid.nz, grid.nr));
558 let mut next = delta.clone();
559 let dr_sq: f64 = grid.dr * grid.dr;
560
561 for _ in 0..iterations {
562 for iz in 1..grid.nz - 1 {
563 for ir in 1..grid.nr - 1 {
564 let coupled_rhs: f64 = ds_dpsi[[iz, ir]] * delta[[iz, ir]] + ds_dx[[iz, ir]];
565 if !coupled_rhs.is_finite() {
566 return Err(FusionError::LinAlg(
567 "sensitivity coupled RHS became non-finite".to_string(),
568 ));
569 }
570 let jacobi: f64 = 0.25_f64
571 * (delta[[iz - 1, ir]]
572 + delta[[iz + 1, ir]]
573 + delta[[iz, ir - 1]]
574 + delta[[iz, ir + 1]]
575 - dr_sq * coupled_rhs);
576 if !jacobi.is_finite() {
577 return Err(FusionError::LinAlg(
578 "sensitivity jacobi update became non-finite".to_string(),
579 ));
580 }
581 next[[iz, ir]] = (1.0 - SENSITIVITY_RELAXATION) * delta[[iz, ir]]
582 + SENSITIVITY_RELAXATION * jacobi;
583 if !next[[iz, ir]].is_finite() {
584 return Err(FusionError::LinAlg(
585 "sensitivity state update became non-finite".to_string(),
586 ));
587 }
588 }
589 }
590
591 for ir in 0..grid.nr {
592 next[[0, ir]] = 0.0;
593 next[[grid.nz - 1, ir]] = 0.0;
594 }
595 for iz in 0..grid.nz {
596 next[[iz, 0]] = 0.0;
597 next[[iz, grid.nr - 1]] = 0.0;
598 }
599
600 std::mem::swap(&mut delta, &mut next);
601 }
602
603 if delta.iter().any(|v| !v.is_finite()) {
604 return Err(FusionError::LinAlg(
605 "sensitivity solution contains non-finite values".to_string(),
606 ));
607 }
608 Ok(delta)
609}
610
611fn kernel_analytical_forward_and_jacobian(
612 reactor_config: &ReactorConfig,
613 probes_rz: &[(f64, f64)],
614 params_p: ProfileParams,
615 params_ff: ProfileParams,
616 kernel_cfg: &KernelInverseConfig,
617) -> FusionResult<(Vec<f64>, Vec<Vec<f64>>)> {
618 validate_kernel_inverse_config(kernel_cfg)?;
619 validate_profile_params(¶ms_p, "params_p")?;
620 validate_profile_params(¶ms_ff, "params_ff")?;
621 validate_probe_coordinates(probes_rz)?;
622 let mut cfg = reactor_config.clone();
623 cfg.solver.max_iterations = kernel_cfg.kernel_max_iterations;
624
625 let mut kernel = FusionKernel::new(cfg);
626 let solve_result = kernel.solve_equilibrium_with_profiles(params_p, params_ff)?;
627 if kernel_cfg.require_kernel_converged && !solve_result.converged {
628 return Err(FusionError::SolverDiverged {
629 iteration: solve_result.iterations,
630 message: "Kernel forward solve did not converge under inverse constraints".to_string(),
631 });
632 }
633
634 validate_probe_domain(probes_rz, kernel.grid())?;
635 let base_observables = kernel.sample_psi_at_probes(probes_rz)?;
636 validate_observables(
637 &base_observables,
638 probes_rz.len(),
639 "kernel analytical observables",
640 )?;
641 let grid = kernel.grid();
642 let psi = kernel.psi();
643 let mu0 = kernel.config().physics.vacuum_permeability;
644 let i_target = kernel.config().physics.plasma_current_target;
645 if !mu0.is_finite() || mu0 <= 0.0 {
646 return Err(FusionError::ConfigError(format!(
647 "kernel inverse mu0 must be finite and > 0, got {mu0}"
648 )));
649 }
650 if !i_target.is_finite() {
651 return Err(FusionError::ConfigError(format!(
652 "kernel inverse i_target must be finite, got {i_target}"
653 )));
654 }
655
656 let flux_denom = validate_flux_denom(solve_result.psi_boundary - solve_result.psi_axis)?;
657
658 let mut psi_norm = Array2::zeros((grid.nz, grid.nr));
659 let mut inside = Array2::from_elem((grid.nz, grid.nr), false);
660 let mut raw = Array2::zeros((grid.nz, grid.nr));
661 let mut raw_dpsi_norm = Array2::zeros((grid.nz, grid.nr));
662
663 for iz in 0..grid.nz {
664 for ir in 0..grid.nr {
665 let psi_n = (psi[[iz, ir]] - solve_result.psi_axis) / flux_denom;
666 if !psi_n.is_finite() {
667 return Err(FusionError::ConfigError(format!(
668 "kernel inverse psi_norm must be finite, got {psi_n}"
669 )));
670 }
671 psi_norm[[iz, ir]] = psi_n;
672 if !(0.0..1.0).contains(&psi_n) {
673 continue;
674 }
675
676 let r = validate_radius(grid.rr[[iz, ir]], "kernel inverse in-plasma radius")?;
677 let p = mtanh_profile(psi_n, ¶ms_p);
678 let ff = mtanh_profile(psi_n, ¶ms_ff);
679 let dp_dpsi = mtanh_profile_dpsi_norm(psi_n, ¶ms_p, "params_p")?;
680 let dff_dpsi = mtanh_profile_dpsi_norm(psi_n, ¶ms_ff, "params_ff")?;
681
682 raw[[iz, ir]] = SOURCE_BETA_MIX * r * p + (1.0 - SOURCE_BETA_MIX) * (ff / (mu0 * r));
683 raw_dpsi_norm[[iz, ir]] =
684 SOURCE_BETA_MIX * r * dp_dpsi + (1.0 - SOURCE_BETA_MIX) * (dff_dpsi / (mu0 * r));
685 inside[[iz, ir]] = true;
686 }
687 }
688
689 let i_raw = raw.iter().sum::<f64>() * grid.dr * grid.dz;
690 if !i_raw.is_finite() || i_raw.abs() <= MIN_CURRENT_INTEGRAL {
691 return Err(FusionError::ConfigError(format!(
692 "kernel inverse raw current integral must be finite and |i_raw| > {MIN_CURRENT_INTEGRAL}, got {i_raw}"
693 )));
694 }
695 let scale = i_target / i_raw;
696 if !scale.is_finite() {
697 return Err(FusionError::ConfigError(
698 "kernel inverse source scaling became non-finite".to_string(),
699 ));
700 }
701
702 let mut ds_dpsi = Array2::zeros((grid.nz, grid.nr));
704 for iz in 0..grid.nz {
705 for ir in 0..grid.nr {
706 if !inside[[iz, ir]] {
707 continue;
708 }
709 let r = validate_radius(grid.rr[[iz, ir]], "kernel inverse in-plasma radius")?;
710 let dj_dpsi = scale * raw_dpsi_norm[[iz, ir]] / flux_denom;
711 ds_dpsi[[iz, ir]] = -mu0 * r * dj_dpsi;
712 }
713 }
714
715 let probe_idx = probe_indices(grid, probes_rz);
716 let mut jac = vec![vec![0.0; N_PARAMS]; probes_rz.len()];
717 let sens_iters = (kernel_cfg.kernel_max_iterations.max(20) * 2).min(600);
718
719 for (col, _) in [(); N_PARAMS].iter().enumerate() {
720 let mut d_raw = Array2::zeros((grid.nz, grid.nr));
721 for iz in 0..grid.nz {
722 for ir in 0..grid.nr {
723 if !inside[[iz, ir]] {
724 continue;
725 }
726
727 let psi_n = psi_norm[[iz, ir]];
728 let r = validate_radius(grid.rr[[iz, ir]], "kernel inverse in-plasma radius")?;
729 if col < 4 {
730 let dp = mtanh_profile_derivatives(psi_n, ¶ms_p)[col];
731 d_raw[[iz, ir]] = SOURCE_BETA_MIX * r * dp;
732 } else {
733 let dff = mtanh_profile_derivatives(psi_n, ¶ms_ff)[col - 4];
734 d_raw[[iz, ir]] = (1.0 - SOURCE_BETA_MIX) * (dff / (mu0 * r));
735 }
736 }
737 }
738
739 let d_i = d_raw.iter().sum::<f64>() * grid.dr * grid.dz;
740 let mut ds_dx = Array2::zeros((grid.nz, grid.nr));
741 for iz in 0..grid.nz {
742 for ir in 0..grid.nr {
743 if !inside[[iz, ir]] {
744 continue;
745 }
746 let r = validate_radius(grid.rr[[iz, ir]], "kernel inverse in-plasma radius")?;
747 let dj = scale * (d_raw[[iz, ir]] - raw[[iz, ir]] * d_i / i_raw);
748 ds_dx[[iz, ir]] = -mu0 * r * dj;
749 }
750 }
751
752 let delta_psi = solve_linearized_sensitivity(grid, &ds_dx, &ds_dpsi, sens_iters)?;
753 for (row, &(iz, ir)) in probe_idx.iter().enumerate() {
754 jac[row][col] = -delta_psi[[iz, ir]];
755 }
756 }
757
758 validate_jacobian_matrix(
759 &jac,
760 probes_rz.len(),
761 N_PARAMS,
762 "kernel analytical jacobian",
763 )?;
764 Ok((base_observables, jac))
765}
766
767fn kernel_fd_jacobian_from_base(
768 reactor_config: &ReactorConfig,
769 probes_rz: &[(f64, f64)],
770 params_p: ProfileParams,
771 params_ff: ProfileParams,
772 kernel_cfg: &KernelInverseConfig,
773 base: &[f64],
774) -> FusionResult<Vec<Vec<f64>>> {
775 let mut jac = vec![vec![0.0; N_PARAMS]; probes_rz.len()];
776 validate_kernel_inverse_config(kernel_cfg)?;
777 validate_probe_coordinates(probes_rz)?;
778 validate_observables(base, probes_rz.len(), "kernel fd base observables")?;
779 let h = kernel_cfg.inverse.fd_step;
780
781 for (col, _) in [(); N_PARAMS].iter().enumerate() {
782 let mut x = pack_params(¶ms_p, ¶ms_ff);
783 x[col] += h;
784 let (p_pert, ff_pert) = unpack_params(&x);
785 let pert =
786 kernel_forward_observables(reactor_config, probes_rz, p_pert, ff_pert, kernel_cfg)?;
787 for i in 0..probes_rz.len() {
788 jac[i][col] = (pert[i] - base[i]) / h;
789 }
790 }
791 validate_jacobian_matrix(&jac, probes_rz.len(), N_PARAMS, "kernel fd jacobian")?;
792 Ok(jac)
793}
794
795pub fn reconstruct_equilibrium(
797 probe_psi_norm: &[f64],
798 measurements: &[f64],
799 initial_params_p: ProfileParams,
800 initial_params_ff: ProfileParams,
801 config: &InverseConfig,
802) -> FusionResult<InverseResult> {
803 if probe_psi_norm.is_empty() || measurements.is_empty() {
804 return Err(FusionError::ConfigError(
805 "Probe and measurement vectors must be non-empty".to_string(),
806 ));
807 }
808 if probe_psi_norm.len() != measurements.len() {
809 return Err(FusionError::ConfigError(format!(
810 "Length mismatch: probes={}, measurements={}",
811 probe_psi_norm.len(),
812 measurements.len()
813 )));
814 }
815 validate_inverse_config(config)?;
816 validate_probe_psi_norm(probe_psi_norm)?;
817 validate_measurements(measurements)?;
818 validate_profile_params(&initial_params_p, "initial_params_p")?;
819 validate_profile_params(&initial_params_ff, "initial_params_ff")?;
820
821 let mut x = pack_params(&initial_params_p, &initial_params_ff);
822 let mut residual_history = Vec::with_capacity(config.max_iterations + 1);
823 let mut converged = false;
824 let mut iter_done = 0;
825 let mut damping = config.damping;
826
827 for iter in 0..config.max_iterations {
828 let (params_p, params_ff) = unpack_params(&x);
829 let prediction = forward_model_response(probe_psi_norm, ¶ms_p, ¶ms_ff)?;
830 let residual_vec: Vec<f64> = prediction
831 .iter()
832 .zip(measurements.iter())
833 .map(|(p, m)| p - m)
834 .collect();
835 let residual = compute_rmse(&prediction, measurements, "inverse residual")?;
836 residual_history.push(residual);
837 iter_done = iter + 1;
838
839 if residual < config.tolerance {
840 converged = true;
841 break;
842 }
843
844 let jac = match config.jacobian_mode {
845 JacobianMode::Analytical => {
846 compute_analytical_jacobian(¶ms_p, ¶ms_ff, probe_psi_norm)
847 }
848 JacobianMode::FiniteDifference => {
849 compute_fd_jacobian(¶ms_p, ¶ms_ff, probe_psi_norm, config.fd_step)
850 }
851 }?;
852
853 let j = to_array2(&jac, N_PARAMS, "inverse jacobian")?;
854 let delta = compute_lm_delta(&j, &residual_vec, config.tikhonov, "inverse.delta")?;
855
856 let mut accepted = false;
857 let mut local_damping = damping;
858 for _ in 0..8 {
859 let mut x_trial = x;
860 for i in 0..N_PARAMS {
861 x_trial[i] += local_damping * delta[i];
862 }
863 let (p_trial, ff_trial) = unpack_params(&x_trial);
864 if validate_profile_params(&p_trial, "params_p").is_err()
865 || validate_profile_params(&ff_trial, "params_ff").is_err()
866 {
867 local_damping *= 0.5;
868 continue;
869 }
870 let pred_trial = forward_model_response(probe_psi_norm, &p_trial, &ff_trial)?;
871 let residual_trial = compute_rmse(&pred_trial, measurements, "inverse trial residual")?;
872
873 if residual_trial <= residual {
874 x = x_trial;
875 damping = (local_damping * 1.2).min(1.0);
876 accepted = true;
877 break;
878 }
879 local_damping *= 0.5;
880 }
881
882 if !accepted {
883 break;
884 }
885 }
886
887 let (params_p, params_ff) = unpack_params(&x);
888 validate_profile_params(¶ms_p, "params_p")?;
889 validate_profile_params(¶ms_ff, "params_ff")?;
890 let prediction = forward_model_response(probe_psi_norm, ¶ms_p, ¶ms_ff)?;
891 let final_residual = compute_rmse(&prediction, measurements, "inverse final residual")?;
892
893 Ok(InverseResult {
894 params_p,
895 params_ff,
896 converged,
897 iterations: iter_done,
898 residual: final_residual,
899 residual_history,
900 })
901}
902
903pub fn reconstruct_equilibrium_with_kernel(
908 reactor_config: &ReactorConfig,
909 probes_rz: &[(f64, f64)],
910 measurements: &[f64],
911 initial_params_p: ProfileParams,
912 initial_params_ff: ProfileParams,
913 kernel_cfg: &KernelInverseConfig,
914) -> FusionResult<InverseResult> {
915 if probes_rz.is_empty() || measurements.is_empty() {
916 return Err(FusionError::ConfigError(
917 "Probe and measurement vectors must be non-empty".to_string(),
918 ));
919 }
920 if probes_rz.len() != measurements.len() {
921 return Err(FusionError::ConfigError(format!(
922 "Length mismatch: probes={}, measurements={}",
923 probes_rz.len(),
924 measurements.len()
925 )));
926 }
927 validate_kernel_inverse_config(kernel_cfg)?;
928 validate_probe_coordinates(probes_rz)?;
929 validate_measurements(measurements)?;
930 validate_profile_params(&initial_params_p, "initial_params_p")?;
931 validate_profile_params(&initial_params_ff, "initial_params_ff")?;
932
933 let mut x = pack_params(&initial_params_p, &initial_params_ff);
934 let mut residual_history = Vec::with_capacity(kernel_cfg.inverse.max_iterations + 1);
935 let mut converged = false;
936 let mut iter_done = 0usize;
937 let mut damping = kernel_cfg.inverse.damping;
938
939 for iter in 0..kernel_cfg.inverse.max_iterations {
940 let (params_p, params_ff) = unpack_params(&x);
941 let (prediction, jac) = match kernel_cfg.inverse.jacobian_mode {
942 JacobianMode::Analytical => kernel_analytical_forward_and_jacobian(
943 reactor_config,
944 probes_rz,
945 params_p,
946 params_ff,
947 kernel_cfg,
948 )?,
949 JacobianMode::FiniteDifference => {
950 let pred = kernel_forward_observables(
951 reactor_config,
952 probes_rz,
953 params_p,
954 params_ff,
955 kernel_cfg,
956 )?;
957 let jac = kernel_fd_jacobian_from_base(
958 reactor_config,
959 probes_rz,
960 params_p,
961 params_ff,
962 kernel_cfg,
963 &pred,
964 )?;
965 (pred, jac)
966 }
967 };
968 let residual_vec: Vec<f64> = prediction
969 .iter()
970 .zip(measurements.iter())
971 .map(|(p, m)| p - m)
972 .collect();
973 let residual = compute_rmse(&prediction, measurements, "kernel inverse residual")?;
974 residual_history.push(residual);
975 iter_done = iter + 1;
976
977 if residual < kernel_cfg.inverse.tolerance {
978 converged = true;
979 break;
980 }
981
982 let j = to_array2(&jac, N_PARAMS, "kernel inverse jacobian")?;
983 let delta = compute_lm_delta(
984 &j,
985 &residual_vec,
986 kernel_cfg.inverse.tikhonov,
987 "kernel_inverse.delta",
988 )?;
989
990 let mut accepted = false;
991 let mut local_damping = damping;
992 for _ in 0..6 {
993 let mut x_trial = x;
994 for i in 0..N_PARAMS {
995 x_trial[i] += local_damping * delta[i];
996 }
997 let (p_trial, ff_trial) = unpack_params(&x_trial);
998 if validate_profile_params(&p_trial, "params_p").is_err()
999 || validate_profile_params(&ff_trial, "params_ff").is_err()
1000 {
1001 local_damping *= 0.5;
1002 continue;
1003 }
1004 let pred_trial = kernel_forward_observables(
1005 reactor_config,
1006 probes_rz,
1007 p_trial,
1008 ff_trial,
1009 kernel_cfg,
1010 )?;
1011 let residual_trial =
1012 compute_rmse(&pred_trial, measurements, "kernel inverse trial residual")?;
1013 if residual_trial <= residual {
1014 x = x_trial;
1015 damping = (local_damping * 1.1).min(1.0);
1016 accepted = true;
1017 break;
1018 }
1019 local_damping *= 0.5;
1020 }
1021
1022 if !accepted {
1023 break;
1024 }
1025 }
1026
1027 let (params_p, params_ff) = unpack_params(&x);
1028 validate_profile_params(¶ms_p, "params_p")?;
1029 validate_profile_params(¶ms_ff, "params_ff")?;
1030 let prediction =
1031 kernel_forward_observables(reactor_config, probes_rz, params_p, params_ff, kernel_cfg)?;
1032 let final_residual = compute_rmse(&prediction, measurements, "kernel inverse final residual")?;
1033
1034 Ok(InverseResult {
1035 params_p,
1036 params_ff,
1037 converged,
1038 iterations: iter_done,
1039 residual: final_residual,
1040 residual_history,
1041 })
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use crate::jacobian::forward_model_response;
1048 use fusion_types::config::ReactorConfig;
1049 use ndarray::{Array1, Array2};
1050 use std::path::PathBuf;
1051
1052 fn project_root() -> PathBuf {
1053 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1054 .join("..")
1055 .join("..")
1056 .join("..")
1057 }
1058
1059 fn config_path(relative: &str) -> String {
1060 project_root().join(relative).to_string_lossy().to_string()
1061 }
1062
1063 #[test]
1064 fn test_reconstruct_with_analytical_jacobian() {
1065 let true_p = ProfileParams {
1066 ped_top: 0.91,
1067 ped_width: 0.08,
1068 ped_height: 1.2,
1069 core_alpha: 0.3,
1070 };
1071 let true_ff = ProfileParams {
1072 ped_top: 0.84,
1073 ped_width: 0.06,
1074 ped_height: 0.9,
1075 core_alpha: 0.15,
1076 };
1077 let probes: Vec<f64> = (0..60).map(|i| i as f64 / 59.0).collect();
1078 let measurements = forward_model_response(&probes, &true_p, &true_ff).unwrap();
1079
1080 let init_p = ProfileParams {
1081 ped_top: 0.75,
1082 ped_width: 0.12,
1083 ped_height: 0.7,
1084 core_alpha: 0.05,
1085 };
1086 let init_ff = ProfileParams {
1087 ped_top: 0.70,
1088 ped_width: 0.10,
1089 ped_height: 0.6,
1090 core_alpha: 0.02,
1091 };
1092 let init_prediction = forward_model_response(&probes, &init_p, &init_ff).unwrap();
1093 let init_residual = (init_prediction
1094 .iter()
1095 .zip(measurements.iter())
1096 .map(|(p, m)| (p - m).powi(2))
1097 .sum::<f64>()
1098 / measurements.len() as f64)
1099 .sqrt();
1100
1101 let cfg = InverseConfig {
1102 jacobian_mode: JacobianMode::Analytical,
1103 max_iterations: 50,
1104 damping: 0.7,
1105 tolerance: 1e-9,
1106 ..Default::default()
1107 };
1108 let result =
1109 reconstruct_equilibrium(&probes, &measurements, init_p, init_ff, &cfg).unwrap();
1110
1111 assert!(
1112 result.residual < init_residual * 0.75,
1113 "Expected >=25% residual reduction: initial={}, final={}",
1114 init_residual,
1115 result.residual,
1116 );
1117 assert!(result.iterations > 0);
1118 }
1119
1120 #[test]
1121 fn test_fd_and_analytical_modes_agree() {
1122 let true_p = ProfileParams {
1123 ped_top: 0.9,
1124 ped_width: 0.07,
1125 ped_height: 1.1,
1126 core_alpha: 0.2,
1127 };
1128 let true_ff = ProfileParams {
1129 ped_top: 0.83,
1130 ped_width: 0.05,
1131 ped_height: 0.95,
1132 core_alpha: 0.1,
1133 };
1134 let probes: Vec<f64> = (0..48).map(|i| i as f64 / 47.0).collect();
1135 let measurements = forward_model_response(&probes, &true_p, &true_ff).unwrap();
1136
1137 let init = ProfileParams {
1138 ped_top: 0.8,
1139 ped_width: 0.11,
1140 ped_height: 0.8,
1141 core_alpha: 0.05,
1142 };
1143
1144 let cfg_a = InverseConfig {
1145 jacobian_mode: JacobianMode::Analytical,
1146 max_iterations: 35,
1147 ..Default::default()
1148 };
1149 let cfg_fd = InverseConfig {
1150 jacobian_mode: JacobianMode::FiniteDifference,
1151 max_iterations: 35,
1152 ..Default::default()
1153 };
1154
1155 let ra = reconstruct_equilibrium(&probes, &measurements, init, init, &cfg_a).unwrap();
1156 let rf = reconstruct_equilibrium(&probes, &measurements, init, init, &cfg_fd).unwrap();
1157
1158 assert!(
1159 (ra.residual - rf.residual).abs() < 1e-3,
1160 "Modes diverged too much: analytical={}, fd={}",
1161 ra.residual,
1162 rf.residual
1163 );
1164 }
1165
1166 #[test]
1167 fn test_inverse_rejects_invalid_solver_config() {
1168 let probes: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
1169 let measurements = vec![0.1; probes.len()];
1170 let init = ProfileParams::default();
1171 let cfg = InverseConfig {
1172 damping: 0.0,
1173 ..Default::default()
1174 };
1175 let err = reconstruct_equilibrium(&probes, &measurements, init, init, &cfg)
1176 .expect_err("invalid config must error");
1177 match err {
1178 FusionError::ConfigError(msg) => assert!(msg.contains("inverse.damping")),
1179 other => panic!("Unexpected error: {other:?}"),
1180 }
1181 }
1182
1183 #[test]
1184 fn test_inverse_rejects_invalid_initial_profile_params() {
1185 let probes: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
1186 let measurements = vec![0.1; probes.len()];
1187 let bad_init = ProfileParams {
1188 ped_top: 0.0,
1189 ..ProfileParams::default()
1190 };
1191 let cfg = InverseConfig::default();
1192 let err = reconstruct_equilibrium(
1193 &probes,
1194 &measurements,
1195 bad_init,
1196 ProfileParams::default(),
1197 &cfg,
1198 )
1199 .expect_err("invalid initial profile params must error");
1200 match err {
1201 FusionError::ConfigError(msg) => assert!(msg.contains("initial_params_p.ped_top")),
1202 other => panic!("Unexpected error: {other:?}"),
1203 }
1204 }
1205
1206 #[test]
1207 fn test_inverse_runtime_scalar_guards_reject_invalid_flux_and_radius() {
1208 assert!(validate_flux_denom(0.0).is_err());
1209 assert!(validate_flux_denom(f64::NAN).is_err());
1210 assert!(validate_flux_denom(1e-3).is_ok());
1211
1212 assert!(validate_radius(0.0, "r").is_err());
1213 assert!(validate_radius(-0.5, "r").is_err());
1214 assert!(validate_radius(f64::INFINITY, "r").is_err());
1215 assert!(validate_radius(0.25, "r").is_ok());
1216 }
1217
1218 #[test]
1219 fn test_inverse_update_vector_validation_guards() {
1220 let bad_len = Array1::zeros(N_PARAMS - 1);
1221 assert!(validate_update_vector(&bad_len, "delta").is_err());
1222
1223 let mut bad_values = Array1::zeros(N_PARAMS);
1224 bad_values[2] = f64::NAN;
1225 assert!(validate_update_vector(&bad_values, "delta").is_err());
1226
1227 let ok = Array1::zeros(N_PARAMS);
1228 assert!(validate_update_vector(&ok, "delta").is_ok());
1229 }
1230
1231 #[test]
1232 fn test_inverse_observable_and_jacobian_validation_guards() {
1233 assert!(validate_observables(&[1.0, 2.0], 2, "obs").is_ok());
1234 assert!(validate_observables(&[1.0, f64::NAN], 2, "obs").is_err());
1235 assert!(validate_observables(&[1.0], 2, "obs").is_err());
1236
1237 let jac_ok = vec![vec![0.0; N_PARAMS]; 2];
1238 assert!(validate_jacobian_matrix(&jac_ok, 2, N_PARAMS, "jac").is_ok());
1239
1240 let jac_bad_rows = vec![vec![0.0; N_PARAMS]; 1];
1241 assert!(validate_jacobian_matrix(&jac_bad_rows, 2, N_PARAMS, "jac").is_err());
1242
1243 let jac_bad_cols = vec![vec![0.0; N_PARAMS - 1]; 2];
1244 assert!(validate_jacobian_matrix(&jac_bad_cols, 2, N_PARAMS, "jac").is_err());
1245
1246 let mut jac_bad_vals = vec![vec![0.0; N_PARAMS]; 2];
1247 jac_bad_vals[0][3] = f64::INFINITY;
1248 assert!(validate_jacobian_matrix(&jac_bad_vals, 2, N_PARAMS, "jac").is_err());
1249 }
1250
1251 #[test]
1252 fn test_kernel_inverse_api_input_validation() {
1253 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1254 .unwrap();
1255 let kcfg = KernelInverseConfig::default();
1256 let init = ProfileParams::default();
1257
1258 let err = reconstruct_equilibrium_with_kernel(
1259 &cfg,
1260 &[(6.2, 0.0), (6.3, 0.1)],
1261 &[1.0],
1262 init,
1263 init,
1264 &kcfg,
1265 )
1266 .unwrap_err();
1267
1268 match err {
1269 FusionError::ConfigError(msg) => {
1270 assert!(msg.contains("Length mismatch"), "Unexpected message: {msg}")
1271 }
1272 _ => panic!("Expected ConfigError for mismatched inputs"),
1273 }
1274 }
1275
1276 #[test]
1277 fn test_kernel_inverse_rejects_invalid_kernel_iteration_config() {
1278 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1279 .unwrap();
1280 let kcfg = KernelInverseConfig {
1281 kernel_max_iterations: 0,
1282 ..Default::default()
1283 };
1284 let init = ProfileParams::default();
1285 let err = reconstruct_equilibrium_with_kernel(
1286 &cfg,
1287 &[(6.2, 0.0), (6.3, 0.1)],
1288 &[1.0, 1.0],
1289 init,
1290 init,
1291 &kcfg,
1292 )
1293 .expect_err("invalid kernel config must error");
1294 match err {
1295 FusionError::ConfigError(msg) => assert!(msg.contains("kernel_max_iterations")),
1296 other => panic!("Unexpected error: {other:?}"),
1297 }
1298 }
1299
1300 #[test]
1301 fn test_kernel_inverse_rejects_invalid_initial_profile_params() {
1302 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1303 .unwrap();
1304 let kcfg = KernelInverseConfig::default();
1305 let bad_init = ProfileParams {
1306 ped_width: 0.0,
1307 ..ProfileParams::default()
1308 };
1309 let err = reconstruct_equilibrium_with_kernel(
1310 &cfg,
1311 &[(6.2, 0.0), (6.3, 0.1)],
1312 &[1.0, 1.0],
1313 bad_init,
1314 ProfileParams::default(),
1315 &kcfg,
1316 )
1317 .expect_err("invalid kernel initial profile params must error");
1318 match err {
1319 FusionError::ConfigError(msg) => assert!(msg.contains("initial_params_p.ped_width")),
1320 other => panic!("Unexpected error: {other:?}"),
1321 }
1322 }
1323
1324 #[test]
1325 fn test_inverse_rejects_non_finite_measurements() {
1326 let probes: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
1327 let mut measurements = vec![0.1; probes.len()];
1328 measurements[3] = f64::NAN;
1329 let err = reconstruct_equilibrium(
1330 &probes,
1331 &measurements,
1332 ProfileParams::default(),
1333 ProfileParams::default(),
1334 &InverseConfig::default(),
1335 )
1336 .expect_err("non-finite measurements must error");
1337 match err {
1338 FusionError::ConfigError(msg) => assert!(msg.contains("measurements must be finite")),
1339 other => panic!("Unexpected error: {other:?}"),
1340 }
1341 }
1342
1343 #[test]
1344 fn test_kernel_inverse_rejects_non_finite_measurements_or_probes() {
1345 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1346 .unwrap();
1347 let kcfg = KernelInverseConfig::default();
1348 let bad_probe_err = reconstruct_equilibrium_with_kernel(
1349 &cfg,
1350 &[(6.2, 0.0), (f64::INFINITY, 0.1)],
1351 &[1.0, 1.0],
1352 ProfileParams::default(),
1353 ProfileParams::default(),
1354 &kcfg,
1355 )
1356 .expect_err("non-finite probe coordinates must error");
1357 match bad_probe_err {
1358 FusionError::ConfigError(msg) => {
1359 assert!(msg.contains("probes_rz coordinates must be finite"))
1360 }
1361 other => panic!("Unexpected error: {other:?}"),
1362 }
1363
1364 let bad_measurement_err = reconstruct_equilibrium_with_kernel(
1365 &cfg,
1366 &[(6.2, 0.0), (6.3, 0.1)],
1367 &[1.0, f64::NAN],
1368 ProfileParams::default(),
1369 ProfileParams::default(),
1370 &kcfg,
1371 )
1372 .expect_err("non-finite measurements must error");
1373 match bad_measurement_err {
1374 FusionError::ConfigError(msg) => assert!(msg.contains("measurements must be finite")),
1375 other => panic!("Unexpected error: {other:?}"),
1376 }
1377 }
1378
1379 #[test]
1380 fn test_kernel_inverse_rejects_out_of_domain_probes() {
1381 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1382 .unwrap();
1383 let kcfg = KernelInverseConfig::default();
1384 let err = reconstruct_equilibrium_with_kernel(
1385 &cfg,
1386 &[(100.0, 0.0), (6.3, 0.1)],
1387 &[1.0, 1.0],
1388 ProfileParams::default(),
1389 ProfileParams::default(),
1390 &kcfg,
1391 )
1392 .expect_err("out-of-domain probes must error");
1393 match err {
1394 FusionError::ConfigError(msg) => assert!(msg.contains("outside grid bounds")),
1395 other => panic!("Unexpected error: {other:?}"),
1396 }
1397 }
1398
1399 #[test]
1400 fn test_kernel_fd_jacobian_rejects_base_length_mismatch() {
1401 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1402 .unwrap();
1403 let probes = vec![(6.2, 0.0), (6.3, 0.1)];
1404 let kcfg = KernelInverseConfig::default();
1405 let params = ProfileParams::default();
1406 let err = kernel_fd_jacobian_from_base(&cfg, &probes, params, params, &kcfg, &[0.1])
1407 .expect_err("base observables length mismatch must error");
1408 match err {
1409 FusionError::ConfigError(msg) => {
1410 assert!(msg.contains("kernel fd base observables length mismatch"))
1411 }
1412 other => panic!("Unexpected error: {other:?}"),
1413 }
1414 }
1415
1416 #[test]
1417 fn test_sensitivity_solver_rejects_invalid_inputs() {
1418 let grid = Grid2D::new(4, 4, 1.0, 2.0, -1.0, 1.0);
1419 let ds = Array2::zeros((4, 4));
1420 assert!(solve_linearized_sensitivity(&grid, &ds, &ds, 2).is_ok());
1421
1422 let bad_shape = Array2::zeros((3, 4));
1423 assert!(solve_linearized_sensitivity(&grid, &bad_shape, &ds, 2).is_err());
1424 assert!(solve_linearized_sensitivity(&grid, &ds, &bad_shape, 2).is_err());
1425
1426 let mut bad_values = Array2::zeros((4, 4));
1427 bad_values[[1, 1]] = f64::NAN;
1428 assert!(solve_linearized_sensitivity(&grid, &bad_values, &ds, 2).is_err());
1429
1430 assert!(solve_linearized_sensitivity(&grid, &ds, &ds, 0).is_err());
1431
1432 let small_grid = Grid2D::new(2, 2, 1.0, 2.0, -1.0, 1.0);
1433 let ds_small = Array2::zeros((2, 2));
1434 assert!(solve_linearized_sensitivity(&small_grid, &ds_small, &ds_small, 1).is_err());
1435 }
1436
1437 #[test]
1438 fn test_to_array2_rejects_invalid_shapes_or_values() {
1439 let jac_ok = vec![vec![0.0; N_PARAMS]; 2];
1440 assert!(to_array2(&jac_ok, N_PARAMS, "jac").is_ok());
1441
1442 let jac_jagged = vec![vec![0.0; N_PARAMS], vec![0.0; N_PARAMS - 1]];
1443 assert!(to_array2(&jac_jagged, N_PARAMS, "jac").is_err());
1444
1445 let mut jac_bad = vec![vec![0.0; N_PARAMS]; 2];
1446 jac_bad[1][2] = f64::NAN;
1447 assert!(to_array2(&jac_bad, N_PARAMS, "jac").is_err());
1448
1449 let empty: Vec<Vec<f64>> = Vec::new();
1450 assert!(to_array2(&empty, N_PARAMS, "jac").is_err());
1451 }
1452
1453 #[test]
1454 fn test_rmse_helper_rejects_invalid_inputs() {
1455 assert!(compute_rmse(&[1.0, 2.0], &[1.0, 2.0], "rmse").is_ok());
1456 assert!(compute_rmse(&[], &[1.0], "rmse").is_err());
1457 assert!(compute_rmse(&[1.0], &[], "rmse").is_err());
1458 assert!(compute_rmse(&[1.0], &[1.0, 2.0], "rmse").is_err());
1459 assert!(compute_rmse(&[1.0, f64::NAN], &[1.0, 2.0], "rmse").is_err());
1460 assert!(compute_rmse(&[1.0, 2.0], &[1.0, f64::INFINITY], "rmse").is_err());
1461 }
1462
1463 #[test]
1464 fn test_lm_delta_helper_rejects_invalid_inputs() {
1465 let j_ok = Array2::zeros((2, N_PARAMS));
1466 assert!(compute_lm_delta(&j_ok, &[0.1, -0.2], 1e-4, "delta").is_ok());
1467
1468 assert!(compute_lm_delta(&j_ok, &[0.1], 1e-4, "delta").is_err());
1469 assert!(compute_lm_delta(&j_ok, &[0.1, -0.2], 0.0, "delta").is_err());
1470
1471 let mut j_bad = Array2::zeros((2, N_PARAMS));
1472 j_bad[[0, 3]] = f64::NAN;
1473 assert!(compute_lm_delta(&j_bad, &[0.1, -0.2], 1e-4, "delta").is_err());
1474
1475 let j_bad_shape = Array2::zeros((2, N_PARAMS - 1));
1476 assert!(compute_lm_delta(&j_bad_shape, &[0.1, -0.2], 1e-4, "delta").is_err());
1477 }
1478
1479 #[test]
1480 fn test_kernel_analytical_jacobian_computes() {
1481 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1482 .unwrap();
1483 let probes = vec![(6.2, 0.0), (6.35, 0.1), (6.45, -0.15)];
1484 let params_p = ProfileParams {
1485 ped_top: 0.9,
1486 ped_width: 0.08,
1487 ped_height: 1.1,
1488 core_alpha: 0.25,
1489 };
1490 let params_ff = ProfileParams {
1491 ped_top: 0.86,
1492 ped_width: 0.07,
1493 ped_height: 0.95,
1494 core_alpha: 0.12,
1495 };
1496 let kcfg = KernelInverseConfig {
1497 inverse: InverseConfig {
1498 jacobian_mode: JacobianMode::Analytical,
1499 fd_step: 1e-5,
1500 ..Default::default()
1501 },
1502 kernel_max_iterations: 50,
1503 require_kernel_converged: false,
1504 };
1505
1506 let (pred, jac) =
1507 kernel_analytical_forward_and_jacobian(&cfg, &probes, params_p, params_ff, &kcfg)
1508 .unwrap();
1509 assert_eq!(pred.len(), probes.len());
1510 assert_eq!(jac.len(), probes.len());
1511 assert!(jac.iter().all(|row| row.len() == N_PARAMS));
1512 assert!(jac.iter().flatten().all(|v| v.is_finite()));
1513 }
1514
1515 #[test]
1516 fn test_kernel_analytical_jacobian_tracks_fd() {
1517 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1518 .unwrap();
1519 let probes = vec![(6.2, 0.0), (6.3, 0.08), (6.4, -0.12), (6.5, 0.0)];
1520 let params_p = ProfileParams {
1521 ped_top: 0.9,
1522 ped_width: 0.08,
1523 ped_height: 1.1,
1524 core_alpha: 0.25,
1525 };
1526 let params_ff = ProfileParams {
1527 ped_top: 0.86,
1528 ped_width: 0.07,
1529 ped_height: 0.95,
1530 core_alpha: 0.12,
1531 };
1532 let kcfg = KernelInverseConfig {
1533 inverse: InverseConfig {
1534 jacobian_mode: JacobianMode::Analytical,
1535 fd_step: 1e-5,
1536 ..Default::default()
1537 },
1538 kernel_max_iterations: 50,
1539 require_kernel_converged: false,
1540 };
1541
1542 let (pred, jac_analytical) =
1543 kernel_analytical_forward_and_jacobian(&cfg, &probes, params_p, params_ff, &kcfg)
1544 .unwrap();
1545 let jac_fd =
1546 kernel_fd_jacobian_from_base(&cfg, &probes, params_p, params_ff, &kcfg, &pred).unwrap();
1547
1548 assert_eq!(jac_analytical.len(), jac_fd.len());
1549 assert!(jac_analytical.iter().all(|row| row.len() == N_PARAMS));
1550 assert!(jac_fd.iter().all(|row| row.len() == N_PARAMS));
1551
1552 let mut num = 0.0_f64;
1553 let mut den = 0.0_f64;
1554 let mut same_sign = 0usize;
1555 let mut comparable = 0usize;
1556
1557 for (row_a, row_f) in jac_analytical.iter().zip(jac_fd.iter()) {
1558 for (&a, &f) in row_a.iter().zip(row_f.iter()) {
1559 assert!(a.is_finite() && f.is_finite());
1560 let d = a - f;
1561 num += d * d;
1562 den += f * f;
1563
1564 if a.abs() > 1e-6 || f.abs() > 1e-6 {
1565 comparable += 1;
1566 if a.signum() == f.signum() {
1567 same_sign += 1;
1568 }
1569 }
1570 }
1571 }
1572
1573 let nrmse = (num / den.max(1e-14)).sqrt();
1574 let sign_match = same_sign as f64 / comparable.max(1) as f64;
1575
1576 assert!(
1579 nrmse < 1.5,
1580 "Kernel analytical Jacobian deviates too much from FD (NRMSE={nrmse})"
1581 );
1582 assert!(
1583 sign_match >= 0.65,
1584 "Kernel analytical Jacobian sign agreement too low ({sign_match})"
1585 );
1586 }
1587
1588 #[test]
1589 fn test_kernel_inverse_analytical_mode_reduces_residual() {
1590 let cfg = ReactorConfig::from_file(&config_path("validation/iter_validated_config.json"))
1591 .unwrap();
1592 let probes = vec![(6.2, 0.0), (6.3, 0.1), (6.4, -0.1), (6.55, 0.0)];
1593
1594 let true_p = ProfileParams {
1595 ped_top: 0.91,
1596 ped_width: 0.08,
1597 ped_height: 1.15,
1598 core_alpha: 0.28,
1599 };
1600 let true_ff = ProfileParams {
1601 ped_top: 0.85,
1602 ped_width: 0.06,
1603 ped_height: 0.92,
1604 core_alpha: 0.14,
1605 };
1606 let init_p = ProfileParams {
1607 ped_top: 0.78,
1608 ped_width: 0.12,
1609 ped_height: 0.7,
1610 core_alpha: 0.03,
1611 };
1612 let init_ff = ProfileParams {
1613 ped_top: 0.74,
1614 ped_width: 0.11,
1615 ped_height: 0.65,
1616 core_alpha: 0.02,
1617 };
1618
1619 let base_cfg = KernelInverseConfig {
1620 inverse: InverseConfig {
1621 max_iterations: 4,
1622 damping: 0.6,
1623 tolerance: 1e-8,
1624 tikhonov: 1e-4,
1625 ..Default::default()
1626 },
1627 kernel_max_iterations: 50,
1628 require_kernel_converged: false,
1629 };
1630
1631 let measurements =
1632 kernel_forward_observables(&cfg, &probes, true_p, true_ff, &base_cfg).unwrap();
1633 let init_prediction =
1634 kernel_forward_observables(&cfg, &probes, init_p, init_ff, &base_cfg).unwrap();
1635 let init_residual = (init_prediction
1636 .iter()
1637 .zip(measurements.iter())
1638 .map(|(p, m)| (p - m).powi(2))
1639 .sum::<f64>()
1640 / measurements.len() as f64)
1641 .sqrt();
1642
1643 let mut analytical_cfg = base_cfg.clone();
1644 analytical_cfg.inverse.jacobian_mode = JacobianMode::Analytical;
1645 let result = reconstruct_equilibrium_with_kernel(
1646 &cfg,
1647 &probes,
1648 &measurements,
1649 init_p,
1650 init_ff,
1651 &analytical_cfg,
1652 )
1653 .unwrap();
1654
1655 assert!(result.residual.is_finite());
1656 assert!(
1657 result.residual < init_residual,
1658 "Expected analytical kernel mode to reduce residual: init={}, final={}",
1659 init_residual,
1660 result.residual
1661 );
1662 }
1663}