fusion_core/
inverse.rs

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