1use nereids_endf::resonance::ResonanceData;
17use nereids_physics::transmission::{self, InstrumentParams, SampleParams};
18
19use crate::error::PipelineError;
20
21const NEUTRON_MASS_CONSTANT: f64 = 0.5 * 1.6749e-27 / 1.602e-19;
25
26#[derive(Debug, Clone)]
28pub struct CalibrationResult {
29 pub flight_path_m: f64,
31 pub t0_us: f64,
33 pub total_density: f64,
35 pub reduced_chi_squared: f64,
37 pub energies_corrected: Vec<f64>,
39}
40
41#[allow(clippy::too_many_arguments)]
65pub fn calibrate_energy(
66 energies_nominal: &[f64],
67 transmission: &[f64],
68 uncertainty: &[f64],
69 isotopes: &[ResonanceData],
70 abundances: &[f64],
71 assumed_flight_path_m: f64,
72 temperature_k: f64,
73 resolution: Option<&InstrumentParams>,
74) -> Result<CalibrationResult, PipelineError> {
75 let n = energies_nominal.len();
76 if n == 0 {
77 return Err(PipelineError::InvalidParameter(
78 "energies_nominal must not be empty".into(),
79 ));
80 }
81 if transmission.len() != n || uncertainty.len() != n {
82 return Err(PipelineError::InvalidParameter(format!(
83 "transmission ({}) and uncertainty ({}) must match energies ({})",
84 transmission.len(),
85 uncertainty.len(),
86 n,
87 )));
88 }
89 if isotopes.len() != abundances.len() {
90 return Err(PipelineError::InvalidParameter(format!(
91 "isotopes ({}) must match abundances ({})",
92 isotopes.len(),
93 abundances.len(),
94 )));
95 }
96
97 let tof_s: Vec<f64> = energies_nominal
99 .iter()
100 .map(|&e| assumed_flight_path_m * (NEUTRON_MASS_CONSTANT / e).sqrt())
101 .collect();
102
103 let valid: Vec<bool> = transmission
105 .iter()
106 .zip(uncertainty.iter())
107 .map(|(&t, &s)| t.is_finite() && s.is_finite() && s > 0.0)
108 .collect();
109
110 let l_center = assumed_flight_path_m;
116 let mut best_chi2 = f64::INFINITY;
117 let mut best_l = l_center;
118 let mut best_t0_us = 0.0f64;
119 let mut best_n = 1e-4;
120
121 let l_steps: Vec<f64> = (-15..=15)
123 .map(|i| l_center * (1.0 + i as f64 * 0.001))
124 .collect();
125 let t0_steps: Vec<f64> = (-5..=10).map(|i| i as f64).collect();
127
128 for &l in &l_steps {
129 for &t0 in &t0_steps {
130 let t0_s = t0 * 1e-6;
131 let e_corr: Vec<f64> = tof_s
133 .iter()
134 .map(|&t| {
135 let t_corr = t - t0_s;
136 if t_corr <= 0.0 {
137 f64::NAN
138 } else {
139 NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
140 }
141 })
142 .collect();
143
144 if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
146 continue;
147 }
148
149 for &n_total in &[5e-5, 1e-4, 1.5e-4, 2e-4, 3e-4] {
151 let chi2 = compute_chi2(
152 &e_corr,
153 transmission,
154 uncertainty,
155 isotopes,
156 abundances,
157 n_total,
158 temperature_k,
159 &valid,
160 resolution,
161 );
162 if chi2 < best_chi2 {
163 best_chi2 = chi2;
164 best_l = l;
165 best_t0_us = t0;
166 best_n = n_total;
167 }
168 }
169 }
170 }
171
172 let l_fine: Vec<f64> = (-5..=5)
178 .map(|i| best_l * (1.0 + i as f64 * 0.0001))
179 .collect();
180 let t0_fine: Vec<f64> = (-8..=8).map(|i| best_t0_us + i as f64 * 0.25).collect();
181 let n_fine: Vec<f64> = (-10..=10)
182 .map(|i| best_n * (1.0 + i as f64 * 0.05))
183 .filter(|&n| n > 0.0)
184 .collect();
185
186 for &l in &l_fine {
187 for &t0 in &t0_fine {
188 let t0_s = t0 * 1e-6;
189 let e_corr: Vec<f64> = tof_s
190 .iter()
191 .map(|&t| {
192 let t_corr = t - t0_s;
193 if t_corr <= 0.0 {
194 f64::NAN
195 } else {
196 NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
197 }
198 })
199 .collect();
200 if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
201 continue;
202 }
203 for &n_total in &n_fine {
204 let chi2 = compute_chi2(
205 &e_corr,
206 transmission,
207 uncertainty,
208 isotopes,
209 abundances,
210 n_total,
211 temperature_k,
212 &valid,
213 resolution,
214 );
215 if chi2 < best_chi2 {
216 best_chi2 = chi2;
217 best_l = l;
218 best_t0_us = t0;
219 best_n = n_total;
220 }
221 }
222 }
223 }
224
225 let l_ultra: Vec<f64> = (-5..=5)
231 .map(|i| best_l * (1.0 + i as f64 * 0.00001))
232 .collect();
233 let t0_ultra: Vec<f64> = (-10..=10).map(|i| best_t0_us + i as f64 * 0.05).collect();
234 let n_ultra: Vec<f64> = (-10..=10)
235 .map(|i| best_n * (1.0 + i as f64 * 0.01))
236 .filter(|&n| n > 0.0)
237 .collect();
238
239 for &l in &l_ultra {
240 for &t0 in &t0_ultra {
241 let t0_s = t0 * 1e-6;
242 let e_corr: Vec<f64> = tof_s
243 .iter()
244 .map(|&t| {
245 let t_corr = t - t0_s;
246 if t_corr <= 0.0 {
247 f64::NAN
248 } else {
249 NEUTRON_MASS_CONSTANT * (l / t_corr).powi(2)
250 }
251 })
252 .collect();
253 if e_corr.iter().any(|e| !e.is_finite() || *e <= 0.0) {
254 continue;
255 }
256 for &n_total in &n_ultra {
257 let chi2 = compute_chi2(
258 &e_corr,
259 transmission,
260 uncertainty,
261 isotopes,
262 abundances,
263 n_total,
264 temperature_k,
265 &valid,
266 resolution,
267 );
268 if chi2 < best_chi2 {
269 best_chi2 = chi2;
270 best_l = l;
271 best_t0_us = t0;
272 best_n = n_total;
273 }
274 }
275 }
276 }
277
278 let t0_best_s = best_t0_us * 1e-6;
280 let energies_corrected: Vec<f64> = tof_s
281 .iter()
282 .map(|&t| NEUTRON_MASS_CONSTANT * (best_l / (t - t0_best_s)).powi(2))
283 .collect();
284
285 let n_valid = valid.iter().filter(|&&v| v).count();
287 let dof = if n_valid > 3 { n_valid - 3 } else { 1 }; let chi2r = best_chi2 / dof as f64;
289
290 Ok(CalibrationResult {
291 flight_path_m: best_l,
292 t0_us: best_t0_us,
293 total_density: best_n,
294 reduced_chi_squared: chi2r,
295 energies_corrected,
296 })
297}
298
299#[allow(clippy::too_many_arguments)]
301fn compute_chi2(
302 energies: &[f64],
303 transmission: &[f64],
304 uncertainty: &[f64],
305 isotopes: &[ResonanceData],
306 abundances: &[f64],
307 n_total: f64,
308 temperature_k: f64,
309 valid: &[bool],
310 resolution: Option<&InstrumentParams>,
311) -> f64 {
312 let pairs: Vec<(ResonanceData, f64)> = isotopes
314 .iter()
315 .zip(abundances.iter())
316 .map(|(iso, &abd)| (iso.clone(), abd * n_total))
317 .collect();
318
319 let sample = match SampleParams::new(temperature_k, pairs) {
320 Ok(s) => s,
321 Err(_) => return f64::INFINITY,
322 };
323
324 let model = match transmission::forward_model(energies, &sample, resolution) {
327 Ok(m) => m,
328 Err(_) => return f64::INFINITY,
329 };
330
331 let mut chi2 = 0.0;
333 for (i, (&t_data, &t_model)) in transmission.iter().zip(model.iter()).enumerate() {
334 if !valid[i] {
335 continue;
336 }
337 let residual = (t_data - t_model) / uncertainty[i];
338 chi2 += residual * residual;
339 }
340 chi2
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 #[ignore = "requires network: downloads Hf-178 ENDF from IAEA"]
349 fn test_calibrate_round_trip() {
350 let true_l = 25.08;
352 let true_t0_us = 1.0;
353 let true_n = 1.5e-4;
354 let temperature_k = 293.6;
355
356 let iso = {
358 use nereids_core::types::Isotope;
359 use nereids_endf::parser::parse_endf_file2;
360 use nereids_endf::retrieval::{EndfLibrary, EndfRetriever, mat_number};
361
362 let isotope = Isotope::new(72, 178).unwrap();
363 let mat = mat_number(&isotope).expect("No MAT number for Hf-178");
364 let retriever = EndfRetriever::new();
365 let (_path, contents) = retriever
366 .get_endf_file(&isotope, EndfLibrary::EndfB8_0, mat)
367 .expect("Failed to retrieve Hf-178");
368 parse_endf_file2(&contents).expect("Failed to parse Hf-178")
369 };
370
371 let assumed_l = 25.0;
373 let e_nominal: Vec<f64> = (0..500)
374 .map(|i| 5.0 + i as f64 * 0.4) .collect();
376
377 let tof_s: Vec<f64> = e_nominal
379 .iter()
380 .map(|&e| assumed_l * (NEUTRON_MASS_CONSTANT / e).sqrt())
381 .collect();
382
383 let true_t0_s = true_t0_us * 1e-6;
385 let e_true: Vec<f64> = tof_s
386 .iter()
387 .map(|&t| NEUTRON_MASS_CONSTANT * (true_l / (t - true_t0_s)).powi(2))
388 .collect();
389
390 let sample = SampleParams::new(temperature_k, vec![(iso.clone(), true_n)])
392 .expect("SampleParams creation failed");
393 let t_model =
394 transmission::forward_model(&e_true, &sample, None).expect("forward_model failed");
395
396 let sigma = vec![0.01; e_nominal.len()];
398
399 let result = calibrate_energy(
401 &e_nominal,
402 &t_model,
403 &sigma,
404 &[iso],
405 &[1.0], assumed_l,
407 temperature_k,
408 None,
409 )
410 .expect("Calibration failed");
411
412 assert!(
414 (result.flight_path_m - true_l).abs() < 0.05,
415 "L: got {}, expected {}",
416 result.flight_path_m,
417 true_l,
418 );
419 assert!(
420 (result.t0_us - true_t0_us).abs() < 1.0,
421 "t0: got {}, expected {}",
422 result.t0_us,
423 true_t0_us,
424 );
425 assert!(
426 (result.total_density - true_n).abs() / true_n < 0.2,
427 "n: got {}, expected {}",
428 result.total_density,
429 true_n,
430 );
431 assert!(
432 result.reduced_chi_squared < 1.0,
433 "chi2r should be < 1 for synthetic data, got {}",
434 result.reduced_chi_squared,
435 );
436 }
437}