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