fusion_core/
inverse.rs

1// ─────────────────────────────────────────────────────────────────────
2// SCPN Fusion Core — Inverse Profile Reconstruction
3// © 1998–2026 Miroslav Šotek. All rights reserved.
4// Contact: www.anulum.li | protoscience@anulum.li
5// ORCID: https://orcid.org/0009-0009-3560-0851
6// License: GNU AGPL v3 | Commercial licensing available
7// ─────────────────────────────────────────────────────────────────────
8//! Inverse reconstruction module with selectable Jacobian mode.
9
10use 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(&params_p, "params_p")?;
500    validate_profile_params(&params_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(&params_p, "params_p")?;
621    validate_profile_params(&params_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, &params_p);
679            let ff = mtanh_profile(psi_n, &params_ff);
680            let dp_dpsi = mtanh_profile_dpsi_norm(psi_n, &params_p, "params_p")?;
681            let dff_dpsi = mtanh_profile_dpsi_norm(psi_n, &params_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    // Approximate local dS/dPsi for linearized kernel solve.
704    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, &params_p)[col];
732                    d_raw[[iz, ir]] = SOURCE_BETA_MIX * r * dp;
733                } else {
734                    let dff = mtanh_profile_derivatives(psi_n, &params_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(&params_p, &params_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
796/// Reconstruct profile parameters from probe-space measurements.
797pub 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, &params_p, &params_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(&params_p, &params_ff, probe_psi_norm)
848            }
849            JacobianMode::FiniteDifference => {
850                compute_fd_jacobian(&params_p, &params_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(&params_p, "params_p")?;
890    validate_profile_params(&params_ff, "params_ff")?;
891    let prediction = forward_model_response(probe_psi_norm, &params_p, &params_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
904/// Reconstruct profile parameters against actual `FusionKernel` observables.
905///
906/// This API evaluates residuals at physical probe coordinates `(R, Z)` by running
907/// the Grad-Shafranov kernel with candidate profile parameters.
908pub 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(&params_p, "params_p")?;
1030    validate_profile_params(&params_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        // The analytical kernel Jacobian uses a linearized local sensitivity model.
1578        // It should track FD directionality and scale well enough for LM updates.
1579        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}