1use nucleide_mcnp_io::meshtal::{MeshTallyData, ParticleKind};
46use nucleide_mcnp_io::wwinp::Wwinp;
47
48use crate::{Error, MagicOutput, Result};
49
50#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct OpenMcOptions {
53 pub mesh_id: u32,
56 pub window_id: u32,
58 pub upper_bound_ratio: f64,
62 pub survival_ratio: f64,
65 pub max_split: u32,
67 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#[derive(Debug, Clone, PartialEq)]
87pub struct OpenMcWeightWindows {
88 pub xml: String,
90 pub notes: Vec<String>,
92}
93
94#[derive(Debug, Clone, PartialEq)]
96pub struct SerpentWwin {
97 pub text: String,
99 pub card: String,
101 pub notes: Vec<String>,
103}
104
105pub 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 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 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
208fn 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
227pub 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 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
318fn 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
401fn 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
427fn 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
436fn 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
475fn xml_f64(v: f64) -> String {
480 XmlF64(v).to_string()
481}
482
483struct 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 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 fn sample_output() -> MagicOutput {
540 MagicOutput {
541 lower_bounds_ww: vec![
542 0.5, 0.25, 0.25, 0.5, 0.125, 0.75, 0.0625, 1.0, ],
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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(serpent.text.contains("0.00000E+00"));
996 assert!(openmc.xml.contains(" 0.0 "));
997 }
998}