Skip to main content

nucleide_vr_tools/
windows.rs

1//! Weight-window emission for OpenMC and Serpent over [`MagicOutput`].
2//!
3//! Both emitters are pure formatting steps: they reorder
4//! [`MagicOutput::lower_bounds_ww`] into the target code's flat layout and
5//! wrap it with the mesh/energy structure taken from the source
6//! [`MeshTallyData`]. No new math lives here.
7//!
8//! # Pinned format spellings (public docs)
9//!
10//! - **OpenMC `settings.xml`** — `<mesh>` element per the settings
11//!   specification §3.29 (`id` attribute; `dimension`, `lower_left`,
12//!   `upper_right` sub-elements) and `<weight_windows>` per §3.66, spelled
13//!   exactly as the OpenMC Python writer emits and the C++ XML reader parses
14//!   it: `id` attribute plus `mesh`, `particle_type`, `energy_bounds` (eV),
15//!   `lower_ww_bounds`, `upper_ww_bounds`, `survival_ratio`, `max_split`, and
16//!   `weight_cutoff` sub-elements. Flat bound order follows the C++ bounds
17//!   tensor layout `(energy_bin, mesh_bin)` with mesh bins x-fastest — i.e.
18//!   per energy group, all volume elements in x-slowest/z-fastest mesh order
19//!   transposed to x-fastest order, groups outermost. The emitted fragment is
20//!   pasted inside the existing `<settings>` root next to the user's other
21//!   elements.
22//! - **Serpent `wwin`** — the user guide §2.2.8.3 and the input-syntax manual
23//!   (`wwin` card) define `wwin NAME wf FILE FMT` with `FMT = 1` for a
24//!   Serpent-generated mesh and `FMT = 2` for the MCNP WWINP text format.
25//!   Only FMT = 2 is emitted: its layout is pinned by public documentation
26//!   (MCNP user manual) and by the in-workspace reader in
27//!   [`nucleide_mcnp_io::wwinp`], which the tests re-parse through. The
28//!   Serpent-native FMT = 1 layout is not publicly specified; requesting it
29//!   is a loud error (there is deliberately no API that emits it), and the
30//!   returned [`SerpentWwin::card`] pins `wf "<file>" 2`.
31//!
32//! # Loud-error boundary
33//!
34//! A [`MagicOutput`] has no well-formed spelling when any lower bound is
35//! non-finite or negative (both formats treat non-positive windows as inert,
36//! but a negative bound has no meaning), when the energy upper bounds are not
37//! strictly increasing positive values, when the mesh bounds are degenerate,
38//! or when the requested OpenMC tuning parameters fall outside the ranges
39//! the OpenMC reader enforces. The Serpent `wwin` card tokens (`name`,
40//! `file`) must be single whitespace-and-quote-free words, and the OpenMC
41//! scaling steps (MeV→eV energies, `lower × upper_bound_ratio`) must stay
42//! finite after formatting. Each case is a named [`crate::Error`]
43//! variant; nothing is silently dropped or clamped.
44
45use nucleide_mcnp_io::meshtal::{MeshTallyData, ParticleKind};
46use nucleide_mcnp_io::wwinp::Wwinp;
47
48use crate::{Error, MagicOutput, Result};
49
50/// OpenMC emission settings, defaulting to the values OpenMC itself uses.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct OpenMcOptions {
53    /// ID written to the `<mesh id="...">` attribute (referenced by
54    /// `<weight_windows><mesh>`).
55    pub mesh_id: u32,
56    /// ID written to the `<weight_windows id="...">` attribute.
57    pub window_id: u32,
58    /// Ratio of upper to lower weight-window bounds. MAGIC produces lower
59    /// bounds only; OpenMC requires both, so the uppers are synthesized as
60    /// `upper = lower * ratio`. OpenMC's own WWINP importer uses 5.0.
61    pub upper_bound_ratio: f64,
62    /// Survival weight over lower bound; must be greater than 1 and less
63    /// than [`OpenMcOptions::upper_bound_ratio`] (OpenMC reader range).
64    pub survival_ratio: f64,
65    /// Maximum split count; must be at least 2 (OpenMC reader range).
66    pub max_split: u32,
67    /// Russian-roulette weight cutoff; must be in `(0, 1]` (OpenMC reader
68    /// range).
69    pub weight_cutoff: f64,
70}
71
72impl Default for OpenMcOptions {
73    fn default() -> Self {
74        Self {
75            mesh_id: 1,
76            window_id: 1,
77            upper_bound_ratio: 5.0,
78            survival_ratio: 3.0,
79            max_split: 10,
80            weight_cutoff: 1.0e-38,
81        }
82    }
83}
84
85/// Emitted OpenMC weight-window fragment.
86#[derive(Debug, Clone, PartialEq)]
87pub struct OpenMcWeightWindows {
88    /// `<mesh>` + `<weight_windows>` elements to paste into `settings.xml`.
89    pub xml: String,
90    /// Machine-readable drift notes (synthesized structure, null cells).
91    pub notes: Vec<String>,
92}
93
94/// Emitted Serpent weight-window file (MCNP WWINP text, read via `wf ... 2`).
95#[derive(Debug, Clone, PartialEq)]
96pub struct SerpentWwin {
97    /// WWINP-format file content (the `.wwd` file body).
98    pub text: String,
99    /// Input card referencing the file: `wwin <name> wf "<file>" 2`.
100    pub card: String,
101    /// Machine-readable drift notes (format pinning, synthesized structure).
102    pub notes: Vec<String>,
103}
104
105/// Emit MAGIC lower bounds as an OpenMC `settings.xml` fragment.
106pub fn emit_openmc_weight_windows(
107    output: &MagicOutput,
108    tally: &MeshTallyData,
109    options: &OpenMcOptions,
110) -> Result<OpenMcWeightWindows> {
111    let dims = validate(output, tally)?;
112    validate_openmc_options(options)?;
113
114    let groups = output.groups_per_ve;
115    let particle = match tally.particle {
116        ParticleKind::Neutron => "neutron",
117        ParticleKind::Photon => "photon",
118    };
119    // Energy bounds in eV with the implicit zero lower edge, matching the
120    // convention OpenMC's own WWINP importer applies to upper-bound-only
121    // inputs. Meshtal energies are MeV.
122    let energy_bounds: Vec<f64> = std::iter::once(0.0)
123        .chain(output.e_upper_bounds.iter().map(|e| e * 1.0e6))
124        .collect();
125    let lower = flat_bounds_xfastest(output, dims);
126    let upper: Vec<f64> = lower
127        .iter()
128        .map(|v| v * options.upper_bound_ratio)
129        .collect();
130    // Finite validated inputs can still overflow at the scaling steps
131    // (MeV→eV, lower × ratio); a non-finite value in the XML would silently
132    // break the OpenMC reader, so the emission is loud instead.
133    if let Some(&v) = energy_bounds.iter().find(|v| !v.is_finite()) {
134        return Err(Error::BadEmissionOption {
135            option: "energy_bounds",
136            value: v.to_string(),
137            detail: "MeV-to-eV scaling overflowed to a non-finite value",
138        });
139    }
140    if let Some(&v) = upper.iter().find(|v| !v.is_finite()) {
141        return Err(Error::BadEmissionOption {
142            option: "upper_ww_bounds",
143            value: v.to_string(),
144            detail: "lower * upper_bound_ratio overflowed to a non-finite value",
145        });
146    }
147
148    let mut xml = String::new();
149    xml += &format!("  <mesh id=\"{}\">\n", options.mesh_id);
150    xml += &format!(
151        "    <dimension>{} {} {}</dimension>\n",
152        dims[0], dims[1], dims[2]
153    );
154    xml += &format!(
155        "    <lower_left>{} {} {}</lower_left>\n",
156        xml_f64(tally.x_bounds[0]),
157        xml_f64(tally.y_bounds[0]),
158        xml_f64(tally.z_bounds[0])
159    );
160    xml += &format!(
161        "    <upper_right>{} {} {}</upper_right>\n",
162        xml_f64(*tally.x_bounds.last().expect("validated")),
163        xml_f64(*tally.y_bounds.last().expect("validated")),
164        xml_f64(*tally.z_bounds.last().expect("validated"))
165    );
166    xml += "  </mesh>\n";
167    xml += &format!("  <weight_windows id=\"{}\">\n", options.window_id);
168    xml += &format!("    <mesh>{}</mesh>\n", options.mesh_id);
169    xml += &format!("    <particle_type>{particle}</particle_type>\n");
170    xml += &format!(
171        "    <energy_bounds>{}</energy_bounds>\n",
172        join_f64(&energy_bounds)
173    );
174    xml += &format!(
175        "    <lower_ww_bounds>{}</lower_ww_bounds>\n",
176        join_f64(&lower)
177    );
178    xml += &format!(
179        "    <upper_ww_bounds>{}</upper_ww_bounds>\n",
180        join_f64(&upper)
181    );
182    xml += &format!(
183        "    <survival_ratio>{}</survival_ratio>\n",
184        xml_f64(options.survival_ratio)
185    );
186    xml += &format!("    <max_split>{}</max_split>\n", options.max_split);
187    xml += &format!(
188        "    <weight_cutoff>{}</weight_cutoff>\n",
189        xml_f64(options.weight_cutoff)
190    );
191    xml += "  </weight_windows>\n";
192
193    let mut notes = vec![format!(
194        "upper-bounds-synthesized: upper = lower * {} (MAGIC produces lower bounds only; OpenMC requires both)",
195        options.upper_bound_ratio
196    )];
197    if groups > 1 {
198        notes.push(format!(
199            "energy-bounds-mev-to-ev: {} group upper bounds converted MeV -> eV with implicit 0 eV lower edge",
200            groups
201        ));
202    }
203    notes.extend(null_notes(output));
204
205    Ok(OpenMcWeightWindows { xml, notes })
206}
207
208/// Validate one token interpolated into the Serpent `wwin` card. The card
209/// is assembled as `wwin {name} wf "{file}" 2`, so a token holding
210/// whitespace or a quote would corrupt the deck (an injected newline starts
211/// a new card). Only a single whitespace-and-quote-free word is accepted.
212fn check_wwin_token(option: &'static str, token: &str) -> Result<()> {
213    if token.is_empty()
214        || token
215            .chars()
216            .any(|c| c.is_whitespace() || c == '"' || c == '\'')
217    {
218        return Err(Error::BadEmissionOption {
219            option,
220            value: token.to_string(),
221            detail: "must be a single token without whitespace or quotes",
222        });
223    }
224    Ok(())
225}
226
227/// Emit MAGIC lower bounds as a Serpent-readable weight-window file.
228///
229/// The file is written in the MCNP WWINP text spelling, which Serpent reads
230/// through `wwin <name> wf "<file>" 2` (user guide §2.2.8.3). The
231/// Serpent-native FMT = 1 layout is not publicly documented and is never
232/// emitted. WWINP carries only window lower bounds and the energies/mesh in
233/// MeV/cm, so no unit conversion is applied.
234pub fn emit_serpent_wwin(
235    output: &MagicOutput,
236    tally: &MeshTallyData,
237    name: &str,
238    file: &str,
239) -> Result<SerpentWwin> {
240    check_wwin_token("name", name)?;
241    check_wwin_token("file", file)?;
242    let dims = validate(output, tally)?;
243    let groups = output.groups_per_ve;
244
245    let mut cm = Vec::with_capacity(3);
246    let mut fm = Vec::with_capacity(3);
247    let mut bounds = Vec::with_capacity(3);
248    for axis in [&tally.x_bounds, &tally.y_bounds, &tally.z_bounds] {
249        let (c, f) = coarse_fine(axis);
250        cm.push(c);
251        fm.push(f);
252        bounds.push(axis.clone());
253    }
254    let nc = [cm[0].len() as u32, cm[1].len() as u32, cm[2].len() as u32];
255    let nf = [dims[0] as u32, dims[1] as u32, dims[2] as u32];
256    let nft = nf.iter().map(|v| u64::from(*v)).product();
257
258    let nve = dims[0] * dims[1] * dims[2];
259    let mut particle_windows: Vec<Vec<f64>> = vec![vec![0.0; nve]; groups];
260    for_each_bound_xfastest(output, dims, |g, ve, w| particle_windows[g][ve] = w);
261    let energies = output.e_upper_bounds.clone();
262
263    // MCNP convention (see the nucleide-mcnp-io WWINP fixtures): neutron-only
264    // files set ni = 1 with a single energy-group slot; photon-only files set
265    // ni = 2 and declare an empty neutron slot, with photon data in the
266    // second slot.
267    let (ni, ne, e, ww) = match tally.particle {
268        ParticleKind::Neutron => (
269            1,
270            vec![groups as u32],
271            vec![energies],
272            vec![particle_windows],
273        ),
274        ParticleKind::Photon => (
275            2,
276            vec![0, groups as u32],
277            vec![energies],
278            vec![particle_windows],
279        ),
280    };
281
282    let wwinp = Wwinp {
283        ni,
284        nr: 10,
285        ne,
286        nf,
287        nft,
288        origin: [tally.x_bounds[0], tally.y_bounds[0], tally.z_bounds[0]],
289        nc,
290        nwg: 1,
291        date_time: String::new(),
292        cm,
293        fm,
294        bounds,
295        e,
296        ww,
297    };
298    let text = wwinp.to_text().map_err(|e| Error::Wwinp(e.to_string()))?;
299
300    let mut notes = vec![
301        "format-pinned: Serpent reads this file via `wwin <name> wf \"<file>\" 2` (MCNP WWINP \
302         spelling, user guide 2.2.8.3); the Serpent-native FMT=1 wwd layout is not publicly \
303         documented and is not emitted"
304            .to_string(),
305        "lower-bounds-only: WWINP carries lower bounds; Serpent derives upper bounds from \
306         importances at load time (set wwb LB=0.5 UB=2 defaults)"
307            .to_string(),
308    ];
309    notes.extend(null_notes(output));
310
311    Ok(SerpentWwin {
312        text,
313        card: format!("wwin {name} wf \"{file}\" 2"),
314        notes,
315    })
316}
317
318/// Validate the shared input structure; returns `[nx, ny, nz]` on success.
319fn validate(output: &MagicOutput, tally: &MeshTallyData) -> Result<[usize; 3]> {
320    let axes = [&tally.x_bounds, &tally.y_bounds, &tally.z_bounds];
321    for (axis, b) in axes.iter().enumerate() {
322        if b.len() < 2 {
323            return Err(Error::BadMeshBounds { axis, index: 0 });
324        }
325        for (i, &v) in b.iter().enumerate() {
326            if !v.is_finite() {
327                return Err(Error::BadMeshBounds { axis, index: i });
328            }
329            if i > 0 && v <= b[i - 1] {
330                return Err(Error::BadMeshBounds { axis, index: i });
331            }
332        }
333    }
334    let dims = [
335        tally.x_bounds.len() - 1,
336        tally.y_bounds.len() - 1,
337        tally.z_bounds.len() - 1,
338    ];
339    let groups = output.groups_per_ve;
340    if groups == 0 || output.e_upper_bounds.len() != groups {
341        return Err(Error::BadEnergyBounds { index: 0 });
342    }
343    for (g, &e) in output.e_upper_bounds.iter().enumerate() {
344        if !e.is_finite() || e <= 0.0 {
345            return Err(Error::BadEnergyBounds { index: g });
346        }
347        if g > 0 && e <= output.e_upper_bounds[g - 1] {
348            return Err(Error::BadEnergyBounds { index: g });
349        }
350    }
351    let expected = dims[0] * dims[1] * dims[2] * groups;
352    if output.lower_bounds_ww.len() != expected {
353        return Err(Error::LengthMismatch {
354            expected,
355            got: output.lower_bounds_ww.len(),
356        });
357    }
358    for (i, &w) in output.lower_bounds_ww.iter().enumerate() {
359        if !w.is_finite() {
360            return Err(Error::NonFiniteWindow { index: i });
361        }
362        if w < 0.0 {
363            return Err(Error::NegativeWindow { index: i, value: w });
364        }
365    }
366    Ok(dims)
367}
368
369fn validate_openmc_options(options: &OpenMcOptions) -> Result<()> {
370    if options.upper_bound_ratio <= 1.0 {
371        return Err(Error::BadEmissionOption {
372            option: "upper_bound_ratio",
373            value: options.upper_bound_ratio.to_string(),
374            detail: "must be greater than 1",
375        });
376    }
377    if !(options.survival_ratio > 1.0 && options.survival_ratio < options.upper_bound_ratio) {
378        return Err(Error::BadEmissionOption {
379            option: "survival_ratio",
380            value: options.survival_ratio.to_string(),
381            detail: "must be greater than 1 and less than upper_bound_ratio",
382        });
383    }
384    if options.max_split < 2 {
385        return Err(Error::BadEmissionOption {
386            option: "max_split",
387            value: options.max_split.to_string(),
388            detail: "must be at least 2",
389        });
390    }
391    if !(options.weight_cutoff > 0.0 && options.weight_cutoff <= 1.0) {
392        return Err(Error::BadEmissionOption {
393            option: "weight_cutoff",
394            value: options.weight_cutoff.to_string(),
395            detail: "must be in (0, 1]",
396        });
397    }
398    Ok(())
399}
400
401/// Visit every `(group, x-fastest ve index, bound)` triple in the flat
402/// x-fastest window layout both target codes use: group outermost, then k,
403/// j, i with x fastest. [`MagicOutput::lower_bounds_ww`] stores the
404/// transposed (z-fastest) layout with groups innermost, so this walks it in
405/// one sequential pass (volume-element rows are contiguous) and scatters
406/// each row across the `groups` destination regions.
407fn for_each_bound_xfastest(
408    output: &MagicOutput,
409    dims: [usize; 3],
410    mut emit: impl FnMut(usize, usize, f64),
411) {
412    let (nx, ny, nz) = (dims[0], dims[1], dims[2]);
413    let groups = output.groups_per_ve;
414    for i in 0..nx {
415        for j in 0..ny {
416            for k in 0..nz {
417                let row = &output.lower_bounds_ww[((i * ny + j) * nz + k) * groups..][..groups];
418                let ve = (k * ny + j) * nx + i;
419                for (g, &w) in row.iter().enumerate() {
420                    emit(g, ve, w);
421                }
422            }
423        }
424    }
425}
426
427/// Flat x-fastest window vector for all energy groups: group `g` outermost,
428/// then k, j, i with x fastest — the ordering both OpenMC and WWINP use.
429fn flat_bounds_xfastest(output: &MagicOutput, dims: [usize; 3]) -> Vec<f64> {
430    let nve = dims[0] * dims[1] * dims[2];
431    let mut flat = vec![0.0; nve * output.groups_per_ve];
432    for_each_bound_xfastest(output, dims, |g, ve, w| flat[g * nve + ve] = w);
433    flat
434}
435
436/// Group equal-width runs of cells into WWINP coarse bins. WWINP block 2
437/// stores only coarse boundaries plus a fine-bin count, with fine bins
438/// defined by uniform interpolation, so cells may share a coarse bin only
439/// when their widths are exactly equal (bitwise) — anything else gets its
440/// own coarse bin and reproduces the grid exactly.
441fn coarse_fine(bounds: &[f64]) -> (Vec<f64>, Vec<f64>) {
442    debug_assert!(bounds.len() >= 2);
443    let mut cm = Vec::new();
444    let mut fm = Vec::new();
445    let mut run_start = 0usize;
446    let mut run_w = bounds[1] - bounds[0];
447    for k in 1..bounds.len() - 1 {
448        let w = bounds[k + 1] - bounds[k];
449        if w != run_w {
450            cm.push(bounds[k]);
451            fm.push((k - run_start) as f64);
452            run_start = k;
453            run_w = w;
454        }
455    }
456    cm.push(bounds[bounds.len() - 1]);
457    fm.push((bounds.len() - 1 - run_start) as f64);
458    (cm, fm)
459}
460
461fn null_notes(output: &MagicOutput) -> Vec<String> {
462    let nulled = output.lower_bounds_ww.iter().filter(|&&w| w == 0.0).count();
463    if nulled == 0 {
464        Vec::new()
465    } else {
466        vec![format!(
467            "null-cells: {nulled} of {} lower bounds are 0.0 (MAGIC null value); non-positive \
468             windows are inert in OpenMC and Serpent treats the cell importance as infinite \
469             (set wwb) — review whether null_value=0 is intended",
470            output.lower_bounds_ww.len()
471        )]
472    }
473}
474
475/// Format a float for XML text nodes: shortest round-trip decimal, switching
476/// to scientific notation outside `[1e-6, 1e21)` so tiny values like the
477/// default `1e-38` weight cutoff stay compact. Any spelling `std::stod`
478/// (OpenMC) parses is acceptable; fixtures pin this one.
479fn xml_f64(v: f64) -> String {
480    XmlF64(v).to_string()
481}
482
483/// [`std::fmt::Display`] adapter spelling [`xml_f64`] without an
484/// intermediate heap `String` per value.
485struct XmlF64(f64);
486
487impl std::fmt::Display for XmlF64 {
488    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489        let v = self.0;
490        if v == 0.0 {
491            return f.write_str("0.0");
492        }
493        if (1.0e-6..1.0e21).contains(&v.abs()) {
494            write!(f, "{v}")
495        } else {
496            write!(f, "{v:e}")
497        }
498    }
499}
500
501fn join_f64(values: &[f64]) -> String {
502    use std::fmt::Write as _;
503    let mut out = String::with_capacity(values.len() * 4);
504    for (i, &v) in values.iter().enumerate() {
505        if i > 0 {
506            out.push(' ');
507        }
508        write!(out, "{}", XmlF64(v)).expect("write to String");
509    }
510    out
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::magic::MagicOutput;
517
518    /// Synthetic 2×2×1 neutron tally with uniform 1.0/2.0/2.0 cm cells.
519    fn sample_tally() -> MeshTallyData {
520        MeshTallyData {
521            tally_number: 4,
522            particle: ParticleKind::Neutron,
523            dose_response: false,
524            x_bounds: vec![0.0, 1.0, 2.0],
525            y_bounds: vec![-1.0, 1.0, 3.0],
526            z_bounds: vec![10.0, 12.0],
527            e_bounds: vec![0.0, 0.5, 1.0],
528            column_idx: Default::default(),
529            result: Vec::new(),
530            rel_error: Vec::new(),
531            total_result: Vec::new(),
532            total_rel_error: Vec::new(),
533        }
534    }
535
536    /// Two-group windows, values chosen dyadic so `{:13.5E}` round-trips
537    /// exactly through the WWINP reader and `upper = 5*lower` is exact.
538    /// `lower_bounds_ww` is ve-major (z-fastest ve, groups innermost).
539    fn sample_output() -> MagicOutput {
540        MagicOutput {
541            lower_bounds_ww: vec![
542                0.5, 0.25, // ve0 = (i0, j0, k0)
543                0.25, 0.5, // ve1 = (i0, j1, k0)
544                0.125, 0.75, // ve2 = (i1, j0, k0)
545                0.0625, 1.0, // ve3 = (i1, j1, k0)
546            ],
547            groups_per_ve: 2,
548            scale_factors: vec![1.0, 1.0],
549            e_upper_bounds: vec![0.5, 1.0],
550            ww_tag_name: "ww_n".to_string(),
551            e_upper_bounds_tag_name: "n_e_upper_bounds".to_string(),
552        }
553    }
554
555    /// The x-fastest flat layout both target codes use, group-outermost.
556    const XFASTEST_FLAT: [f64; 8] = [0.5, 0.125, 0.25, 0.0625, 0.25, 0.75, 0.5, 1.0];
557
558    fn floats_of(tag: &str, xml: &str) -> Vec<f64> {
559        let open = format!("<{tag}>");
560        let line = xml
561            .lines()
562            .find(|l| l.trim_start().starts_with(&open))
563            .unwrap_or_else(|| panic!("missing <{tag}> in:\n{xml}"));
564        line.trim()
565            .strip_prefix(&open)
566            .and_then(|s| s.strip_suffix(&format!("</{tag}>")))
567            .expect("malformed element")
568            .split_whitespace()
569            .map(|t| t.parse::<f64>().unwrap())
570            .collect()
571    }
572
573    #[test]
574    fn openmc_per_group_matches_golden_xml() {
575        let out = emit_openmc_weight_windows(
576            &sample_output(),
577            &sample_tally(),
578            &OpenMcOptions::default(),
579        )
580        .unwrap();
581        let expected = "  <mesh id=\"1\">\n\
582            \x20   <dimension>2 2 1</dimension>\n\
583            \x20   <lower_left>0.0 -1 10</lower_left>\n\
584            \x20   <upper_right>2 3 12</upper_right>\n\
585            \x20 </mesh>\n\
586            \x20 <weight_windows id=\"1\">\n\
587            \x20   <mesh>1</mesh>\n\
588            \x20   <particle_type>neutron</particle_type>\n\
589            \x20   <energy_bounds>0.0 500000 1000000</energy_bounds>\n\
590            \x20   <lower_ww_bounds>0.5 0.125 0.25 0.0625 0.25 0.75 0.5 1</lower_ww_bounds>\n\
591            \x20   <upper_ww_bounds>2.5 0.625 1.25 0.3125 1.25 3.75 2.5 5</upper_ww_bounds>\n\
592            \x20   <survival_ratio>3</survival_ratio>\n\
593            \x20   <max_split>10</max_split>\n\
594            \x20   <weight_cutoff>1e-38</weight_cutoff>\n\
595            \x20 </weight_windows>\n";
596        assert_eq!(out.xml, expected);
597    }
598
599    #[test]
600    fn openmc_reparse_recovers_windows_and_structure() {
601        let out = emit_openmc_weight_windows(
602            &sample_output(),
603            &sample_tally(),
604            &OpenMcOptions::default(),
605        )
606        .unwrap();
607        // Structural assertions on our own emitted text (test-local parsing,
608        // no new reader crate).
609        assert_eq!(
610            floats_of("lower_ww_bounds", &out.xml),
611            XFASTEST_FLAT.to_vec()
612        );
613        let upper: Vec<f64> = XFASTEST_FLAT.iter().map(|v| v * 5.0).collect();
614        assert_eq!(floats_of("upper_ww_bounds", &out.xml), upper);
615        assert_eq!(
616            floats_of("energy_bounds", &out.xml),
617            vec![0.0, 500000.0, 1.0e6]
618        );
619        assert_eq!(floats_of("dimension", &out.xml), vec![2.0, 2.0, 1.0]);
620        assert_eq!(floats_of("lower_left", &out.xml), vec![0.0, -1.0, 10.0]);
621        assert_eq!(floats_of("upper_right", &out.xml), vec![2.0, 3.0, 12.0]);
622        assert!(out.xml.contains("<mesh id=\"1\">"));
623        assert!(out.xml.contains("<weight_windows id=\"1\">"));
624        assert!(out.xml.contains("<particle_type>neutron</particle_type>"));
625        assert!(out
626            .notes
627            .iter()
628            .any(|n| n.starts_with("upper-bounds-synthesized")));
629    }
630
631    #[test]
632    fn openmc_total_mode_emits_single_energy_bin() {
633        let mut output = sample_output();
634        output.groups_per_ve = 1;
635        output.lower_bounds_ww = vec![0.5, 0.25, 0.125, 0.0625];
636        output.e_upper_bounds = vec![1.0];
637        let out = emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default())
638            .unwrap();
639        assert_eq!(floats_of("energy_bounds", &out.xml), vec![0.0, 1.0e6]);
640        assert_eq!(
641            floats_of("lower_ww_bounds", &out.xml),
642            vec![0.5, 0.125, 0.25, 0.0625]
643        );
644    }
645
646    #[test]
647    fn openmc_post_scaling_overflow_is_loud() {
648        // Finite, validated inputs can still overflow at the scaling steps:
649        // huge MeV group bounds x1e6 and huge lower bounds x the ratio.
650        let mut output = sample_output();
651        output.e_upper_bounds = vec![1.0e300, 1.0e308];
652        assert!(matches!(
653            emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default()),
654            Err(Error::BadEmissionOption {
655                option: "energy_bounds",
656                ..
657            })
658        ));
659        let mut output = sample_output();
660        output.lower_bounds_ww = vec![1.0e308; 8];
661        assert!(matches!(
662            emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default()),
663            Err(Error::BadEmissionOption {
664                option: "upper_ww_bounds",
665                ..
666            })
667        ));
668    }
669
670    #[test]
671    fn serpent_wwin_rejects_card_token_injection() {
672        // `name`/`file` interpolate into `wwin {name} wf "{file}" 2`; a
673        // whitespace or quote character would corrupt the deck.
674        for (name, file, option) in [
675            ("ww1\nsrc 2 1 1 1", "mesh.wwd", "name"),
676            ("ww1 src", "mesh.wwd", "name"),
677            ("ww1", "mesh.wwd\nsrc 2", "file"),
678            ("ww1", "mesh\"wwd", "file"),
679        ] {
680            let err = emit_serpent_wwin(&sample_output(), &sample_tally(), name, file).unwrap_err();
681            assert!(
682                matches!(err, Error::BadEmissionOption { option: o, .. } if o == option),
683                "{name:?}/{file:?}: {err}"
684            );
685        }
686        // Plain tokens still emit the pinned card.
687        let out = emit_serpent_wwin(&sample_output(), &sample_tally(), "ww1", "mesh.wwd").unwrap();
688        assert_eq!(out.card, "wwin ww1 wf \"mesh.wwd\" 2");
689    }
690
691    #[test]
692    fn openmc_photon_spells_photon_type() {
693        let mut tally = sample_tally();
694        tally.particle = ParticleKind::Photon;
695        let out = emit_openmc_weight_windows(&sample_output(), &tally, &OpenMcOptions::default())
696            .unwrap();
697        assert!(out.xml.contains("<particle_type>photon</particle_type>"));
698    }
699
700    #[test]
701    fn openmc_option_validation_is_loud() {
702        let bad = OpenMcOptions {
703            survival_ratio: 1.0,
704            ..Default::default()
705        };
706        assert!(matches!(
707            emit_openmc_weight_windows(&sample_output(), &sample_tally(), &bad),
708            Err(Error::BadEmissionOption {
709                option: "survival_ratio",
710                ..
711            })
712        ));
713        let bad = OpenMcOptions {
714            survival_ratio: 6.0,
715            ..Default::default()
716        };
717        assert!(matches!(
718            emit_openmc_weight_windows(&sample_output(), &sample_tally(), &bad),
719            Err(Error::BadEmissionOption {
720                option: "survival_ratio",
721                ..
722            })
723        ));
724        let bad = OpenMcOptions {
725            max_split: 1,
726            ..Default::default()
727        };
728        assert!(matches!(
729            emit_openmc_weight_windows(&sample_output(), &sample_tally(), &bad),
730            Err(Error::BadEmissionOption {
731                option: "max_split",
732                ..
733            })
734        ));
735        let bad = OpenMcOptions {
736            weight_cutoff: 2.0,
737            ..Default::default()
738        };
739        assert!(matches!(
740            emit_openmc_weight_windows(&sample_output(), &sample_tally(), &bad),
741            Err(Error::BadEmissionOption {
742                option: "weight_cutoff",
743                ..
744            })
745        ));
746    }
747
748    #[test]
749    fn serpent_matches_golden_wwinp_text() {
750        let out = emit_serpent_wwin(&sample_output(), &sample_tally(), "ww1", "mesh.wwd").unwrap();
751        assert_eq!(out.card, "wwin ww1 wf \"mesh.wwd\" 2");
752        // The emitter must reproduce exactly what assembling the same Wwinp
753        // through the canonical mcnp-io writer produces.
754        let expected = Wwinp {
755            ni: 1,
756            nr: 10,
757            ne: vec![2],
758            nf: [2, 2, 1],
759            nft: 4,
760            origin: [0.0, -1.0, 10.0],
761            nc: [1, 1, 1],
762            nwg: 1,
763            date_time: String::new(),
764            cm: vec![vec![2.0], vec![3.0], vec![12.0]],
765            fm: vec![vec![2.0], vec![2.0], vec![1.0]],
766            bounds: vec![vec![0.0, 1.0, 2.0], vec![-1.0, 1.0, 3.0], vec![10.0, 12.0]],
767            e: vec![vec![0.5, 1.0]],
768            ww: vec![vec![
769                vec![0.5, 0.125, 0.25, 0.0625],
770                vec![0.25, 0.75, 0.5, 1.0],
771            ]],
772        };
773        assert_eq!(out.text, expected.to_text().unwrap());
774    }
775
776    #[test]
777    fn serpent_reparse_through_wwinp_reader_is_exact() {
778        let out = emit_serpent_wwin(&sample_output(), &sample_tally(), "ww1", "mesh.wwd").unwrap();
779        // Real reader round trip: bounds, energies, and every window value
780        // must come back exactly (fixture values are 13.5E-exact).
781        let w = Wwinp::parse(&out.text).unwrap();
782        assert_eq!(w.ni, 1);
783        assert_eq!(w.nr, 10);
784        assert_eq!(w.ne, vec![2]);
785        assert_eq!(w.nf, [2, 2, 1]);
786        assert_eq!(w.nft, 4);
787        assert_eq!(w.origin, [0.0, -1.0, 10.0]);
788        assert_eq!(
789            w.bounds,
790            vec![vec![0.0, 1.0, 2.0], vec![-1.0, 1.0, 3.0], vec![10.0, 12.0]]
791        );
792        assert_eq!(w.e, vec![vec![0.5, 1.0]]);
793        // x-fastest order, group rows: this is the transpose-sensitive check.
794        assert_eq!(w.ww[0][0], vec![0.5, 0.125, 0.25, 0.0625]);
795        assert_eq!(w.ww[0][1], vec![0.25, 0.75, 0.5, 1.0]);
796    }
797
798    #[test]
799    fn serpent_nonuniform_mesh_reproduces_bounds() {
800        let mut tally = sample_tally();
801        // Widths 1, 2, 1 with no exactly-uniform run beyond single cells:
802        // every cell becomes its own coarse bin and the grid is reproduced
803        // exactly by the block-2 stream.
804        tally.x_bounds = vec![0.0, 1.0, 3.0, 4.0];
805        let mut output = sample_output();
806        output.lower_bounds_ww = vec![
807            0.5, 0.25, 0.25, 0.5, 0.125, 0.75, 0.0625, 1.0, 0.5, 0.25, 0.25, 0.5,
808        ];
809        output.e_upper_bounds = vec![0.5, 1.0];
810        let out = emit_serpent_wwin(&output, &tally, "ww1", "m.wwd").unwrap();
811        let w = Wwinp::parse(&out.text).unwrap();
812        assert_eq!(w.nf, [3, 2, 1]);
813        assert_eq!(w.nc, [3, 1, 1]);
814        assert_eq!(w.fm[0], vec![1.0, 1.0, 1.0]);
815        assert_eq!(w.cm[0], vec![1.0, 3.0, 4.0]);
816        assert_eq!(w.bounds[0], vec![0.0, 1.0, 3.0, 4.0]);
817        assert_eq!(w.ww[0][0].len(), 6);
818    }
819
820    #[test]
821    fn serpent_photon_uses_two_slot_header() {
822        let mut tally = sample_tally();
823        tally.particle = ParticleKind::Photon;
824        let out = emit_serpent_wwin(&sample_output(), &tally, "ww1", "m.wwd").unwrap();
825        let w = Wwinp::parse(&out.text).unwrap();
826        assert_eq!(w.ni, 2);
827        assert_eq!(w.ne, vec![0, 2]);
828        assert_eq!(w.ww.len(), 1);
829        assert_eq!(w.ww[0][0], vec![0.5, 0.125, 0.25, 0.0625]);
830        assert!(out.notes.iter().any(|n| n.starts_with("format-pinned")));
831    }
832
833    #[test]
834    fn serpent_total_mode_single_group() {
835        let mut output = sample_output();
836        output.groups_per_ve = 1;
837        output.lower_bounds_ww = vec![0.5, 0.25, 0.125, 0.0625];
838        output.e_upper_bounds = vec![1.0];
839        let out = emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd").unwrap();
840        let w = Wwinp::parse(&out.text).unwrap();
841        assert_eq!(w.ne, vec![1]);
842        assert_eq!(w.e, vec![vec![1.0]]);
843        assert_eq!(w.ww[0].len(), 1);
844    }
845
846    #[test]
847    fn magic_end_to_end_emission_recovers_fixture_windows() {
848        // The full pipeline on the real meshtal fixture: MAGIC per-group →
849        // both emitters → reader re-parse of the Serpent file and flat-order
850        // spot checks of the OpenMC fragment.
851        let m = nucleide_mcnp_io::meshtal::Meshtal::from_file(format!(
852            "{}/../../fixtures/mcnp/meshtal/mcnp_meshtal_single_meshtal.txt",
853            env!("CARGO_MANIFEST_DIR")
854        ))
855        .unwrap();
856        let t = &m.tallies[&4];
857        let output = crate::magic_with(
858            t,
859            crate::MagicSelection::PerGroup,
860            crate::MagicParams::default(),
861        )
862        .unwrap();
863
864        let serpent = emit_serpent_wwin(&output, t, "ww1", "mesh.wwd").unwrap();
865        let w = Wwinp::parse(&serpent.text).unwrap();
866        let dims = t.dims();
867        assert_eq!(dims, [3, 5, 3]);
868        assert_eq!(w.nf, [3, 5, 3]);
869        assert_eq!(w.nft, 45);
870        assert_eq!(w.ne, vec![3]);
871        assert_eq!(w.e[0], output.e_upper_bounds);
872        // Cell 22 (z-fastest ve) holds the max of every group; decompose
873        // ve 22 = (i*ny + j)*nz + k and recompose x-fastest.
874        let (i, j, k) = (
875            22 / (dims[1] * dims[2]),
876            (22 / dims[2]) % dims[1],
877            22 % dims[2],
878        );
879        let ve_w = (k * dims[1] + j) * dims[0] + i;
880        for g in 0..3 {
881            assert!((w.ww[0][g][ve_w] - 0.5).abs() < 1e-12);
882        }
883
884        let openmc = emit_openmc_weight_windows(&output, t, &OpenMcOptions::default()).unwrap();
885        let lower = floats_of("lower_ww_bounds", &openmc.xml);
886        assert_eq!(lower.len(), 45 * 3);
887        // Same cell through the OpenMC fragment (group-outermost flat list).
888        for g in 0..3 {
889            assert!((lower[g * 45 + ve_w] - 0.5).abs() < 1e-12);
890        }
891    }
892
893    #[test]
894    fn negative_window_is_rejected() {
895        let mut output = sample_output();
896        output.lower_bounds_ww[3] = -0.25;
897        assert!(matches!(
898            emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd"),
899            Err(Error::NegativeWindow {
900                index: 3,
901                value: -0.25
902            })
903        ));
904        assert!(matches!(
905            emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default()),
906            Err(Error::NegativeWindow {
907                index: 3,
908                value: -0.25
909            })
910        ));
911    }
912
913    #[test]
914    fn non_finite_window_is_rejected() {
915        let mut output = sample_output();
916        output.lower_bounds_ww[0] = f64::NAN;
917        assert!(matches!(
918            emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd"),
919            Err(Error::NonFiniteWindow { index: 0 })
920        ));
921    }
922
923    #[test]
924    fn bad_energy_bounds_are_rejected() {
925        let mut output = sample_output();
926        output.e_upper_bounds = vec![1.0, 0.5];
927        assert!(matches!(
928            emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd"),
929            Err(Error::BadEnergyBounds { index: 1 })
930        ));
931        output.e_upper_bounds = vec![0.0, 1.0];
932        assert!(matches!(
933            emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd"),
934            Err(Error::BadEnergyBounds { index: 0 })
935        ));
936        output.e_upper_bounds = vec![f64::INFINITY, 1.0];
937        assert!(matches!(
938            emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default()),
939            Err(Error::BadEnergyBounds { index: 0 })
940        ));
941    }
942
943    #[test]
944    fn bad_mesh_bounds_are_rejected() {
945        let mut tally = sample_tally();
946        tally.x_bounds = vec![2.0, 1.0];
947        assert!(matches!(
948            emit_serpent_wwin(&sample_output(), &tally, "ww1", "m.wwd"),
949            Err(Error::BadMeshBounds { axis: 0, index: 1 })
950        ));
951        tally.x_bounds = vec![0.0, f64::NAN];
952        assert!(matches!(
953            emit_serpent_wwin(&sample_output(), &tally, "ww1", "m.wwd"),
954            Err(Error::BadMeshBounds { axis: 0, index: 1 })
955        ));
956        tally.x_bounds = vec![0.0];
957        assert!(matches!(
958            emit_serpent_wwin(&sample_output(), &tally, "ww1", "m.wwd"),
959            Err(Error::BadMeshBounds { axis: 0, index: 0 })
960        ));
961    }
962
963    #[test]
964    fn length_mismatch_is_rejected() {
965        let mut output = sample_output();
966        output.lower_bounds_ww.pop();
967        assert!(matches!(
968            emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd"),
969            Err(Error::LengthMismatch {
970                expected: 8,
971                got: 7
972            })
973        ));
974    }
975
976    #[test]
977    fn null_cells_produce_drift_notes() {
978        let mut output = sample_output();
979        output.lower_bounds_ww[1] = 0.0;
980        output.lower_bounds_ww[5] = 0.0;
981        let serpent = emit_serpent_wwin(&output, &sample_tally(), "ww1", "m.wwd").unwrap();
982        let openmc =
983            emit_openmc_weight_windows(&output, &sample_tally(), &OpenMcOptions::default())
984                .unwrap();
985        let note = |notes: &[String]| {
986            notes
987                .iter()
988                .find(|n| n.starts_with("null-cells"))
989                .unwrap()
990                .clone()
991        };
992        assert!(note(&serpent.notes).contains("2 of 8"));
993        assert!(note(&openmc.notes).contains("2 of 8"));
994        // And the zeros must actually appear in the outputs.
995        assert!(serpent.text.contains("0.00000E+00"));
996        assert!(openmc.xml.contains(" 0.0 "));
997    }
998}