1use crate::source::{
15 mtanh_profile, mtanh_profile_derivatives as source_mtanh_profile_derivatives, ProfileParams,
16};
17use fusion_types::error::{FusionError, FusionResult};
18
19fn validate_profile_params(params: &ProfileParams, label: &str) -> FusionResult<()> {
20 if !params.ped_top.is_finite() || params.ped_top <= 0.0 {
21 return Err(FusionError::ConfigError(format!(
22 "{label}.ped_top must be finite and > 0, got {}",
23 params.ped_top
24 )));
25 }
26 if !params.ped_width.is_finite() || params.ped_width <= 0.0 {
27 return Err(FusionError::ConfigError(format!(
28 "{label}.ped_width must be finite and > 0, got {}",
29 params.ped_width
30 )));
31 }
32 if !params.ped_height.is_finite() || !params.core_alpha.is_finite() {
33 return Err(FusionError::ConfigError(format!(
34 "{label}.ped_height/core_alpha must be finite"
35 )));
36 }
37 Ok(())
38}
39
40fn validate_probe_psi_norm(probe_psi_norm: &[f64]) -> FusionResult<()> {
41 if probe_psi_norm.is_empty() {
42 return Err(FusionError::ConfigError(
43 "probe_psi_norm must be non-empty".to_string(),
44 ));
45 }
46 if probe_psi_norm
47 .iter()
48 .any(|psi| !psi.is_finite() || *psi < 0.0 || *psi > 1.0)
49 {
50 return Err(FusionError::ConfigError(
51 "probe_psi_norm values must be finite and within [0, 1]".to_string(),
52 ));
53 }
54 Ok(())
55}
56
57pub fn mtanh_profile_derivatives(psi_norm: f64, params: &ProfileParams) -> [f64; 4] {
59 source_mtanh_profile_derivatives(psi_norm, params)
60}
61
62pub fn forward_model_response(
64 probe_psi_norm: &[f64],
65 params_p: &ProfileParams,
66 params_ff: &ProfileParams,
67) -> FusionResult<Vec<f64>> {
68 validate_probe_psi_norm(probe_psi_norm)?;
69 validate_profile_params(params_p, "params_p")?;
70 validate_profile_params(params_ff, "params_ff")?;
71
72 let out: Vec<f64> = probe_psi_norm
73 .iter()
74 .map(|&psi| mtanh_profile(psi, params_p) + mtanh_profile(psi, params_ff))
75 .collect();
76 if out.iter().any(|v| !v.is_finite()) {
77 return Err(FusionError::ConfigError(
78 "forward model response contains non-finite values".to_string(),
79 ));
80 }
81 Ok(out)
82}
83
84pub fn compute_analytical_jacobian(
88 params_p: &ProfileParams,
89 params_ff: &ProfileParams,
90 probe_psi_norm: &[f64],
91) -> FusionResult<Vec<Vec<f64>>> {
92 validate_probe_psi_norm(probe_psi_norm)?;
93 validate_profile_params(params_p, "params_p")?;
94 validate_profile_params(params_ff, "params_ff")?;
95
96 let mut jac = Vec::with_capacity(probe_psi_norm.len());
97 for &psi in probe_psi_norm {
98 let dp = mtanh_profile_derivatives(psi, params_p);
99 let df = mtanh_profile_derivatives(psi, params_ff);
100 if dp.iter().any(|v| !v.is_finite()) || df.iter().any(|v| !v.is_finite()) {
101 return Err(FusionError::ConfigError(
102 "analytical jacobian derivative evaluation became non-finite".to_string(),
103 ));
104 }
105 jac.push(vec![dp[0], dp[1], dp[2], dp[3], df[0], df[1], df[2], df[3]]);
106 }
107 if jac.iter().flatten().any(|v| !v.is_finite()) {
108 return Err(FusionError::ConfigError(
109 "analytical jacobian contains non-finite values".to_string(),
110 ));
111 }
112 Ok(jac)
113}
114
115fn perturb_param(
116 params_p: &ProfileParams,
117 params_ff: &ProfileParams,
118 idx: usize,
119 delta: f64,
120) -> (ProfileParams, ProfileParams) {
121 let mut p = *params_p;
122 let mut ff = *params_ff;
123 match idx {
124 0 => p.ped_height += delta,
125 1 => p.ped_top += delta,
126 2 => p.ped_width += delta,
127 3 => p.core_alpha += delta,
128 4 => ff.ped_height += delta,
129 5 => ff.ped_top += delta,
130 6 => ff.ped_width += delta,
131 7 => ff.core_alpha += delta,
132 _ => {}
133 }
134 (p, ff)
135}
136
137pub fn compute_fd_jacobian(
139 params_p: &ProfileParams,
140 params_ff: &ProfileParams,
141 probe_psi_norm: &[f64],
142 fd_step: f64,
143) -> FusionResult<Vec<Vec<f64>>> {
144 if !fd_step.is_finite() || fd_step <= 0.0 {
145 return Err(FusionError::ConfigError(
146 "jacobian fd_step must be finite and > 0".to_string(),
147 ));
148 }
149 validate_probe_psi_norm(probe_psi_norm)?;
150 validate_profile_params(params_p, "params_p")?;
151 validate_profile_params(params_ff, "params_ff")?;
152
153 let base = forward_model_response(probe_psi_norm, params_p, params_ff)?;
154 let h = fd_step;
155 const PARAM_INDICES: [usize; 8] = [0, 1, 2, 3, 4, 5, 6, 7];
156
157 let mut jac = vec![vec![0.0; 8]; probe_psi_norm.len()];
158 for col in PARAM_INDICES {
159 let (p_pert, ff_pert) = perturb_param(params_p, params_ff, col, h);
160 let f_pert = forward_model_response(probe_psi_norm, &p_pert, &ff_pert)?;
161 for (row, (&fp, &b)) in jac.iter_mut().zip(f_pert.iter().zip(base.iter())) {
162 row[col] = (fp - b) / h;
163 }
164 }
165 if jac.iter().flatten().any(|v| !v.is_finite()) {
166 return Err(FusionError::ConfigError(
167 "finite-difference jacobian contains non-finite values".to_string(),
168 ));
169 }
170 Ok(jac)
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_analytical_vs_fd_jacobian() {
179 let p = ProfileParams {
180 ped_top: 0.9,
181 ped_width: 0.08,
182 ped_height: 1.1,
183 core_alpha: 0.25,
184 };
185 let ff = ProfileParams {
186 ped_top: 0.85,
187 ped_width: 0.06,
188 ped_height: 0.95,
189 core_alpha: 0.15,
190 };
191 let probes: Vec<f64> = (0..32).map(|i| i as f64 / 31.0).collect();
192
193 let ja = compute_analytical_jacobian(&p, &ff, &probes).expect("valid analytical jacobian");
194 let jf = compute_fd_jacobian(&p, &ff, &probes, 1e-6).expect("valid fd_step");
195 for i in 0..probes.len() {
196 for j in 0..8 {
197 let abs = (ja[i][j] - jf[i][j]).abs();
198 let denom = jf[i][j].abs().max(1e-8);
199 let rel = abs / denom;
200 assert!(
201 abs < 1e-8 || rel < 5e-2,
202 "Mismatch at row {i}, col {j}: analytical={}, fd={}, abs={}, rel={}",
203 ja[i][j],
204 jf[i][j],
205 abs,
206 rel
207 );
208 }
209 }
210 }
211
212 #[test]
213 fn test_analytical_jacobian_symmetry() {
214 let params = ProfileParams {
215 ped_top: 0.92,
216 ped_width: 0.07,
217 ped_height: 1.0,
218 core_alpha: 0.2,
219 };
220 let probes: Vec<f64> = (0..16).map(|i| i as f64 / 15.0).collect();
221 let jac = compute_analytical_jacobian(¶ms, ¶ms, &probes)
222 .expect("valid analytical jacobian");
223
224 for row in &jac {
226 for k in 0..4 {
227 assert!(
228 (row[k] - row[k + 4]).abs() < 1e-12,
229 "Expected symmetry in Jacobian columns {k} and {}",
230 k + 4
231 );
232 }
233 }
234 }
235
236 #[test]
237 fn test_fd_jacobian_rejects_invalid_fd_step() {
238 let p = ProfileParams {
239 ped_top: 0.9,
240 ped_width: 0.08,
241 ped_height: 1.1,
242 core_alpha: 0.25,
243 };
244 let ff = p;
245 let probes: Vec<f64> = (0..8).map(|i| i as f64 / 7.0).collect();
246
247 assert!(compute_fd_jacobian(&p, &ff, &probes, 0.0).is_err());
248 assert!(compute_fd_jacobian(&p, &ff, &probes, -1e-6).is_err());
249 assert!(compute_fd_jacobian(&p, &ff, &probes, f64::NAN).is_err());
250 }
251
252 #[test]
253 fn test_forward_model_rejects_invalid_runtime_inputs() {
254 let p = ProfileParams {
255 ped_top: 0.9,
256 ped_width: 0.08,
257 ped_height: 1.1,
258 core_alpha: 0.25,
259 };
260 let ff = p;
261 assert!(forward_model_response(&[], &p, &ff).is_err());
262 assert!(forward_model_response(&[0.1, f64::INFINITY], &p, &ff).is_err());
263 let bad = ProfileParams {
264 ped_top: 0.9,
265 ped_width: 0.0,
266 ped_height: 1.0,
267 core_alpha: 0.2,
268 };
269 assert!(forward_model_response(&[0.2, 0.4], &bad, &ff).is_err());
270 assert!(compute_analytical_jacobian(&p, &ff, &[0.2, -0.1]).is_err());
271 }
272}