1use nucleide_mcnp_io::meshtal::{MeshTallyData, ParticleKind};
26
27use crate::Error;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MagicSelection {
35 Total,
38 PerGroup,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct MagicParams {
47 pub tolerance: f64,
50 pub null_value: f64,
52}
53
54impl Default for MagicParams {
55 fn default() -> Self {
56 Self {
57 tolerance: 0.5,
58 null_value: 0.0,
59 }
60 }
61}
62
63#[derive(Debug, Clone, PartialEq)]
66pub struct MagicOutput {
67 pub lower_bounds_ww: Vec<f64>,
71 pub groups_per_ve: usize,
73 pub scale_factors: Vec<f64>,
76 pub e_upper_bounds: Vec<f64>,
79 pub ww_tag_name: String,
82 pub e_upper_bounds_tag_name: String,
85}
86
87pub fn magic(tally: &MeshTallyData) -> Result<MagicOutput, Error> {
90 magic_with(tally, MagicSelection::Total, MagicParams::default())
91}
92
93pub fn magic_with(
95 tally: &MeshTallyData,
96 selection: MagicSelection,
97 params: MagicParams,
98) -> Result<MagicOutput, Error> {
99 if tally.num_ves() == 0 || tally.e_bounds.len() < 2 {
100 return Err(Error::EmptyTally);
101 }
102 let groups_per_ve = match selection {
103 MagicSelection::Total => 1,
104 MagicSelection::PerGroup => tally.num_e_groups(),
105 };
106
107 let (vals, errs): (Vec<f64>, Vec<f64>) = match selection {
110 MagicSelection::Total => (tally.total_result.clone(), tally.total_rel_error.clone()),
111 MagicSelection::PerGroup => {
112 let mut v = Vec::with_capacity(tally.result.len() * groups_per_ve);
113 let mut e = Vec::with_capacity(tally.rel_error.len() * groups_per_ve);
114 for (rv, re) in tally.result.iter().zip(tally.rel_error.iter()) {
115 v.extend_from_slice(rv);
116 e.extend_from_slice(re);
117 }
118 (v, e)
119 }
120 };
121 let expected = tally.num_ves() * groups_per_ve;
122 if vals.len() != expected || errs.len() != expected {
123 return Err(Error::LengthMismatch {
124 expected,
125 got: vals.len().max(errs.len()),
126 });
127 }
128 for (i, &v) in vals.iter().enumerate() {
129 if !v.is_finite() {
130 return Err(Error::NonFiniteTally {
131 field: "flux",
132 index: i,
133 });
134 }
135 }
136 for (i, &e) in errs.iter().enumerate() {
137 if !e.is_finite() {
138 return Err(Error::NonFiniteTally {
139 field: "error",
140 index: i,
141 });
142 }
143 }
144
145 let mut max_val = vec![f64::NEG_INFINITY; groups_per_ve];
147 for (idx, &v) in vals.iter().enumerate() {
148 let g = idx % groups_per_ve;
149 if v > max_val[g] {
150 max_val[g] = v;
151 }
152 }
153 for (g, &m) in max_val.iter().enumerate() {
154 if m <= 0.0 {
155 return Err(Error::ZeroMaxFlux { energy_group: g });
156 }
157 }
158
159 let scale_factors: Vec<f64> = max_val.iter().map(|m| 1.0 / (2.0 * m)).collect();
161 let mut lower_bounds_ww = Vec::with_capacity(vals.len());
162 for (idx, (&v, &e)) in vals.iter().zip(errs.iter()).enumerate() {
163 let g = idx % groups_per_ve;
164 if e > params.tolerance {
165 lower_bounds_ww.push(params.null_value);
166 } else {
167 lower_bounds_ww.push(v * scale_factors[g]);
168 }
169 }
170
171 let letter = match tally.particle {
172 ParticleKind::Neutron => 'n',
173 ParticleKind::Photon => 'p',
174 };
175 let e_upper_bounds = match selection {
176 MagicSelection::Total => vec![tally.e_bounds.last().copied().unwrap_or(0.0)],
177 MagicSelection::PerGroup => tally.e_bounds[1..].to_vec(),
178 };
179
180 Ok(MagicOutput {
181 lower_bounds_ww,
182 groups_per_ve,
183 scale_factors,
184 e_upper_bounds,
185 ww_tag_name: format!("ww_{letter}"),
186 e_upper_bounds_tag_name: format!("{letter}_e_upper_bounds"),
187 })
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn sample_tally(
197 result: Vec<Vec<f64>>,
198 rel_error: Vec<Vec<f64>>,
199 total_result: Vec<f64>,
200 total_rel_error: Vec<f64>,
201 ) -> MeshTallyData {
202 MeshTallyData {
203 tally_number: 4,
204 particle: ParticleKind::Neutron,
205 dose_response: false,
206 x_bounds: vec![0.0, 1.0, 2.0],
207 y_bounds: vec![-1.0, 3.0, 4.0],
208 z_bounds: vec![10.0, 12.0],
209 e_bounds: vec![0.0, 0.5, 1.0],
210 column_idx: Default::default(),
211 result,
212 rel_error,
213 total_result,
214 total_rel_error,
215 }
216 }
217
218 fn approx(a: f64, b: f64) {
219 assert!(
220 (a - b).abs() <= 1e-12 * b.abs().max(1e-30),
221 "expected {b}, got {a}"
222 );
223 }
224
225 fn approx_vec(got: &[f64], want: &[f64]) {
226 assert_eq!(got.len(), want.len());
227 for (a, b) in got.iter().zip(want.iter()) {
228 approx(*a, *b);
229 }
230 }
231
232 #[test]
233 fn oracle_total_below_default_tolerance() {
234 let t = sample_tally(
236 vec![vec![0.0]; 4],
237 vec![vec![0.0]; 4],
238 vec![1.2, 3.3, 1.6, 1.7],
239 vec![0.11, 0.013, 0.14, 0.19],
240 );
241 let out = magic(&t).unwrap();
242 assert_eq!(out.groups_per_ve, 1);
243 assert_eq!(out.ww_tag_name, "ww_n");
244 assert_eq!(out.e_upper_bounds_tag_name, "n_e_upper_bounds");
245 assert_eq!(out.scale_factors, vec![1.0 / (2.0 * 3.3)]);
246 approx_vec(
247 &out.lower_bounds_ww,
248 &[1.2 / 6.6, 0.5, 1.6 / 6.6, 1.7 / 6.6],
249 );
250 }
251
252 #[test]
253 fn oracle_total_nulling_custom_params() {
254 let t = sample_tally(
256 vec![vec![0.0]; 4],
257 vec![vec![0.0]; 4],
258 vec![1.2, 3.3, 1.6, 1.7],
259 vec![0.11, 0.013, 0.14, 0.19],
260 );
261 let out = magic_with(
262 &t,
263 MagicSelection::Total,
264 MagicParams {
265 tolerance: 0.15,
266 null_value: 0.001,
267 },
268 )
269 .unwrap();
270 approx_vec(&out.lower_bounds_ww, &[1.2 / 6.6, 0.5, 1.6 / 6.6, 0.001]);
271 }
272
273 #[test]
274 fn oracle_multi_bin_strict_tolerance_compare() {
275 let t = sample_tally(
279 vec![
280 vec![1.2, 3.3],
281 vec![1.6, 1.7],
282 vec![1.5, 1.4],
283 vec![2.6, 1.0],
284 ],
285 vec![
286 vec![0.11, 0.013],
287 vec![0.14, 0.19],
288 vec![0.02, 0.16],
289 vec![0.04, 0.09],
290 ],
291 vec![0.0; 4],
292 vec![0.0; 4],
293 );
294 let out = magic_with(
295 &t,
296 MagicSelection::PerGroup,
297 MagicParams {
298 tolerance: 0.15,
299 null_value: 0.001,
300 },
301 )
302 .unwrap();
303 assert_eq!(out.groups_per_ve, 2);
304 assert_eq!(out.e_upper_bounds, vec![0.5, 1.0]);
305 let want = [
306 [1.2 / 5.2, 0.5],
307 [1.6 / 5.2, 0.001],
308 [1.5 / 5.2, 0.001],
309 [0.5, 1.0 / 6.6],
310 ];
311 for (ve, row) in want.iter().enumerate() {
312 approx_vec(&out.lower_bounds_ww[ve * 2..ve * 2 + 2], row);
313 }
314 }
315
316 #[test]
317 fn fixture_total_mode_matches_hand_computed_magic() {
318 let m = nucleide_mcnp_io::meshtal::Meshtal::from_file(fixture(
319 "mcnp_meshtal_single_meshtal.txt",
320 ))
321 .unwrap();
322 let t = &m.tallies[&4];
323 let out = magic(t).unwrap();
324 assert_eq!(out.groups_per_ve, 1);
325 assert_eq!(out.ww_tag_name, "ww_n");
326 assert_eq!(out.e_upper_bounds, vec![1.0]);
328 assert_eq!(out.scale_factors, vec![1.0 / (2.0 * 1.31488e-06)]);
329 let want_first = [
330 0.07277089924555853,
331 0.09303472560233633,
332 0.059732447067413,
333 0.1160508943781942,
334 0.1535896811876369,
335 ];
336 approx_vec(&out.lower_bounds_ww[..5], &want_first);
337 let n = t.num_ves();
338 let want_last = [
339 0.07414554940374787,
340 0.09211715137503042,
341 0.06157482051594062,
342 ];
343 approx_vec(&out.lower_bounds_ww[n - 3..], &want_last);
344 }
345
346 #[test]
347 fn fixture_group_mode_matches_hand_computed_magic() {
348 let m = nucleide_mcnp_io::meshtal::Meshtal::from_file(fixture(
349 "mcnp_meshtal_single_meshtal.txt",
350 ))
351 .unwrap();
352 let t = &m.tallies[&4];
353 let out = magic_with(t, MagicSelection::PerGroup, MagicParams::default()).unwrap();
354 assert_eq!(out.groups_per_ve, 3);
355 assert_eq!(out.e_upper_bounds, vec![0.1, 0.2, 1.0]);
356 assert_eq!(out.scale_factors.len(), 3);
359 approx(out.scale_factors[0], 1.0 / (2.0 * 4.45445e-08));
360 approx(out.scale_factors[1], 1.0 / (2.0 * 1.04704e-07));
361 approx(out.scale_factors[2], 1.0 / (2.0 * 1.16563e-06));
362 approx_vec(&out.lower_bounds_ww[22 * 3..22 * 3 + 3], &[0.5, 0.5, 0.5]);
364 approx_vec(
365 &out.lower_bounds_ww[..3],
366 &[
367 0.05572753089607023,
368 0.08699954156479218,
369 0.07214424817480675,
370 ],
371 );
372 let last = t.num_ves() - 1;
373 approx_vec(
374 &out.lower_bounds_ww[last * 3..],
375 &[
376 0.04513037524273479,
377 0.06796731738997555,
378 0.06162933349347562,
379 ],
380 );
381 }
382
383 #[test]
384 fn fixture_tight_tolerance_produces_null_values() {
385 let m = nucleide_mcnp_io::meshtal::Meshtal::from_file(fixture(
386 "mcnp_meshtal_single_meshtal.txt",
387 ))
388 .unwrap();
389 let t = &m.tallies[&4];
390 let out = magic_with(
391 t,
392 MagicSelection::PerGroup,
393 MagicParams {
394 tolerance: 0.06,
395 null_value: 1e-3,
396 },
397 )
398 .unwrap();
399 let nulled = out.lower_bounds_ww.iter().filter(|&&w| w == 1e-3).count();
401 assert_eq!(nulled, 87);
402 approx_vec(
403 &out.lower_bounds_ww[..3],
404 &[1e-3, 1e-3, 0.07214424817480675],
405 );
406 }
407
408 #[test]
409 fn zero_max_flux_group_is_rejected() {
410 let t = sample_tally(
411 vec![vec![0.0, 1.0]; 4],
412 vec![vec![0.0, 0.0]; 4],
413 vec![0.0; 4],
414 vec![0.0; 4],
415 );
416 assert_eq!(
417 magic_with(&t, MagicSelection::PerGroup, MagicParams::default()),
418 Err(Error::ZeroMaxFlux { energy_group: 0 })
419 );
420 assert_eq!(magic(&t), Err(Error::ZeroMaxFlux { energy_group: 0 }));
422 }
423
424 #[test]
425 fn empty_tally_rejected() {
426 let t = MeshTallyData {
427 tally_number: 1,
428 particle: ParticleKind::Neutron,
429 dose_response: false,
430 x_bounds: vec![0.0],
431 y_bounds: vec![0.0],
432 z_bounds: vec![0.0],
433 e_bounds: vec![],
434 column_idx: Default::default(),
435 result: Vec::new(),
436 rel_error: Vec::new(),
437 total_result: Vec::new(),
438 total_rel_error: Vec::new(),
439 };
440 assert_eq!(magic(&t), Err(Error::EmptyTally));
441 }
442
443 #[test]
444 fn length_mismatch_detected() {
445 let mut t = sample_tally(
446 vec![vec![1.0], vec![1.0]],
447 vec![vec![0.0], vec![0.0]],
448 vec![1.0],
449 vec![0.0],
450 );
451 t.total_rel_error = vec![0.0, 0.0];
452 assert!(matches!(magic(&t), Err(Error::LengthMismatch { .. })));
453 }
454
455 #[test]
456 fn empty_e_bounds_with_volume_elements_does_not_panic() {
457 let t = MeshTallyData {
460 tally_number: 1,
461 particle: ParticleKind::Neutron,
462 dose_response: false,
463 x_bounds: vec![0.0, 1.0],
464 y_bounds: vec![0.0, 1.0],
465 z_bounds: vec![0.0, 1.0],
466 e_bounds: vec![],
467 column_idx: Default::default(),
468 result: vec![vec![1.0]],
469 rel_error: vec![vec![0.0]],
470 total_result: vec![1.0],
471 total_rel_error: vec![0.0],
472 };
473 assert_eq!(magic(&t), Err(Error::EmptyTally));
474 }
475
476 #[test]
477 fn non_finite_flux_rejected() {
478 let mut t = sample_tally(
479 vec![vec![0.0]; 4],
480 vec![vec![0.0]; 4],
481 vec![1.0, f64::NAN, 1.0, 1.0],
482 vec![0.0; 4],
483 );
484 assert!(matches!(
485 magic(&t),
486 Err(Error::NonFiniteTally {
487 field: "flux",
488 index: 1
489 })
490 ));
491
492 t.total_result[1] = f64::INFINITY;
493 assert!(matches!(
494 magic(&t),
495 Err(Error::NonFiniteTally {
496 field: "flux",
497 index: 1
498 })
499 ));
500 }
501
502 #[test]
503 fn non_finite_error_rejected() {
504 let t = sample_tally(
505 vec![vec![0.0]; 4],
506 vec![vec![0.0]; 4],
507 vec![1.0; 4],
508 vec![0.0, f64::NAN, 0.0, 0.0],
509 );
510 assert!(matches!(
511 magic(&t),
512 Err(Error::NonFiniteTally {
513 field: "error",
514 index: 1
515 })
516 ));
517 }
518
519 fn fixture(name: &str) -> String {
520 format!(
521 "{}/../../fixtures/mcnp/meshtal/{name}",
522 env!("CARGO_MANIFEST_DIR")
523 )
524 }
525}