Skip to main content

nucleide_vr_tools/
magic.rs

1//! MAGIC weight-window generation.
2//!
3//! Derives MCNP weight-window lower bounds from an MCNP mesh tally:
4//!
5//! ```text
6//! max_val[g]  = max over all volume elements of flux[ve][g]
7//! ww[ve][g]   = null_value                       if rel_error[ve][g] > tolerance
8//!             = flux[ve][g] / (2 * max_val[g])   otherwise
9//! ```
10//!
11//! Defaults: `tolerance = 0.5`, `null_value = 0.0`. The comparison against
12//! the tolerance is strict (`>`), so a cell whose error equals the tolerance
13//! keeps its scaled value.
14//!
15//! Following the legacy mesh-tag convention, the output also carries the
16//! single maximum energy bound for total/single-bin tallies (or `e_bounds[1:]`
17//! for multi-group tallies) in [`MagicOutput::e_upper_bounds`], plus the tag
18//! names the data would be stored under (`ww_n`, `n_e_upper_bounds`, ...) in
19//! [`MagicOutput::ww_tag_name`] / [`MagicOutput::e_upper_bounds_tag_name`].
20//!
21//! If every flux feeding one energy bin is non-positive, the normalization
22//! would divide by zero; [`Error::ZeroMaxFlux`] is returned rather than
23//! emitting `inf`/`nan`.
24
25use nucleide_mcnp_io::meshtal::{MeshTallyData, ParticleKind};
26
27use crate::Error;
28
29/// Which tally arrays feed the MAGIC algorithm.
30///
31/// Legacy tooling selected this implicitly through the mesh tag passed in
32/// (`n_total_result` versus `n_result`); here it is explicit.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MagicSelection {
35    /// Energy-integrated totals: uses [`MeshTallyData::total_result`] /
36    /// [`MeshTallyData::total_rel_error`]. One lower bound per volume element.
37    Total,
38    /// Per-energy-group values: uses [`MeshTallyData::result`] /
39    /// [`MeshTallyData::rel_error`]. One lower bound per element per group,
40    /// flattened `[ve][group]`.
41    PerGroup,
42}
43
44/// MAGIC tuning parameters.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct MagicParams {
47    /// Maximum relative error for which a weight-window lower bound is
48    /// generated. Elements above it receive `null_value`.
49    pub tolerance: f64,
50    /// Lower-bound value assigned where relative error exceeds `tolerance`.
51    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/// Output of the MAGIC algorithm — the data written to mesh tags in legacy
64/// workflows.
65#[derive(Debug, Clone, PartialEq)]
66pub struct MagicOutput {
67    /// Weight-window lower bounds, row-major over volume elements:
68    /// total mode → `lower_bounds_ww[ve]`; per-group mode →
69    /// `lower_bounds_ww[ve * groups + g]`.
70    pub lower_bounds_ww: Vec<f64>,
71    /// Number of bounds per volume element (1 in total mode).
72    pub groups_per_ve: usize,
73    /// Per-energy-group normalization scale factors `1 / (2 * max_val[g])`
74    /// that non-nulled fluxes were multiplied by (length `groups_per_ve`).
75    pub scale_factors: Vec<f64>,
76    /// Content of the `{particle}_e_upper_bounds` tag: the maximum
77    /// energy bound in total mode, `e_bounds[1..]` otherwise.
78    pub e_upper_bounds: Vec<f64>,
79    /// Mesh tag name for the lower bounds
80    /// (e.g. `"ww_n"` for neutron tallies).
81    pub ww_tag_name: String,
82    /// Mesh tag name for [`MagicOutput::e_upper_bounds`]
83    /// (e.g. `"n_e_upper_bounds"`).
84    pub e_upper_bounds_tag_name: String,
85}
86
87/// Run MAGIC on the energy-integrated totals of a tally with default
88/// parameters (`tolerance = 0.5`, `null_value = 0.0`).
89pub fn magic(tally: &MeshTallyData) -> Result<MagicOutput, Error> {
90    magic_with(tally, MagicSelection::Total, MagicParams::default())
91}
92
93/// Run MAGIC on a tally with explicit array selection and parameters.
94pub 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    // Flatten to ve-major [ve * groups_per_ve + g], matching the legacy
108    // `vals[:]` / `errors[:]` tag reads.
109    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    // max_val[i] = np.max over all ves for each energy bin.
146    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    // ww[ve][i] = null_value if error > tolerance else value / (2 * max_val[i]).
160    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    /// Minimal hand-built neutron tally mirroring reference test meshes:
195    /// 4 ves, e_bounds [0, 0.5, 1].
196    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        // Replica of test_magic_below_tolerance: all errors < 0.5, defaults.
235        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        // Replica of test_magic_e_total: error 0.19 > 0.15 nulls to 0.001.
255        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        // Replica of test_magic_multi_bins. Group-1 error 0.16 > 0.15 nulls;
276        // the boundary case itself is covered by the fixture tests below via
277        // strict inequality in the implementation.
278        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        // max(total_result) = 1.31488E-06, no rel error exceeds 0.5.
327        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        // Per-group flux maxima across all 45 ves (hand-computed from the
357        // fixture): 4.45445E-08, 1.04704E-07, 1.16563E-06.
358        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        // Cell 22 holds the maximum of every group simultaneously.
363        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        // Hand-computed: 87 of the 135 (ve, group) pairs exceed tol = 0.06.
400        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        // Totals all zero too.
421        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        // num_ves() > 0 but e_bounds is empty used to underflow in
458        // num_e_groups(). The guard must check e_bounds.len() < 2 directly.
459        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}