Skip to main content

nucleide_bindings/
lib.rs

1//! Python bindings (`nucleide._internal`).
2//!
3//! Thin facade only: all logic lives in workspace crates so the Rust API
4//! stays usable without Python. Type stubs live in `python/nucleide/_internal.pyi`.
5
6use std::collections::BTreeMap;
7use std::str::FromStr;
8
9use pyo3::exceptions::{PyTypeError, PyValueError};
10use pyo3::prelude::*;
11
12use numpy::{IntoPyArray, PyArray1, PyArray2, PyArrayMethods};
13
14use nucleide_nuclei::NuclideId;
15
16/// Package version, re-exported to Python.
17#[pyfunction]
18fn version() -> &'static str {
19    env!("CARGO_PKG_VERSION")
20}
21
22fn wrap_nucid_err(e: nucleide_nuclei::Error) -> PyErr {
23    PyValueError::new_err(e.to_string())
24}
25
26// ---------------------------------------------------------------------------
27// Nuclide naming
28// ---------------------------------------------------------------------------
29
30/// A nuclide identifier (canonical nucid integer + naming conversions).
31#[pyclass(name = "Nuclide")]
32struct PyNuclide {
33    inner: NuclideId,
34}
35
36#[pymethods]
37impl PyNuclide {
38    /// Create from a name such as "U235" or "Am242_m1".
39    #[new]
40    fn new(name: &str) -> PyResult<Self> {
41        NuclideId::from_name(name)
42            .map(|inner| Self { inner })
43            .map_err(wrap_nucid_err)
44    }
45
46    /// GNDS-style name ("U235", "Am242_m1").
47    #[getter]
48    fn name(&self) -> String {
49        self.inner.to_name()
50    }
51
52    /// Raw nucid integer.
53    #[getter]
54    fn nucid(&self) -> u32 {
55        self.inner.nucid()
56    }
57
58    /// ZZAAAM form (922350 for U-235).
59    #[getter]
60    fn zzaaam(&self) -> u32 {
61        self.inner.zzaaam()
62    }
63
64    /// Atomic number.
65    #[getter]
66    fn z(&self) -> u32 {
67        self.inner.z()
68    }
69
70    /// Mass number.
71    #[getter]
72    fn a(&self) -> u32 {
73        self.inner.a()
74    }
75
76    /// Metastable state index (0 = ground).
77    #[getter]
78    fn state(&self) -> u32 {
79        self.inner.state()
80    }
81
82    /// MCNP ZAID integer.
83    #[getter]
84    fn zaid(&self) -> u32 {
85        nucleide_nuclei::dialects::to_zaid(self.inner)
86    }
87
88    /// zzllaaam form ("U-235").
89    #[getter]
90    fn zzllaaam(&self) -> String {
91        nucleide_nuclei::dialects::zzllaaam(self.inner)
92    }
93
94    /// Serpent-style name ("U-235").
95    #[getter]
96    fn serpent(&self) -> String {
97        nucleide_nuclei::dialects::serpent(self.inner)
98    }
99
100    /// NIST-style name.
101    #[getter]
102    fn nist(&self) -> String {
103        nucleide_nuclei::dialects::nist(self.inner)
104    }
105
106    /// Cinder integer id.
107    #[getter]
108    fn cinder(&self) -> u32 {
109        nucleide_nuclei::dialects::to_cinder(self.inner)
110    }
111
112    /// ALARA name ("u:235").
113    #[getter]
114    fn alara(&self) -> String {
115        nucleide_nuclei::dialects::alara(self.inner)
116    }
117
118    /// SZA integer.
119    #[getter]
120    fn sza(&self) -> u32 {
121        nucleide_nuclei::dialects::to_sza(self.inner)
122    }
123
124    /// FLUKA element-isotope name; raises ValueError if unavailable.
125    fn fluka(&self) -> PyResult<&'static str> {
126        nucleide_nuclei::dialects::id_to_fluka(self.inner)
127            .map_err(|e| PyValueError::new_err(e.to_string()))
128    }
129
130    /// Atomic mass in u (AME2020), or None if unknown.
131    #[getter]
132    fn mass(&self) -> Option<f64> {
133        nucleide_nuclei::data::atomic_mass(self.inner.nucid())
134    }
135
136    /// Natural abundance fraction, or None.
137    #[getter]
138    fn abundance(&self) -> Option<f64> {
139        nucleide_nuclei::data::natural_abundance(self.inner.nucid())
140    }
141
142    fn __repr__(&self) -> String {
143        format!("Nuclide({})", self.inner.to_name())
144    }
145}
146
147/// Parse a MCNP ZAID integer into a Nuclide.
148#[pyfunction]
149fn from_zaid(zaid: u32) -> PyResult<PyNuclide> {
150    nucleide_nuclei::dialects::from_zaid(zaid)
151        .map(|inner| PyNuclide { inner })
152        .map_err(|e| PyValueError::new_err(e.to_string()))
153}
154
155fn lookup(key: &Bound<'_, PyAny>, f: impl Fn(u32) -> Option<f64>) -> PyResult<Option<f64>> {
156    if let Ok(nucid) = key.extract::<u32>() {
157        return Ok(f(nucid));
158    }
159    if let Ok(name) = key.extract::<&str>() {
160        let id = NuclideId::from_name(name).map_err(wrap_nucid_err)?;
161        return Ok(f(id.nucid()));
162    }
163    Err(PyTypeError::new_err("expected int nucid or str name"))
164}
165
166/// Atomic mass in u for a nucid integer or name string.
167#[pyfunction]
168fn atomic_mass(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
169    lookup(key, nucleide_nuclei::data::atomic_mass)
170}
171
172/// Natural abundance fraction for a nucid integer or name string.
173#[pyfunction]
174fn natural_abundance(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
175    lookup(key, nucleide_nuclei::data::natural_abundance)
176}
177
178/// A particle species with cross-code name translations.
179#[pyclass(name = "Particle")]
180struct PyParticle {
181    inner: nucleide_nuclei::particles::ParticleId,
182}
183
184#[pymethods]
185impl PyParticle {
186    /// Create from any alias ("n", "neutron", "gamma", PDC int, ...).
187    #[new]
188    fn new(spec: &Bound<'_, PyAny>) -> PyResult<Self> {
189        let inner = if let Ok(pdc) = spec.extract::<i32>() {
190            nucleide_nuclei::particles::ParticleId::from_pdc(pdc)
191                .ok_or_else(|| PyValueError::new_err(format!("unknown PDC code {pdc}")))?
192        } else if let Ok(s) = spec.extract::<&str>() {
193            s.parse::<nucleide_nuclei::particles::ParticleId>()
194                .map_err(|e| PyValueError::new_err(e.to_string()))?
195        } else {
196            return Err(PyTypeError::new_err("expected str alias or int PDC"));
197        };
198        Ok(Self { inner })
199    }
200
201    #[getter]
202    fn name(&self) -> &'static str {
203        self.inner.name()
204    }
205
206    #[getter]
207    fn describe(&self) -> &'static str {
208        self.inner.describe()
209    }
210
211    fn mcnp(&self) -> Option<&'static str> {
212        self.inner.mcnp()
213    }
214    fn mcnp6(&self) -> Option<&'static str> {
215        self.inner.mcnp6()
216    }
217    fn fluka(&self) -> Option<&'static str> {
218        self.inner.fluka()
219    }
220    fn geant4(&self) -> Option<&'static str> {
221        self.inner.geant4()
222    }
223
224    fn __repr__(&self) -> String {
225        format!("Particle('{}')", self.inner.name())
226    }
227}
228
229/// Resolve a reaction name/MT/id string to its numeric id.
230#[pyfunction]
231fn rxname_id(name: &str) -> PyResult<u32> {
232    nucleide_nuclei::rxname::name_to_id(name).map_err(|e| PyValueError::new_err(e.to_string()))
233}
234
235/// Canonical short name for a reaction id.
236#[pyfunction]
237fn rxname_name(id: u32) -> Option<&'static str> {
238    nucleide_nuclei::rxname::id_to_name(id)
239}
240
241/// ENDF MT number for a reaction id (0 if none registered).
242#[pyfunction]
243fn rxname_mt(id: u32) -> i32 {
244    nucleide_nuclei::rxname::id_to_mt(id)
245}
246
247// ---------------------------------------------------------------------------
248// MCNP file I/O
249// ---------------------------------------------------------------------------
250
251fn io_err(e: nucleide_mcnp_io::xsdir::Error) -> PyErr {
252    PyValueError::new_err(e.to_string())
253}
254fn m_err<T>(r: Result<T, impl std::fmt::Display>) -> PyResult<T> {
255    r.map_err(|e| PyValueError::new_err(e.to_string()))
256}
257
258/// One xsdir directory entry.
259#[pyclass(name = "XsdirTable")]
260struct PyXsdirTable {
261    inner: nucleide_mcnp_io::xsdir::XsdirTable,
262}
263
264#[pymethods]
265impl PyXsdirTable {
266    #[getter]
267    fn name(&self) -> &str {
268        &self.inner.name
269    }
270    #[getter]
271    fn awr(&self) -> f64 {
272        self.inner.awr
273    }
274    #[getter]
275    fn filename(&self) -> &str {
276        &self.inner.filename
277    }
278    #[getter]
279    fn filetype(&self) -> i64 {
280        self.inner.filetype
281    }
282    #[getter]
283    fn address(&self) -> i64 {
284        self.inner.address
285    }
286    #[getter]
287    fn tablelength(&self) -> i64 {
288        self.inner.tablelength
289    }
290    #[getter]
291    fn temperature(&self) -> Option<f64> {
292        self.inner.temperature
293    }
294    #[getter]
295    fn ptable(&self) -> bool {
296        self.inner.ptable
297    }
298    /// ZAID text before the '.'.
299    fn zaid(&self) -> &str {
300        self.inner.zaid()
301    }
302    /// Serpent directory-entry line.
303    fn to_serpent(&self, directory: &str) -> PyResult<String> {
304        m_err(self.inner.to_serpent(directory))
305    }
306    fn __repr__(&self) -> String {
307        format!("<XsdirTable: {}>", self.inner.name)
308    }
309}
310
311/// Parsed xsdir index file.
312#[pyclass(name = "Xsdir")]
313struct PyXsdir {
314    inner: nucleide_mcnp_io::xsdir::Xsdir,
315}
316
317#[pymethods]
318impl PyXsdir {
319    #[getter]
320    fn datapath(&self) -> Option<&str> {
321        self.inner.datapath.as_deref()
322    }
323    /// Atomic weight ratios keyed by zaid integer.
324    #[getter]
325    fn awr(&self) -> BTreeMap<u32, f64> {
326        self.inner.awr.clone()
327    }
328    /// Directory entries in file order.
329    #[getter]
330    fn tables(&self) -> Vec<PyXsdirTable> {
331        self.inner
332            .tables
333            .iter()
334            .map(|t| PyXsdirTable { inner: t.clone() })
335            .collect()
336    }
337    /// Tables whose name contains `name`.
338    fn find_table(&self, name: &str) -> Vec<PyXsdirTable> {
339        self.inner
340            .find_table(name)
341            .into_iter()
342            .map(|t| PyXsdirTable { inner: t.clone() })
343            .collect()
344    }
345    /// Distinct nuclides referenced by the entries.
346    fn nucs(&self) -> Vec<u32> {
347        self.inner.nucs().iter().map(|n| n.nucid()).collect()
348    }
349}
350
351/// Parse an MCNP xsdir file.
352#[pyfunction]
353fn read_xsdir(path: &str) -> PyResult<PyXsdir> {
354    nucleide_mcnp_io::xsdir::Xsdir::from_file(path)
355        .map(|inner| PyXsdir { inner })
356        .map_err(io_err)
357}
358
359/// One fmesh4 tally from a meshtal file.
360#[pyclass(name = "MeshTally")]
361struct PyMeshTally {
362    inner: nucleide_mcnp_io::meshtal::MeshTallyData,
363}
364
365#[pymethods]
366impl PyMeshTally {
367    #[getter]
368    fn tally_number(&self) -> u32 {
369        self.inner.tally_number
370    }
371    /// 'n', 'p', ...
372    #[getter]
373    fn particle(&self) -> char {
374        self.inner.particle.letter()
375    }
376    #[getter]
377    fn dose_response(&self) -> bool {
378        self.inner.dose_response
379    }
380    #[getter]
381    fn x_bounds(&self) -> Vec<f64> {
382        self.inner.x_bounds.clone()
383    }
384    #[getter]
385    fn y_bounds(&self) -> Vec<f64> {
386        self.inner.y_bounds.clone()
387    }
388    #[getter]
389    fn z_bounds(&self) -> Vec<f64> {
390        self.inner.z_bounds.clone()
391    }
392    #[getter]
393    fn e_bounds(&self) -> Vec<f64> {
394        self.inner.e_bounds.clone()
395    }
396    /// [nx, ny, nz] cell counts.
397    fn dims(&self) -> [usize; 3] {
398        self.inner.dims()
399    }
400    fn num_ves(&self) -> usize {
401        self.inner.num_ves()
402    }
403    fn num_e_groups(&self) -> usize {
404        self.inner.num_e_groups()
405    }
406    /// All-group results for cell (i,j,k): [result_per_group, error_per_group].
407    fn cell(&self, i: usize, j: usize, k: usize) -> (Vec<f64>, Vec<f64>) {
408        let (r, e) = self.inner.cell(i, j, k);
409        (r.to_vec(), e.to_vec())
410    }
411    /// Energy-integrated totals for cell (i,j,k).
412    fn cell_total(&self, i: usize, j: usize, k: usize) -> (f64, f64) {
413        self.inner.cell_total(i, j, k)
414    }
415    /// Full results array `[ve][group]`.
416    #[getter]
417    fn result(&self) -> Vec<Vec<f64>> {
418        self.inner.result.clone()
419    }
420    /// Full relative-error array `[ve][group]`.
421    #[getter]
422    fn rel_error(&self) -> Vec<Vec<f64>> {
423        self.inner.rel_error.clone()
424    }
425    /// Per-cell energy-integrated totals.
426    #[getter]
427    fn total_result(&self) -> Vec<f64> {
428        self.inner.total_result.clone()
429    }
430    /// Per-cell energy-integrated total relative errors.
431    #[getter]
432    fn total_rel_error(&self) -> Vec<f64> {
433        self.inner.total_rel_error.clone()
434    }
435    /// Full results + relative errors as nested lists (plain copy).
436    ///
437    /// See `result_array` for the zero-copy NumPy bridge over the same data;
438    /// use this when NumPy is unavailable.
439    fn to_list(&self) -> (Vec<Vec<f64>>, Vec<Vec<f64>>) {
440        (self.inner.result.clone(), self.inner.rel_error.clone())
441    }
442    /// Per-cell energy-integrated totals + errors as flat lists (plain copy).
443    fn totals_list(&self) -> (Vec<f64>, Vec<f64>) {
444        (
445            self.inner.total_result.clone(),
446            self.inner.total_rel_error.clone(),
447        )
448    }
449    /// Full results + relative errors as 2-D float64 NumPy arrays.
450    ///
451    /// Shape is `(ve, group)` with `ve = (i * ny + j) * nz + k` (x slowest,
452    /// z fastest, matching `cell(i, j, k)` and MCNP write order), C-order
453    /// (row-major) float64. Each array is owned, writable, and decoupled
454    /// from the tally: resizing fails (NumPy base semantics) and later
455    /// tally mutation is not reflected. Requires NumPy installed at runtime
456    /// (rust-numpy resolves the C-API at import; the wheel itself stays
457    /// dependency-free).
458    #[allow(clippy::type_complexity)]
459    fn result_array<'py>(
460        &self,
461        py: Python<'py>,
462    ) -> PyResult<(Bound<'py, PyArray2<f64>>, Bound<'py, PyArray2<f64>>)> {
463        let n_ve = self.inner.num_ves();
464        let n_g = self.inner.num_e_groups();
465        let flatten = |rows: &[Vec<f64>], name: &str| -> PyResult<Vec<f64>> {
466            if rows.len() != n_ve {
467                return Err(PyValueError::new_err(format!(
468                    "tally {name}: expected {n_ve} rows, found {}",
469                    rows.len()
470                )));
471            }
472            let mut flat = Vec::with_capacity(n_ve * n_g);
473            for (ve, row) in rows.iter().enumerate() {
474                if row.len() != n_g {
475                    return Err(PyValueError::new_err(format!(
476                        "tally {name}: row {ve} has {} groups, expected {n_g}",
477                        row.len()
478                    )));
479                }
480                flat.extend_from_slice(row);
481            }
482            Ok(flat)
483        };
484        let flat_r = flatten(&self.inner.result, "result")?;
485        let flat_e = flatten(&self.inner.rel_error, "rel_error")?;
486        let arr_r = m_err(
487            flat_r
488                .into_pyarray(py)
489                .reshape((n_ve, n_g))
490                .map_err(|e| e.to_string()),
491        )?;
492        let arr_e = m_err(
493            flat_e
494                .into_pyarray(py)
495                .reshape((n_ve, n_g))
496                .map_err(|e| e.to_string()),
497        )?;
498        Ok((arr_r, arr_e))
499    }
500    /// Per-cell energy-integrated totals + errors as 1-D float64 NumPy arrays.
501    ///
502    /// Shape is `(num_ves,)` in the same `ve = (i * ny + j) * nz + k` order
503    /// as `result_array`. Each array is owned, writable, and decoupled from
504    /// the tally. Requires NumPy installed at runtime.
505    #[allow(clippy::type_complexity)]
506    fn totals_array<'py>(
507        &self,
508        py: Python<'py>,
509    ) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
510        Ok((
511            self.inner.total_result.clone().into_pyarray(py),
512            self.inner.total_rel_error.clone().into_pyarray(py),
513        ))
514    }
515}
516
517/// Parsed meshtal file.
518#[pyclass(name = "Meshtal")]
519struct PyMeshtal {
520    inner: nucleide_mcnp_io::meshtal::Meshtal,
521}
522
523#[pymethods]
524impl PyMeshtal {
525    #[getter]
526    fn version(&self) -> &str {
527        &self.inner.version
528    }
529    #[getter]
530    fn ld(&self) -> &str {
531        &self.inner.ld
532    }
533    #[getter]
534    fn title(&self) -> &str {
535        &self.inner.title
536    }
537    #[getter]
538    fn histories(&self) -> u64 {
539        self.inner.histories
540    }
541    /// Tallies keyed by fmesh4 number.
542    #[getter]
543    fn tallies(&self) -> BTreeMap<u32, PyMeshTally> {
544        self.inner
545            .tallies
546            .iter()
547            .map(|(k, v)| (*k, PyMeshTally { inner: v.clone() }))
548            .collect()
549    }
550}
551
552/// Parse an MCNP meshtal file.
553#[pyfunction]
554fn read_meshtal(path: &str) -> PyResult<PyMeshtal> {
555    m_err(nucleide_mcnp_io::meshtal::Meshtal::from_file(path).map(|inner| PyMeshtal { inner }))
556}
557
558/// Parsed WWINP weight-window file.
559#[pyclass(name = "Wwinp")]
560struct PyWwinp {
561    inner: nucleide_mcnp_io::wwinp::Wwinp,
562}
563
564#[pymethods]
565impl PyWwinp {
566    #[getter]
567    fn ni(&self) -> u32 {
568        self.inner.ni
569    }
570    #[getter]
571    fn nr(&self) -> u32 {
572        self.inner.nr
573    }
574    #[getter]
575    fn ne(&self) -> Vec<u32> {
576        self.inner.ne.clone()
577    }
578    #[getter]
579    fn nf(&self) -> [u32; 3] {
580        self.inner.nf
581    }
582    #[getter]
583    fn origin(&self) -> [f64; 3] {
584        self.inner.origin
585    }
586    #[getter]
587    fn nc(&self) -> [u32; 3] {
588        self.inner.nc
589    }
590    /// Coarse boundaries per dimension.
591    #[getter]
592    fn cm(&self) -> Vec<Vec<f64>> {
593        self.inner.cm.clone()
594    }
595    /// Expanded spatial bounds per dimension.
596    #[getter]
597    fn bounds(&self) -> Vec<Vec<f64>> {
598        self.inner.bounds.clone()
599    }
600    /// Energy upper bounds per particle present.
601    #[getter]
602    fn e(&self) -> Vec<Vec<f64>> {
603        self.inner.e.clone()
604    }
605    /// Lower bounds for one group: ww_row(particle, group) -> list[nve].
606    fn ww_row(&self, particle: usize, group: usize) -> Vec<f64> {
607        self.inner.ww[particle][group].clone()
608    }
609    /// Lower-bound vector for one volume element across groups.
610    fn ww_column(&self, particle: usize, ve: usize) -> Vec<f64> {
611        self.inner.ww_column(particle, ve)
612    }
613    /// Lower bounds for one group as a 1-D float64 NumPy array.
614    ///
615    /// Shape is `(nft,)` with `nft = nf[0] * nf[1] * nf[2]` in file order
616    /// (z slowest → x fastest), C-order float64. The array is owned,
617    /// writable, and decoupled from the file data. Requires NumPy installed
618    /// at runtime. Raises `ValueError` on out-of-range particle/group.
619    /// See `ww_row` for the plain-copy list over the same data; use that
620    /// when NumPy is unavailable.
621    fn ww_row_array<'py>(
622        &self,
623        py: Python<'py>,
624        particle: usize,
625        group: usize,
626    ) -> PyResult<Bound<'py, PyArray1<f64>>> {
627        let row = self
628            .inner
629            .ww
630            .get(particle)
631            .and_then(|groups| groups.get(group))
632            .ok_or_else(|| {
633                PyValueError::new_err(format!(
634                    "ww_row_array: particle {particle} group {group} out of range"
635                ))
636            })?;
637        Ok(row.clone().into_pyarray(py))
638    }
639    /// Lower-bound vector for one volume element as a 1-D float64 NumPy array.
640    ///
641    /// Shape is `(n_groups,)` for the selected particle (one entry per
642    /// energy group at volume element `ve`). Owned, writable, decoupled;
643    /// requires NumPy at runtime. Raises `ValueError` on out-of-range
644    /// particle/ve. See `ww_column` for the plain-copy list.
645    fn ww_column_array<'py>(
646        &self,
647        py: Python<'py>,
648        particle: usize,
649        ve: usize,
650    ) -> PyResult<Bound<'py, PyArray1<f64>>> {
651        let groups = self.inner.ww.get(particle).ok_or_else(|| {
652            PyValueError::new_err(format!("ww_column_array: particle {particle} out of range"))
653        })?;
654        if groups.is_empty() {
655            return Err(PyValueError::new_err(format!(
656                "ww_column_array: particle {particle} has no groups"
657            )));
658        }
659        let nft = groups[0].len();
660        if ve >= nft {
661            return Err(PyValueError::new_err(format!(
662                "ww_column_array: ve {ve} out of range for {nft} volume elements"
663            )));
664        }
665        for (g, row) in groups.iter().enumerate() {
666            if row.len() != nft {
667                return Err(PyValueError::new_err(format!(
668                    "ww particle {particle}: group {g} has {} values, expected {nft}",
669                    row.len()
670                )));
671            }
672        }
673        let col: Vec<f64> = groups.iter().map(|row| row[ve]).collect();
674        Ok(col.into_pyarray(py))
675    }
676    /// All lower bounds for one particle as a 2-D float64 NumPy array.
677    ///
678    /// Shape is `(n_groups, nft)` with `nft = nf[0] * nf[1] * nf[2]`; row `g`
679    /// is the `ww_row(particle, g)` vector in file order (z slowest → x
680    /// fastest), C-order (row-major) float64. The array is owned, writable,
681    /// and decoupled from the file data. Requires NumPy installed at
682    /// runtime. Raises `ValueError` on out-of-range particle or on ragged
683    /// group rows (the parser guarantees rectangular data; this is
684    /// defensive). Particles have independent group counts, so each
685    /// particle gets its own array rather than one ragged 3-D stack.
686    fn ww_particle_array<'py>(
687        &self,
688        py: Python<'py>,
689        particle: usize,
690    ) -> PyResult<Bound<'py, PyArray2<f64>>> {
691        let groups = self.inner.ww.get(particle).ok_or_else(|| {
692            PyValueError::new_err(format!(
693                "ww_particle_array: particle {particle} out of range"
694            ))
695        })?;
696        if groups.is_empty() {
697            return Err(PyValueError::new_err(format!(
698                "ww_particle_array: particle {particle} has no groups"
699            )));
700        }
701        let nft = groups[0].len();
702        let mut flat = Vec::with_capacity(groups.len() * nft);
703        for (g, row) in groups.iter().enumerate() {
704            if row.len() != nft {
705                return Err(PyValueError::new_err(format!(
706                    "ww particle {particle}: group {g} has {} values, expected {nft}",
707                    row.len()
708                )));
709            }
710            flat.extend_from_slice(row);
711        }
712        let n_g = groups.len();
713        m_err(
714            flat.into_pyarray(py)
715                .reshape((n_g, nft))
716                .map_err(|e| e.to_string()),
717        )
718    }
719}
720
721/// Parse an MCNP WWINP weight-window file.
722#[pyfunction]
723fn read_wwinp(path: &str) -> PyResult<PyWwinp> {
724    m_err(nucleide_mcnp_io::wwinp::Wwinp::from_file(path).map(|inner| PyWwinp { inner }))
725}
726
727/// Parsed MCTAL kcode data.
728/// One MCTAL bin card as a plain dict (`count`, `values`, plus the
729/// verbatim `variant`/`flag` spellings, each `None` when absent).
730fn mctal_card_dict<'py>(
731    py: Python<'py>,
732    card: &nucleide_mcnp_io::mctal::BinCard,
733) -> PyResult<pyo3::Bound<'py, pyo3::types::PyDict>> {
734    let c = pyo3::types::PyDict::new(py);
735    c.set_item("count", card.count)?;
736    c.set_item("values", card.values.clone())?;
737    c.set_item("variant", card.variant.map(|v| v.to_string()))?;
738    c.set_item("flag", card.flag)?;
739    Ok(c)
740}
741
742#[pyclass(name = "Mctal")]
743struct PyMctal {
744    inner: nucleide_mcnp_io::mctal::Mctal,
745}
746
747#[pymethods]
748impl PyMctal {
749    #[getter]
750    fn code_name(&self) -> &str {
751        &self.inner.code_name
752    }
753    #[getter]
754    fn comment(&self) -> &str {
755        &self.inner.comment
756    }
757    #[getter]
758    fn n_histories(&self) -> u64 {
759        self.inner.n_histories
760    }
761    #[getter]
762    fn n_cycles(&self) -> usize {
763        self.inner.n_cycles
764    }
765    #[getter]
766    fn n_inactive(&self) -> usize {
767        self.inner.n_inactive
768    }
769    #[getter]
770    fn vars_per_cycle(&self) -> usize {
771        self.inner.vars_per_cycle
772    }
773    #[getter]
774    fn k_col(&self) -> Vec<f64> {
775        self.inner.k_col.clone()
776    }
777    #[getter]
778    fn k_abs(&self) -> Vec<f64> {
779        self.inner.k_abs.clone()
780    }
781    #[getter]
782    fn k_path(&self) -> Vec<f64> {
783        self.inner.k_path.clone()
784    }
785    #[getter]
786    fn prompt_life_col(&self) -> Vec<f64> {
787        self.inner.prompt_life_col.clone()
788    }
789    #[getter]
790    fn prompt_life_path(&self) -> Vec<f64> {
791        self.inner.prompt_life_path.clone()
792    }
793    /// Running averages (empty unless vars_per_cycle == 19); each entry is a
794    /// dict of the averaged pairs plus cycle_histories/fom.
795    #[getter]
796    fn averages(&self) -> Vec<BTreeMap<String, f64>> {
797        self.inner
798            .averages
799            .iter()
800            .map(|a| {
801                let mut m = BTreeMap::new();
802                m.insert("avg_k_col".into(), a.avg_k_col.0);
803                m.insert("avg_k_col_stdev".into(), a.avg_k_col.1);
804                m.insert("avg_k_abs".into(), a.avg_k_abs.0);
805                m.insert("avg_k_abs_stdev".into(), a.avg_k_abs.1);
806                m.insert("avg_k_path".into(), a.avg_k_path.0);
807                m.insert("avg_k_path_stdev".into(), a.avg_k_path.1);
808                m.insert("avg_k_combined".into(), a.avg_k_combined.0);
809                m.insert("avg_k_combined_stdev".into(), a.avg_k_combined.1);
810                m.insert("avg_k_combined_active".into(), a.avg_k_combined_active.0);
811                m.insert(
812                    "avg_k_combined_active_stdev".into(),
813                    a.avg_k_combined_active.1,
814                );
815                m.insert("prompt_life_combined".into(), a.prompt_life_combined.0);
816                m.insert(
817                    "prompt_life_combined_stdev".into(),
818                    a.prompt_life_combined.1,
819                );
820                m.insert("cycle_histories".into(), a.cycle_histories);
821                m.insert("fom".into(), a.fom);
822                m
823            })
824            .collect()
825    }
826    /// Per-cycle kcode series as 1-D float64 NumPy arrays.
827    ///
828    /// Returns `(k_col, k_abs, k_path, prompt_life_col, prompt_life_path)`,
829    /// each of shape `(n_cycles,)` in cycle order, C-order float64. Each
830    /// array is owned, writable, and decoupled from the file data (the
831    /// `Vec` is cloned then moved into the array; later mutation is not
832    /// reflected). Requires NumPy installed at runtime. See the `k_col`,
833    /// `k_abs`, `k_path`, `prompt_life_col`, `prompt_life_path` getters for
834    /// the plain-copy lists over the same data; use those when NumPy is
835    /// unavailable.
836    #[allow(clippy::type_complexity)]
837    fn k_arrays<'py>(
838        &self,
839        py: Python<'py>,
840    ) -> PyResult<(
841        Bound<'py, PyArray1<f64>>,
842        Bound<'py, PyArray1<f64>>,
843        Bound<'py, PyArray1<f64>>,
844        Bound<'py, PyArray1<f64>>,
845        Bound<'py, PyArray1<f64>>,
846    )> {
847        Ok((
848            self.inner.k_col.clone().into_pyarray(py),
849            self.inner.k_abs.clone().into_pyarray(py),
850            self.inner.k_path.clone().into_pyarray(py),
851            self.inner.prompt_life_col.clone().into_pyarray(py),
852            self.inner.prompt_life_path.clone().into_pyarray(py),
853        ))
854    }
855    /// Running averages as a 2-D float64 NumPy array.
856    ///
857    /// Shape is `(n_cycles, 14)` (empty `averages` yields `(0, 14)`) with
858    /// one row per cycle in cycle order, C-order float64. Columns are
859    /// `avg_k_col`, `avg_k_col_stdev`, `avg_k_abs`, `avg_k_abs_stdev`,
860    /// `avg_k_path`, `avg_k_path_stdev`, `avg_k_combined`,
861    /// `avg_k_combined_stdev`, `avg_k_combined_active`,
862    /// `avg_k_combined_active_stdev`, `prompt_life_combined`,
863    /// `prompt_life_combined_stdev`, `cycle_histories`, `fom` — the same
864    /// values as the `averages` dicts, in a fixed column order. The array
865    /// is owned, writable, and decoupled. Requires NumPy at runtime.
866    fn averages_array<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyArray2<f64>>> {
867        let n = self.inner.averages.len();
868        let mut flat = Vec::with_capacity(n * 14);
869        for a in &self.inner.averages {
870            flat.extend_from_slice(&[
871                a.avg_k_col.0,
872                a.avg_k_col.1,
873                a.avg_k_abs.0,
874                a.avg_k_abs.1,
875                a.avg_k_path.0,
876                a.avg_k_path.1,
877                a.avg_k_combined.0,
878                a.avg_k_combined.1,
879                a.avg_k_combined_active.0,
880                a.avg_k_combined_active.1,
881                a.prompt_life_combined.0,
882                a.prompt_life_combined.1,
883                a.cycle_histories,
884                a.fom,
885            ]);
886        }
887        m_err(
888            flat.into_pyarray(py)
889                .reshape((n, 14))
890                .map_err(|e| e.to_string()),
891        )
892    }
893    /// Optional third token of the `tally` line (perturbation count when
894    /// present; stored verbatim — perturbation bodies are named-open).
895    #[getter]
896    fn npert(&self) -> Option<String> {
897        self.inner.npert.clone()
898    }
899    /// Declared tally numbers from the header.
900    #[getter]
901    fn tally_nums(&self) -> Vec<u32> {
902        self.inner.tally_nums.clone()
903    }
904    /// Parsed standard-tally bodies in file order (empty for legacy
905    /// kcode-only files). Each entry is a dict with `number`,
906    /// `particle_type`, `detector_type` (or None), `particle_list`,
907    /// `comment` (FC lines), one `{count, values, variant, flag}` dict per
908    /// bin card (`f`, `d`, `u`, `s`, `m`, `c`, `e`, `t`; `variant`/`flag`
909    /// are the stored-verbatim total/cumulative spelling and third-token
910    /// flag, each `None` when absent), `vals` (list of `(value, rel_error)`
911    /// pairs in file order), `tfc` (the tally-fluctuation-chart
912    /// `{jtf, rows}` dict, or `None`), and `total` (sum of values).
913    #[getter]
914    fn tallies(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
915        use pyo3::types::PyDict;
916        let mut out = Vec::with_capacity(self.inner.tallies.len());
917        for t in &self.inner.tallies {
918            let d = PyDict::new(py);
919            d.set_item("number", t.number)?;
920            d.set_item("particle_type", t.particle_type)?;
921            d.set_item("detector_type", t.detector_type)?;
922            d.set_item("particle_list", t.particle_list.clone())?;
923            d.set_item("comment", t.comment.clone())?;
924            for (key, card) in [
925                ("f", &t.f),
926                ("d", &t.d),
927                ("u", &t.u),
928                ("s", &t.s),
929                ("m", &t.m),
930                ("c", &t.c),
931                ("e", &t.e),
932                ("t", &t.t),
933            ] {
934                d.set_item(key, mctal_card_dict(py, card)?)?;
935            }
936            let vals: Vec<(f64, f64)> = t.vals.clone();
937            d.set_item("vals", vals)?;
938            let tfc_obj = if let Some(tfc) = &t.tfc {
939                let td = PyDict::new(py);
940                td.set_item("jtf", tfc.jtf.clone())?;
941                let mut rows = Vec::with_capacity(tfc.rows.len());
942                for r in &tfc.rows {
943                    let rd = PyDict::new(py);
944                    rd.set_item("nps", r.nps)?;
945                    rd.set_item("value", r.value)?;
946                    rd.set_item("rel_err", r.rel_err)?;
947                    rd.set_item("fom", r.fom)?;
948                    rows.push(rd.into_any().unbind());
949                }
950                td.set_item("rows", rows)?;
951                td.into_any().unbind()
952            } else {
953                py.None()
954            };
955            d.set_item("tfc", tfc_obj)?;
956            d.set_item("total", t.total_val())?;
957            out.push(d.into_any().unbind());
958        }
959        Ok(out)
960    }
961    /// Parsed mesh-tally bodies (`detector_type <= -1`) in file order.
962    /// Each entry mirrors a `tallies` dict plus `mesh_unknown`, the
963    /// `ni`/`nj`/`nk` mesh counts, `dims`, `num_cells`, and the
964    /// `cora`/`corb`/`corc` bound vectors (`ni+1`/`nj+1`/`nk+1` values).
965    /// Mesh tallies carry no `tfc` block.
966    #[getter]
967    fn mesh_tallies(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
968        use pyo3::types::PyDict;
969        let mut out = Vec::with_capacity(self.inner.mesh_tallies.len());
970        for t in &self.inner.mesh_tallies {
971            let d = PyDict::new(py);
972            d.set_item("number", t.number)?;
973            d.set_item("particle_type", t.particle_type)?;
974            d.set_item("detector_type", t.detector_type)?;
975            d.set_item("particle_list", t.particle_list.clone())?;
976            d.set_item("comment", t.comment.clone())?;
977            d.set_item("mesh_unknown", t.mesh_unknown)?;
978            d.set_item("ni", t.ni)?;
979            d.set_item("nj", t.nj)?;
980            d.set_item("nk", t.nk)?;
981            d.set_item("dims", t.dims().to_vec())?;
982            d.set_item("num_cells", t.num_cells())?;
983            d.set_item("cora", t.cora.clone())?;
984            d.set_item("corb", t.corb.clone())?;
985            d.set_item("corc", t.corc.clone())?;
986            for (key, card) in [
987                ("d", &t.d),
988                ("u", &t.u),
989                ("s", &t.s),
990                ("m", &t.m),
991                ("c", &t.c),
992                ("e", &t.e),
993                ("t", &t.t),
994            ] {
995                d.set_item(key, mctal_card_dict(py, card)?)?;
996            }
997            let vals: Vec<(f64, f64)> = t.vals.clone();
998            d.set_item("vals", vals)?;
999            d.set_item("total", t.total_val())?;
1000            out.push(d.into_any().unbind());
1001        }
1002        Ok(out)
1003    }
1004    /// Tally `vals` as a 2-D float64 NumPy array.
1005    ///
1006    /// Shape is `(n_pairs, 2)` with one `(value, rel_error)` row per pair
1007    /// in file order, C-order float64. Tallies without bodies yield
1008    /// `(0, 2)`. The array is owned, writable, and decoupled. Requires
1009    /// NumPy at runtime. See the `tallies` dicts for the plain-copy lists
1010    /// over the same data; use those when NumPy is unavailable.
1011    fn tally_vals_array<'py>(
1012        &self,
1013        py: Python<'py>,
1014        number: u32,
1015    ) -> PyResult<Bound<'py, PyArray2<f64>>> {
1016        let tally = self
1017            .inner
1018            .tallies
1019            .iter()
1020            .find(|t| t.number == number)
1021            .ok_or_else(|| {
1022                PyValueError::new_err(format!("mctal has no parsed body for tally {number}"))
1023            })?;
1024        let mut flat = Vec::with_capacity(tally.vals.len() * 2);
1025        for (v, e) in &tally.vals {
1026            flat.push(*v);
1027            flat.push(*e);
1028        }
1029        let n = tally.vals.len();
1030        m_err(
1031            flat.into_pyarray(py)
1032                .reshape((n, 2))
1033                .map_err(|e| e.to_string()),
1034        )
1035    }
1036    /// Mesh tally `vals` as a 2-D float64 NumPy array.
1037    ///
1038    /// Same `(n_pairs, 2)` `(value, rel_error)` layout as
1039    /// `tally_vals_array`, over the `mesh_tallies` bodies in mesh-cell
1040    /// order (`i` fastest). Unknown tally numbers raise `ValueError`.
1041    fn mesh_tally_vals_array<'py>(
1042        &self,
1043        py: Python<'py>,
1044        number: u32,
1045    ) -> PyResult<Bound<'py, PyArray2<f64>>> {
1046        let tally = self
1047            .inner
1048            .mesh_tallies
1049            .iter()
1050            .find(|t| t.number == number)
1051            .ok_or_else(|| {
1052                PyValueError::new_err(format!("mctal has no parsed mesh body for tally {number}"))
1053            })?;
1054        let mut flat = Vec::with_capacity(tally.vals.len() * 2);
1055        for (v, e) in &tally.vals {
1056            flat.push(*v);
1057            flat.push(*e);
1058        }
1059        let n = tally.vals.len();
1060        m_err(
1061            flat.into_pyarray(py)
1062                .reshape((n, 2))
1063                .map_err(|e| e.to_string()),
1064        )
1065    }
1066}
1067
1068/// Parse an MCNP MCTAL file (headers, standard + mesh tally bodies with
1069/// optional `tfc` blocks, and kcode).
1070#[pyfunction]
1071fn read_mctal(path: &str) -> PyResult<PyMctal> {
1072    m_err(nucleide_mcnp_io::mctal::Mctal::from_file(path).map(|inner| PyMctal { inner }))
1073}
1074
1075/// Parsed SSW surface-source file.
1076#[pyclass(name = "SurfSrc")]
1077struct PySurfSrc {
1078    inner: nucleide_mcnp_io::surfsrc::SurfSrc,
1079}
1080
1081#[pymethods]
1082impl PySurfSrc {
1083    #[getter]
1084    fn kod(&self) -> String {
1085        self.inner.header.kod.trim_end().to_string()
1086    }
1087    #[getter]
1088    fn ver(&self) -> String {
1089        self.inner.header.ver.trim_end().to_string()
1090    }
1091    #[getter]
1092    fn np1(&self) -> i64 {
1093        self.inner.header.np1
1094    }
1095    /// Signed stored `np1` (negative ⇒ the file carries table 2).
1096    #[getter]
1097    fn orignp1(&self) -> i64 {
1098        self.inner.header.orignp1
1099    }
1100    #[getter]
1101    fn nrss(&self) -> i64 {
1102        self.inner.header.nrss
1103    }
1104    #[getter]
1105    fn ncrd(&self) -> i32 {
1106        self.inner.header.ncrd
1107    }
1108    #[getter]
1109    fn njsw(&self) -> i32 {
1110        self.inner.header.njsw
1111    }
1112    #[getter]
1113    fn niss(&self) -> i64 {
1114        self.inner.header.niss
1115    }
1116    /// Formatted header block.
1117    fn print_header(&self) -> String {
1118        self.inner.header.print_header()
1119    }
1120    /// Track records as dicts of named fields.
1121    fn tracks(&self) -> PyResult<Vec<BTreeMap<String, f64>>> {
1122        let tracks = self
1123            .inner
1124            .read_tracklist()
1125            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1126        Ok(tracks
1127            .iter()
1128            .map(|t| {
1129                let mut d = BTreeMap::new();
1130                d.insert("nps".into(), t.nps);
1131                d.insert("bitarray".into(), t.bitarray);
1132                d.insert("wgt".into(), t.wgt);
1133                d.insert("erg".into(), t.erg);
1134                d.insert("tme".into(), t.tme);
1135                d.insert("x".into(), t.x);
1136                d.insert("y".into(), t.y);
1137                d.insert("z".into(), t.z);
1138                d.insert("u".into(), t.u);
1139                d.insert("v".into(), t.v);
1140                d.insert("cs".into(), t.cs);
1141                d.insert("w".into(), t.w);
1142                d
1143            })
1144            .collect())
1145    }
1146}
1147
1148/// Read an MCNP SSW surface-source file (header eagerly; tracks on demand).
1149#[pyfunction]
1150fn read_ssw(path: &str) -> PyResult<PySurfSrc> {
1151    nucleide_mcnp_io::surfsrc::SurfSrc::open(path)
1152        .map(|inner| PySurfSrc { inner })
1153        .map_err(|e| PyValueError::new_err(e.to_string()))
1154}
1155
1156/// Detected PTRAC layout: 0 = i4 little-endian, 1 = i8 little-endian.
1157#[pyclass(name = "PtracFile")]
1158struct PyPtracFile {
1159    inner: nucleide_mcnp_io::ptrac::PtracFile,
1160}
1161
1162#[pymethods]
1163impl PyPtracFile {
1164    #[getter]
1165    fn problem_title(&self) -> &str {
1166        &self.inner.problem_title
1167    }
1168    /// 0 for i4, 1 for i8.
1169    #[getter]
1170    fn width_code(&self) -> u8 {
1171        match self.inner.format {
1172            nucleide_mcnp_io::ptrac::Format::I4LittleEndian => 0,
1173            nucleide_mcnp_io::ptrac::Format::I8LittleEndian => 1,
1174        }
1175    }
1176    /// Variable counts per event type as {nps,src,bnk,sur,col,ter}.
1177    #[getter]
1178    fn variable_nums(&self) -> BTreeMap<String, usize> {
1179        let v = &self.inner.variable_nums;
1180        let mut m = BTreeMap::new();
1181        m.insert("nps".into(), v.nps);
1182        m.insert("src".into(), v.src);
1183        m.insert("bnk".into(), v.bnk);
1184        m.insert("sur".into(), v.sur);
1185        m.insert("col".into(), v.col);
1186        m.insert("ter".into(), v.ter);
1187        m
1188    }
1189    /// All events as dicts: {'event_type': int, '<var>': float, ...}.
1190    fn events(&self) -> PyResult<Vec<BTreeMap<String, f64>>> {
1191        let events = self
1192            .inner
1193            .events()
1194            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1195        Ok(events
1196            .iter()
1197            .map(|ev| {
1198                let mut d = BTreeMap::new();
1199                d.insert("event_type".to_string(), ev.event_type as f64);
1200                for (n, v) in ev.iter() {
1201                    d.insert(n.to_string(), v);
1202                }
1203                d
1204            })
1205            .collect())
1206    }
1207    /// All events as a 2-D float64 NumPy array.
1208    ///
1209    /// Shape is `(n_events, 19)` in file order, C-order float64. Columns
1210    /// follow `nucleide.mcnp.ptrac_event_columns()` (`event_type` plus the
1211    /// 18 `PtracEvent`-order data columns `node` … `tme`); variables absent
1212    /// from the file's variable list read as 0.0, matching
1213    /// `ptrac_event_rows`. The array is owned, writable, and decoupled from
1214    /// the file data. Requires NumPy installed at runtime. See `events`
1215    /// for the plain-copy dicts over the same data; use those when NumPy
1216    /// is unavailable.
1217    fn events_array<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyArray2<f64>>> {
1218        let events = self
1219            .inner
1220            .events()
1221            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1222        let n = events.len();
1223        let mut flat = Vec::with_capacity(n * PTRAC_EVENT_COLUMNS.len());
1224        for ev in &events {
1225            flat.push(ev.event_type as f64);
1226            for col in &PTRAC_EVENT_COLUMNS[1..] {
1227                flat.push(ev.get(col).unwrap_or(0.0));
1228            }
1229        }
1230        m_err(
1231            flat.into_pyarray(py)
1232                .reshape((n, PTRAC_EVENT_COLUMNS.len()))
1233                .map_err(|e| e.to_string()),
1234        )
1235    }
1236    /// One event-table column as a 1-D float64 NumPy array.
1237    ///
1238    /// `field` is one of `ptrac_event_columns()` (`event_type` or any of
1239    /// the 18 data columns). Shape is `(n_events,)` in file order; absent
1240    /// variables read as 0.0, matching `ptrac_event_rows`. Owned, writable,
1241    /// decoupled; requires NumPy at runtime. Raises `ValueError` for an
1242    /// unknown field name.
1243    fn event_field_array<'py>(
1244        &self,
1245        py: Python<'py>,
1246        field: &str,
1247    ) -> PyResult<Bound<'py, PyArray1<f64>>> {
1248        if !PTRAC_EVENT_COLUMNS.contains(&field) {
1249            return Err(PyValueError::new_err(format!(
1250                "unknown PTRAC field `{field}` (expected one of {})",
1251                PTRAC_EVENT_COLUMNS.join(", ")
1252            )));
1253        }
1254        let events = self
1255            .inner
1256            .events()
1257            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1258        let col: Vec<f64> = events
1259            .iter()
1260            .map(|ev| {
1261                if field == "event_type" {
1262                    ev.event_type as f64
1263                } else {
1264                    ev.get(field).unwrap_or(0.0)
1265                }
1266            })
1267            .collect();
1268        Ok(col.into_pyarray(py))
1269    }
1270}
1271
1272/// PTRAC event-table columns in `pyne.mcnp.PtracEvent` order: `event_type`
1273/// plus the 18 mapped data columns (`node` … `tme`).
1274///
1275/// Mirrors `nucleide.mcnp.ptrac_event_columns()`; `events_array` columns
1276/// follow this order.
1277const PTRAC_EVENT_COLUMNS: [&str; 19] = [
1278    "event_type",
1279    "node",
1280    "nsr",
1281    "nsf",
1282    "nxs",
1283    "ntyn",
1284    "ipt",
1285    "ncl",
1286    "mat",
1287    "ncp",
1288    "xxx",
1289    "yyy",
1290    "zzz",
1291    "uuu",
1292    "vvv",
1293    "www",
1294    "erg",
1295    "wgt",
1296    "tme",
1297];
1298
1299/// Read an MCNP PTRAC event file.
1300#[pyfunction]
1301fn read_ptrac(path: &str) -> PyResult<PyPtracFile> {
1302    nucleide_mcnp_io::ptrac::PtracFile::open(path)
1303        .map(|inner| PyPtracFile { inner })
1304        .map_err(|e| PyValueError::new_err(e.to_string()))
1305}
1306
1307/// One MCPL particle record (kinetic energy in MeV, position in cm, time
1308/// in ms; see `nucleide-mcpl-io`).
1309fn mcpl_particle_to_dict(py: Python<'_>, p: &nucleide_mcpl_io::Particle) -> PyResult<Py<PyAny>> {
1310    use pyo3::types::PyDict;
1311    let d = PyDict::new(py);
1312    d.set_item("ekin", p.ekin)?;
1313    d.set_item("polarisation", p.polarisation.to_vec())?;
1314    d.set_item("position", p.position.to_vec())?;
1315    d.set_item("direction", p.direction.to_vec())?;
1316    d.set_item("time", p.time)?;
1317    d.set_item("weight", p.weight)?;
1318    d.set_item("pdgcode", p.pdgcode)?;
1319    d.set_item("userflags", p.userflags)?;
1320    Ok(d.into_any().unbind())
1321}
1322
1323fn mcpl_particle_from_dict(d: &Bound<'_, PyAny>) -> PyResult<nucleide_mcpl_io::Particle> {
1324    let get_f64 = |key: &str| -> PyResult<f64> {
1325        d.get_item(key)
1326            .map_err(|e| PyValueError::new_err(format!("particle missing `{key}`: {e}")))?
1327            .extract()
1328            .map_err(|_| PyValueError::new_err(format!("particle `{key}` must be a float")))
1329    };
1330    let get_vec3 = |key: &str| -> PyResult<[f64; 3]> {
1331        let v: Vec<f64> = d
1332            .get_item(key)
1333            .map_err(|e| PyValueError::new_err(format!("particle missing `{key}`: {e}")))?
1334            .extract()
1335            .map_err(|_| PyValueError::new_err(format!("particle `{key}` must be a 3-list")))?;
1336        if v.len() != 3 {
1337            return Err(PyValueError::new_err(format!(
1338                "particle `{key}` must have exactly 3 entries"
1339            )));
1340        }
1341        Ok([v[0], v[1], v[2]])
1342    };
1343    let pdgcode: i32 = d
1344        .get_item("pdgcode")
1345        .map_err(|e| PyValueError::new_err(format!("particle missing `pdgcode`: {e}")))?
1346        .extract()
1347        .map_err(|_| PyValueError::new_err("particle `pdgcode` must be an int"))?;
1348    let userflags: u32 = d
1349        .get_item("userflags")
1350        .map_err(|e| PyValueError::new_err(format!("particle missing `userflags`: {e}")))?
1351        .extract()
1352        .map_err(|_| PyValueError::new_err("particle `userflags` must be an int"))?;
1353    Ok(nucleide_mcpl_io::Particle {
1354        ekin: get_f64("ekin")?,
1355        polarisation: get_vec3("polarisation")?,
1356        position: get_vec3("position")?,
1357        direction: get_vec3("direction")?,
1358        time: get_f64("time")?,
1359        weight: get_f64("weight")?,
1360        pdgcode,
1361        userflags,
1362    })
1363}
1364
1365/// Parsed MCPL particle-list file (header eagerly; particles on demand).
1366#[pyclass(name = "McplFile")]
1367struct PyMcplFile {
1368    inner: nucleide_mcpl_io::McplFile,
1369}
1370
1371#[pymethods]
1372impl PyMcplFile {
1373    /// Format version (2 or 3 on read; writers always emit 3).
1374    #[getter]
1375    fn version(&self) -> u16 {
1376        self.inner.header.version
1377    }
1378    /// Stored particle count.
1379    #[getter]
1380    fn nparticles(&self) -> u64 {
1381        self.inner.header.nparticles
1382    }
1383    /// Source name from the header.
1384    #[getter]
1385    fn srcname(&self) -> &str {
1386        &self.inner.header.srcname
1387    }
1388    /// Header comment strings (round-tripped verbatim, never interpreted).
1389    #[getter]
1390    fn comments(&self) -> Vec<String> {
1391        self.inner.header.comments.clone()
1392    }
1393    /// Whether per-particle user flags are stored.
1394    #[getter]
1395    fn has_userflags(&self) -> bool {
1396        self.inner.header.has_userflags
1397    }
1398    /// Whether per-particle polarisation vectors are stored.
1399    #[getter]
1400    fn has_polarisation(&self) -> bool {
1401        self.inner.header.has_polarisation
1402    }
1403    /// `true` = double precision, `false` = single precision.
1404    #[getter]
1405    fn double_prec(&self) -> bool {
1406        self.inner.header.double_prec
1407    }
1408    /// File-wide PDG code when set (`None` = per-particle codes).
1409    #[getter]
1410    fn universal_pdgcode(&self) -> Option<i32> {
1411        self.inner.header.universal_pdgcode
1412    }
1413    /// File-wide weight when set (`None` = per-particle weights).
1414    #[getter]
1415    fn universal_weight(&self) -> Option<f64> {
1416        self.inner.header.universal_weight
1417    }
1418    /// Header blobs as `(key, bytes)` pairs.
1419    #[getter]
1420    fn blobs(&self) -> Vec<(String, Vec<u8>)> {
1421        self.inner
1422            .header
1423            .blobs
1424            .iter()
1425            .map(|b| (b.key.clone(), b.data.clone()))
1426            .collect()
1427    }
1428    /// All particle records as dicts (`ekin`, `polarisation`, `position`,
1429    /// `direction`, `time`, `weight`, `pdgcode`, `userflags`).
1430    fn particles(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
1431        let ps = self
1432            .inner
1433            .particles()
1434            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1435        ps.iter().map(|p| mcpl_particle_to_dict(py, p)).collect()
1436    }
1437}
1438
1439/// Read an MCPL particle-list file (`.gz` reads through gzip transparently).
1440#[pyfunction]
1441fn read_mcpl(path: &str) -> PyResult<PyMcplFile> {
1442    nucleide_mcpl_io::McplFile::open(path)
1443        .map(|inner| PyMcplFile { inner })
1444        .map_err(|e| PyValueError::new_err(e.to_string()))
1445}
1446
1447/// Write an MCPL particle-list file from a header dict and particle dicts.
1448///
1449/// `header` keys: `srcname` (str), `comments` (list of str),
1450/// `has_userflags`/`has_polarisation`/`double_prec` (bool),
1451/// `universal_pdgcode` (int or None), `universal_weight` (float or None),
1452/// `blobs` (list of `(key, bytes)` pairs). `particles` holds one dict per
1453/// record with the same keys as `McplFile.particles()`. A `.gz` suffix
1454/// compresses through gzip transparently. Thin wrapper over
1455/// `nucleide-mcpl-io`.
1456#[pyfunction]
1457fn write_mcpl(
1458    path: &str,
1459    header: &Bound<'_, PyAny>,
1460    particles: Vec<Bound<'_, PyAny>>,
1461) -> PyResult<()> {
1462    use nucleide_mcpl_io::{Blob, Header};
1463    let get = |key: &str| header.get_item(key);
1464    let srcname: String = get("srcname")
1465        .map_err(|_| PyValueError::new_err("header missing `srcname`"))?
1466        .extract()
1467        .map_err(|_| PyValueError::new_err("header `srcname` must be a str"))?;
1468    let comments: Vec<String> = get("comments")
1469        .map_err(|_| PyValueError::new_err("header missing `comments`"))?
1470        .extract()
1471        .map_err(|_| PyValueError::new_err("header `comments` must be a list of str"))?;
1472    let flag = |key: &str| -> PyResult<bool> {
1473        get(key)
1474            .map_err(|_| PyValueError::new_err(format!("header missing `{key}`")))?
1475            .extract()
1476            .map_err(|_| PyValueError::new_err(format!("header `{key}` must be a bool")))
1477    };
1478    let universal_pdgcode: Option<i32> = get("universal_pdgcode")
1479        .map_err(|_| PyValueError::new_err("header missing `universal_pdgcode`"))?
1480        .extract()
1481        .map_err(|_| PyValueError::new_err("header `universal_pdgcode` must be an int or None"))?;
1482    let universal_weight: Option<f64> = get("universal_weight")
1483        .map_err(|_| PyValueError::new_err("header missing `universal_weight`"))?
1484        .extract()
1485        .map_err(|_| PyValueError::new_err("header `universal_weight` must be a float or None"))?;
1486    let blob_pairs: Vec<(String, Vec<u8>)> = get("blobs")
1487        .map_err(|_| PyValueError::new_err("header missing `blobs`"))?
1488        .extract()
1489        .map_err(|_| {
1490            PyValueError::new_err("header `blobs` must be a list of (key, bytes) pairs")
1491        })?;
1492    let h = Header {
1493        has_userflags: flag("has_userflags")?,
1494        has_polarisation: flag("has_polarisation")?,
1495        double_prec: flag("double_prec")?,
1496        universal_pdgcode,
1497        universal_weight,
1498        srcname,
1499        comments,
1500        blobs: blob_pairs
1501            .into_iter()
1502            .map(|(key, data)| Blob { key, data })
1503            .collect(),
1504        ..Header::default()
1505    };
1506    let ps: Vec<nucleide_mcpl_io::Particle> = particles
1507        .iter()
1508        .map(mcpl_particle_from_dict)
1509        .collect::<PyResult<_>>()?;
1510    nucleide_mcpl_io::write_to_path(path, &h, &ps).map_err(|e| PyValueError::new_err(e.to_string()))
1511}
1512
1513/// Convert an SSW surface-source file to an MCPL particle-list file
1514/// (SSW-PDG table; see `nucleide-mcpl-io` `ssw`).
1515///
1516/// The SSW format stores no per-track surface id or particle kind, so every
1517/// track needs an explicit caller parameter: `surfs[i]`/`kinds[i]` pair with
1518/// track `i` (`kinds` holds `"neutron"`/`"gamma"`/`"electron"`/`"positron"`/
1519/// `"proton"`). `options` (dict or None) holds
1520/// `double_prec`/`surf_to_userflags`/`gzip`/`universal_pdg`/
1521/// `universal_weight` (bool), `polarisation` (3-list or None),
1522/// `srcname` (str), `comments` (list of str), and `deck_blob`
1523/// (`(key, bytes)` pair or None); absent keys take the crate defaults.
1524/// Output is gzip-compressed when `options["gzip"]` is set or `mcpl_path`
1525/// ends in `.gz`. Returns the particle count. Thin wrapper over
1526/// `nucleide-mcpl-io`.
1527#[pyfunction]
1528#[pyo3(signature = (ssw_path, mcpl_path, surfs, kinds, options=None))]
1529fn ssw2mcpl(
1530    ssw_path: &str,
1531    mcpl_path: &str,
1532    surfs: Vec<u32>,
1533    kinds: Vec<String>,
1534    options: Option<Bound<'_, PyAny>>,
1535) -> PyResult<u64> {
1536    use nucleide_mcnp_io::surfsrc::SurfSrc;
1537    use nucleide_mcpl_io::ssw::{SswParticleKind, SswTrack};
1538    let ssw = SurfSrc::open(ssw_path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1539    let raw = ssw
1540        .read_tracklist()
1541        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1542    if raw.len() != surfs.len() || raw.len() != kinds.len() {
1543        return Err(PyValueError::new_err(format!(
1544            "ssw2mcpl: SSW holds {} tracks but got {} surfs and {} kinds \
1545             (one surf+kind per track required)",
1546            raw.len(),
1547            surfs.len(),
1548            kinds.len()
1549        )));
1550    }
1551    let mut tracks = Vec::with_capacity(raw.len());
1552    for (i, (t, surf, kind)) in raw
1553        .iter()
1554        .zip(surfs)
1555        .zip(kinds.iter())
1556        .map(|((t, s), k)| (t, s, k))
1557        .enumerate()
1558    {
1559        let kind = SswParticleKind::parse(kind).ok_or_else(|| {
1560            PyValueError::new_err(format!(
1561                "track {i} kind `{kind}` unknown (expected one of \
1562                 \"neutron\", \"gamma\", \"electron\", \"positron\", \"proton\")"
1563            ))
1564        })?;
1565        tracks.push(SswTrack {
1566            ekin: t.erg,
1567            time_shakes: t.tme,
1568            position: [t.x, t.y, t.z],
1569            direction: [t.u, t.v, t.cs],
1570            weight: t.wgt,
1571            surf,
1572            kind,
1573        });
1574    }
1575    let mut opts = parse_ssw2mcpl_options(options.as_ref())?;
1576    if mcpl_path.ends_with(".gz") {
1577        opts.gzip = true;
1578    }
1579    let bytes = nucleide_mcpl_io::ssw::ssw2mcpl_bytes(&tracks, &opts)
1580        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1581    std::fs::write(mcpl_path, bytes).map_err(|e| PyValueError::new_err(e.to_string()))?;
1582    Ok(tracks.len() as u64)
1583}
1584
1585/// Parse the `ssw2mcpl` options dict (None = crate defaults).
1586fn parse_ssw2mcpl_options(
1587    options: Option<&Bound<'_, PyAny>>,
1588) -> PyResult<nucleide_mcpl_io::ssw::Ssw2McplOptions> {
1589    use nucleide_mcpl_io::ssw::{DeckBlob, Ssw2McplOptions};
1590    let mut opts = Ssw2McplOptions::default();
1591    let Some(d) = options else {
1592        return Ok(opts);
1593    };
1594    if !d.is_instance_of::<pyo3::types::PyDict>() {
1595        return Err(PyValueError::new_err("options must be a dict or None"));
1596    }
1597    let flag = |key: &str| -> PyResult<Option<bool>> {
1598        match d.get_item(key) {
1599            Ok(v) => v
1600                .extract()
1601                .map(Some)
1602                .map_err(|_| PyValueError::new_err(format!("options `{key}` must be a bool"))),
1603            Err(_) => Ok(None),
1604        }
1605    };
1606    if let Some(v) = flag("double_prec")? {
1607        opts.double_prec = v;
1608    }
1609    if let Some(v) = flag("surf_to_userflags")? {
1610        opts.surf_to_userflags = v;
1611    }
1612    if let Some(v) = flag("gzip")? {
1613        opts.gzip = v;
1614    }
1615    if let Some(v) = flag("universal_pdg")? {
1616        opts.universal_pdg = v;
1617    }
1618    if let Some(v) = flag("universal_weight")? {
1619        opts.universal_weight = v;
1620    }
1621    if let Ok(v) = d.get_item("polarisation") {
1622        if v.is_none() {
1623            opts.polarisation = None;
1624        } else {
1625            let vec: Vec<f64> = v.extract().map_err(|_| {
1626                PyValueError::new_err("options `polarisation` must be a 3-list or None")
1627            })?;
1628            if vec.len() != 3 {
1629                return Err(PyValueError::new_err(
1630                    "options `polarisation` must have exactly 3 entries",
1631                ));
1632            }
1633            opts.polarisation = Some([vec[0], vec[1], vec[2]]);
1634        }
1635    }
1636    if let Ok(v) = d.get_item("srcname") {
1637        opts.srcname = v
1638            .extract()
1639            .map_err(|_| PyValueError::new_err("options `srcname` must be a str"))?;
1640    }
1641    if let Ok(v) = d.get_item("comments") {
1642        opts.comments = v
1643            .extract()
1644            .map_err(|_| PyValueError::new_err("options `comments` must be a list of str"))?;
1645    }
1646    if let Ok(v) = d.get_item("deck_blob") {
1647        if !v.is_none() {
1648            let (key, data): (String, Vec<u8>) = v.extract().map_err(|_| {
1649                PyValueError::new_err("options `deck_blob` must be a (key, bytes) pair or None")
1650            })?;
1651            opts.deck_blob = Some(DeckBlob { key, data });
1652        }
1653    }
1654    Ok(opts)
1655}
1656
1657/// Convert an MCPL particle-list file back to an SSW surface-source file
1658/// (SSW-PDG table; see `nucleide-mcpl-io` `ssw`).
1659///
1660/// The output header clones `reference_ssw_path` (code/version/deck
1661/// passthrough) with `nrss`/`np1`/`orignp1` patched to the particle count and
1662/// `niss` passed through unless `niss` stamps an explicit value. Surface ids
1663/// come from each particle's `userflags`; pass `surface` to stamp one id on
1664/// every track instead (either way `[1, 999999]` is enforced). PDG codes
1665/// outside the SSW-PDG table (2112/22/11/-11/2212) are errors. Pass
1666/// `force_cs_to_one=True` to reproduce the upstream 2.2.8 `cs = 1.0`
1667/// spelling (default keeps the true cosine); pass `allow_polarisation=True`
1668/// to drop non-zero input polarisation (default rejects it). Returns the
1669/// track count. Thin wrapper over `nucleide-mcpl-io`.
1670#[pyfunction]
1671#[pyo3(signature = (mcpl_path, reference_ssw_path, ssw_out_path, surface=None, force_cs_to_one=false, niss=None, allow_polarisation=false))]
1672fn mcpl2ssw(
1673    mcpl_path: &str,
1674    reference_ssw_path: &str,
1675    ssw_out_path: &str,
1676    surface: Option<u32>,
1677    force_cs_to_one: bool,
1678    niss: Option<i64>,
1679    allow_polarisation: bool,
1680) -> PyResult<u64> {
1681    use nucleide_mcnp_io::surfsrc::SurfSrc;
1682    use nucleide_mcpl_io::ssw::Mcpl2SswOptions;
1683    let mcpl = nucleide_mcpl_io::McplFile::open(mcpl_path)
1684        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1685    let particles = mcpl
1686        .particles()
1687        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1688    let reference =
1689        SurfSrc::open(reference_ssw_path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1690    let (header, tracks) = nucleide_mcpl_io::ssw::mcpl2ssw(
1691        &particles,
1692        &reference.header,
1693        &Mcpl2SswOptions {
1694            surface,
1695            force_cs_to_one,
1696            niss_override: niss,
1697            allow_polarisation,
1698        },
1699    )
1700    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1701    nucleide_mcnp_io::surfsrc::write_to_path(ssw_out_path, &header, &tracks)
1702        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1703    Ok(tracks.len() as u64)
1704}
1705
1706/// Parsed ENDL evaluation file (EEDL/EPDL scope).
1707#[pyclass(name = "EndlLibrary")]
1708struct PyEndlLibrary {
1709    inner: nucleide_mcnp_io::endl::Library,
1710}
1711
1712#[pymethods]
1713impl PyEndlLibrary {
1714    /// Distinct nucleus ids in file order.
1715    fn nuclides(&self) -> Vec<i64> {
1716        self.inner.nuclides()
1717    }
1718    /// Reaction data for one selector set.
1719    ///
1720    /// `nuc` is an integer nucleus id (e.g. `820000000` for natural Pb) or a
1721    /// fully-specified isotope name (`"Pb208"`); bare element names do not
1722    /// resolve. `x1`/`p_out` filter by subshell/outgoing particle when given.
1723    /// Returns rows of `fields_for_rprop(rprop)` floats.
1724    #[pyo3(signature = (nuc, p_in, rdesc, rprop, x1=None, p_out=None))]
1725    fn get_rx(
1726        &self,
1727        nuc: &Bound<'_, PyAny>,
1728        p_in: i32,
1729        rdesc: i32,
1730        rprop: i32,
1731        x1: Option<i32>,
1732        p_out: Option<i32>,
1733    ) -> PyResult<Vec<Vec<f64>>> {
1734        let id = if let Ok(n) = nuc.extract::<i64>() {
1735            n
1736        } else if let Ok(name) = nuc.extract::<&str>() {
1737            NuclideId::from_name(name).map_err(wrap_nucid_err)?.nucid() as i64
1738        } else {
1739            return Err(PyTypeError::new_err("expected int nucleus id or str name"));
1740        };
1741        self.inner
1742            .get_rx(id, p_in, rdesc, rprop, x1, p_out)
1743            .map(|rows| rows.to_vec())
1744            .map_err(|e| PyValueError::new_err(e.to_string()))
1745    }
1746}
1747
1748/// Read an ENDL evaluation file (EEDL/EPDL scope).
1749#[pyfunction]
1750fn read_endl(path: &str) -> PyResult<PyEndlLibrary> {
1751    nucleide_mcnp_io::endl::Library::open(path)
1752        .map(|inner| PyEndlLibrary { inner })
1753        .map_err(|e| PyValueError::new_err(e.to_string()))
1754}
1755
1756/// Convert one 11-character ENDL number field to float.
1757#[pyfunction]
1758fn endl_endftod(field: &str) -> f64 {
1759    nucleide_mcnp_io::endl::endftod(field)
1760}
1761
1762/// Combine several SSW surface-source files into one (`ssw_combine.py` port).
1763///
1764/// Headers must agree on kod/ver/loddat, particle type, surface counts and
1765/// per-surface records; the output header carries the signed `orignp1` sum
1766/// and the plain `nrss` sum, with later files' track `nps` shifted
1767/// sign-preservingly. Raises `ValueError` on incompatible inputs (upstream
1768/// returns `False`).
1769#[pyfunction]
1770fn combine_ssw_files(output: &str, inputs: Vec<String>) -> PyResult<()> {
1771    nucleide_mcnp_io::surfsrc::combine_files(&inputs, output)
1772        .map_err(|e| PyValueError::new_err(e.to_string()))
1773}
1774
1775// ---------------------------------------------------------------------------
1776// Depletion / CRAM
1777// ---------------------------------------------------------------------------
1778
1779/// A parsed depletion chain (XML format).
1780#[pyclass(name = "Chain")]
1781struct PyChain {
1782    inner: std::sync::Arc<nucleide_depletion::Chain>,
1783}
1784
1785#[pymethods]
1786impl PyChain {
1787    /// Nuclide names in chain order.
1788    #[getter]
1789    fn nuclides(&self) -> Vec<String> {
1790        self.inner.nuclides.iter().map(|n| n.name.clone()).collect()
1791    }
1792
1793    fn index_of(&self, name: &str) -> Option<usize> {
1794        self.inner.index_of(name)
1795    }
1796}
1797
1798/// Parse a depletion-chain XML file.
1799#[pyfunction]
1800fn read_chain(path: &str) -> PyResult<PyChain> {
1801    nucleide_depletion::Chain::from_file(path)
1802        .map(|inner| PyChain {
1803            inner: std::sync::Arc::new(inner),
1804        })
1805        .map_err(|e| PyValueError::new_err(e.to_string()))
1806}
1807
1808/// One-group reaction rates keyed by "NuclideName:reaction".
1809type RateMap = BTreeMap<String, f64>;
1810
1811/// Pre-built depletion system for repeated CRAM solves.
1812#[pyclass(name = "DepletionSystem")]
1813struct PyDepletionSystem {
1814    inner: std::sync::Arc<nucleide_depletion::DepletionSystem>,
1815}
1816
1817#[pymethods]
1818impl PyDepletionSystem {
1819    /// Solve one depletion step with the pre-built system.
1820    ///
1821    /// `order` selects the CRAM order (16 or 48); `method` selects the
1822    /// solver kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
1823    /// default `"cram48"`). An explicitly non-default `method` overrides
1824    /// `order`; the default `method` defers to `order` for backwards
1825    /// compatibility. `Bateman` arms fall back to CRAM-48 on non-decay
1826    /// systems.
1827    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
1828    fn solve(
1829        &self,
1830        n0: BTreeMap<String, f64>,
1831        dt: f64,
1832        order: u8,
1833        method: &str,
1834    ) -> PyResult<BTreeMap<String, f64>> {
1835        let method = resolve_method(order, method)?;
1836        nucleide_depletion::deplete_with_method(&self.inner, method, &n0, dt)
1837            .map(|r| r.atoms)
1838            .map_err(|e| PyValueError::new_err(e.to_string()))
1839    }
1840
1841    /// Solve one depletion step using pre-built index vectors.
1842    ///
1843    /// `n0` and the returned vector are in chain index order; this avoids the
1844    /// name-to-index mapping overhead of `solve()` for tight timing loops.
1845    /// `method` behaves as in [`PyDepletionSystem::solve`].
1846    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
1847    fn solve_vec(&self, n0: Vec<f64>, dt: f64, order: u8, method: &str) -> PyResult<Vec<f64>> {
1848        let method = resolve_method(order, method)?;
1849        nucleide_depletion::solve_with_method(&self.inner, method, &n0, dt)
1850            .map_err(|e| PyValueError::new_err(e.to_string()))
1851    }
1852}
1853
1854/// Build a reusable depletion system from a chain and reaction rates.
1855#[pyfunction]
1856fn build_depletion_system(chain: &PyChain, rates: RateMap) -> PyResult<PyDepletionSystem> {
1857    let rs = split_rates(&rates, &chain.inner)?;
1858    nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rs)
1859        .map(|sys| PyDepletionSystem {
1860            inner: std::sync::Arc::new(sys),
1861        })
1862        .map_err(|e| PyValueError::new_err(e.to_string()))
1863}
1864
1865fn parse_order(order: u8) -> PyResult<nucleide_depletion::Order> {
1866    match order {
1867        16 => Ok(nucleide_depletion::Order::Order16),
1868        48 => Ok(nucleide_depletion::Order::Order48),
1869        other => Err(PyValueError::new_err(format!(
1870            "unsupported CRAM order {other} (supported: 16, 48)"
1871        ))),
1872    }
1873}
1874
1875/// Parse a solver `method=` spelling (`"cram16"`, `"cram48"`, `"bateman"`,
1876/// `"bateman_hp"`; case-insensitive, `-`/`_` interchangeable).
1877fn parse_method(name: &str) -> PyResult<nucleide_depletion::Method> {
1878    name.parse().map_err(|e: String| PyValueError::new_err(e))
1879}
1880
1881/// Resolve the legacy `order` (16|48) plus `method=` into a core [`Method`].
1882///
1883/// An explicitly non-default `method` wins; the default `"cram48"` defers to
1884/// `order` so existing `order=16` calls keep working unchanged.
1885fn resolve_method(order: u8, method: &str) -> PyResult<nucleide_depletion::Method> {
1886    let parsed = parse_method(method)?;
1887    if parsed == nucleide_depletion::Method::default_cram() {
1888        parse_order(order).map(nucleide_depletion::Method::Cram)
1889    } else {
1890        Ok(parsed)
1891    }
1892}
1893
1894fn split_rates(
1895    rates: &RateMap,
1896    chain: &nucleide_depletion::Chain,
1897) -> PyResult<nucleide_depletion::ReactionRates> {
1898    let mut out = nucleide_depletion::ReactionRates::new();
1899    for (key, v) in rates {
1900        let (nuc, rx) = key.split_once(':').ok_or_else(|| {
1901            PyValueError::new_err(format!("rate key `{key}` must be `Name:reaction`"))
1902        })?;
1903        let idx = chain
1904            .index_of(nuc)
1905            .ok_or_else(|| PyValueError::new_err(format!("rate for unknown nuclide `{nuc}`")))?;
1906        out.entry(idx).or_default().insert(rx.to_string(), *v);
1907    }
1908    Ok(out)
1909}
1910
1911/// Solve one depletion step with IPF CRAM or the analytic Bateman fast path.
1912///
1913/// `n0` maps nuclide names to initial atom counts; `rates` maps
1914/// `"Name:(n,gamma)"`-style keys to one-group rates [1/s]; `dt` is the step
1915/// length in seconds; `order` is 16 or 48; `method` selects the solver
1916/// kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`, default
1917/// `"cram48"` — an explicitly non-default `method` overrides `order`).
1918/// `Bateman` arms fall back to CRAM-48 on non-decay systems (rates on,
1919/// cyclic topology, near-degenerate half-lives).
1920#[pyfunction]
1921#[pyo3(signature = (chain, n0, dt, rates=None, order=48, method="cram48"))]
1922fn deplete(
1923    chain: &PyChain,
1924    n0: BTreeMap<String, f64>,
1925    dt: f64,
1926    rates: Option<RateMap>,
1927    order: u8,
1928    method: &str,
1929) -> PyResult<BTreeMap<String, f64>> {
1930    let method = resolve_method(order, method)?;
1931    let rates = split_rates(rates.as_ref().unwrap_or(&BTreeMap::new()), &chain.inner)?;
1932    let sys = nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rates)
1933        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1934    nucleide_depletion::deplete_with_method(&sys, method, &n0, dt)
1935        .map(|r| r.atoms)
1936        .map_err(|e| PyValueError::new_err(e.to_string()))
1937}
1938
1939// ---------------------------------------------------------------------------
1940// Serpent / FLUKA / variance reduction + writers
1941// ---------------------------------------------------------------------------
1942
1943/// Parse a Serpent .m output file ("res", "dep", or "det") into a plain
1944/// Python dict keyed by variable name. Scalars become floats/strings, vectors
1945/// become 1-D lists, and matrices become 2-D lists of row lists (one row per
1946/// Serpent block). A matrix holding non-numeric values raises `ValueError`.
1947#[pyfunction]
1948fn read_serpent(path: &str, kind: &str) -> PyResult<Py<PyAny>> {
1949    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1950    let table = match kind {
1951        "res" => nucleide_serpent_io::parse_res(&text),
1952        "dep" => nucleide_serpent_io::parse_dep(&text),
1953        "det" => nucleide_serpent_io::parse_det(&text),
1954        other => {
1955            return Err(PyValueError::new_err(format!(
1956                "kind must be res|dep|det, got `{other}`"
1957            )))
1958        }
1959    }
1960    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1961    fn entry_to_py(py: Python<'_>, e: &nucleide_serpent_io::Entry) -> PyResult<Py<PyAny>> {
1962        use nucleide_serpent_io::Entry as E;
1963        let value = match e {
1964            E::Scalar(nucleide_serpent_io::Value::Num(n)) => {
1965                n.into_pyobject(py).unwrap().unbind().into_any()
1966            }
1967            E::Scalar(nucleide_serpent_io::Value::Str(s)) => {
1968                s.into_pyobject(py).unwrap().unbind().into_any()
1969            }
1970            E::Vector(vs) => vs
1971                .iter()
1972                .map(|v| match v {
1973                    nucleide_serpent_io::Value::Num(n) => {
1974                        n.into_pyobject(py).unwrap().unbind().into_any()
1975                    }
1976                    nucleide_serpent_io::Value::Str(s) => {
1977                        s.into_pyobject(py).unwrap().unbind().into_any()
1978                    }
1979                })
1980                .collect::<Vec<_>>()
1981                .into_pyobject(py)
1982                .unwrap()
1983                .unbind()
1984                .into_any(),
1985            E::Matrix(m) => m
1986                .to_rows_f64()
1987                .map_err(|err| PyValueError::new_err(err.to_string()))?
1988                .into_pyobject(py)
1989                .unwrap()
1990                .unbind()
1991                .into_any(),
1992        };
1993        Ok(value)
1994    }
1995    Python::attach(|py| {
1996        let dict = pyo3::types::PyDict::new(py);
1997        for (k, e) in table.iter() {
1998            dict.set_item(k, entry_to_py(py, e)?)?;
1999        }
2000        Ok(dict.into_any().unbind())
2001    })
2002}
2003
2004/// One FLUKA USRBIN detector.
2005#[pyclass(name = "UsrbinTally")]
2006struct PyUsrbinTally {
2007    inner: nucleide_fluka_io::usrbin::UsrbinTally,
2008}
2009
2010#[pymethods]
2011impl PyUsrbinTally {
2012    #[getter]
2013    fn name(&self) -> &str {
2014        &self.inner.name
2015    }
2016    #[getter]
2017    fn particle(&self) -> &str {
2018        &self.inner.particle
2019    }
2020    #[getter]
2021    fn nx(&self) -> usize {
2022        self.inner.x_info.bins
2023    }
2024    #[getter]
2025    fn ny(&self) -> usize {
2026        self.inner.y_info.bins
2027    }
2028    #[getter]
2029    fn nz(&self) -> usize {
2030        self.inner.z_info.bins
2031    }
2032    #[getter]
2033    fn x_bounds(&self) -> Vec<f64> {
2034        self.inner.x_bounds.clone()
2035    }
2036    #[getter]
2037    fn y_bounds(&self) -> Vec<f64> {
2038        self.inner.y_bounds.clone()
2039    }
2040    #[getter]
2041    fn z_bounds(&self) -> Vec<f64> {
2042        self.inner.z_bounds.clone()
2043    }
2044    /// Scored values, x slowest -> z fastest.
2045    #[getter]
2046    fn data(&self) -> Vec<f64> {
2047        self.inner.part_data.clone()
2048    }
2049    /// Statistical errors, same layout as `data`.
2050    #[getter]
2051    fn error(&self) -> Vec<f64> {
2052        self.inner.error_data.clone()
2053    }
2054    fn dims(&self) -> [usize; 3] {
2055        [self.nx(), self.ny(), self.nz()]
2056    }
2057}
2058
2059/// Parse all USRBIN tallies from a FLUKA .lis file.
2060#[pyfunction]
2061fn read_usrbin(path: &str) -> PyResult<Vec<PyUsrbinTally>> {
2062    let tallies = nucleide_fluka_io::usrbin::read_usrbin_file(path)
2063        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2064    Ok(tallies
2065        .into_iter()
2066        .map(|inner| PyUsrbinTally { inner })
2067        .collect())
2068}
2069
2070/// MAGIC weight-window output.
2071#[pyclass(name = "MagicOutput")]
2072struct PyMagicOutput {
2073    inner: nucleide_vr_tools::magic::MagicOutput,
2074}
2075
2076#[pymethods]
2077impl PyMagicOutput {
2078    /// Flat lower bounds ([ve] in total mode, [ve*g+g] per-group).
2079    #[getter]
2080    fn lower_bounds_ww(&self) -> Vec<f64> {
2081        self.inner.lower_bounds_ww.clone()
2082    }
2083    #[getter]
2084    fn groups_per_ve(&self) -> usize {
2085        self.inner.groups_per_ve
2086    }
2087    #[getter]
2088    fn scale_factors(&self) -> Vec<f64> {
2089        self.inner.scale_factors.clone()
2090    }
2091    #[getter]
2092    fn e_upper_bounds(&self) -> Vec<f64> {
2093        self.inner.e_upper_bounds.clone()
2094    }
2095    #[getter]
2096    fn ww_tag_name(&self) -> &str {
2097        &self.inner.ww_tag_name
2098    }
2099}
2100
2101/// Generate MAGIC weight-window lower bounds from a meshtal tally.
2102#[pyfunction]
2103#[pyo3(signature = (tally, per_group=false, tolerance=0.5))]
2104fn magic(tally: &PyMeshTally, per_group: bool, tolerance: f64) -> PyResult<PyMagicOutput> {
2105    let selection = if per_group {
2106        nucleide_vr_tools::magic::MagicSelection::PerGroup
2107    } else {
2108        nucleide_vr_tools::magic::MagicSelection::Total
2109    };
2110    let params = nucleide_vr_tools::magic::MagicParams {
2111        tolerance,
2112        ..Default::default()
2113    };
2114    nucleide_vr_tools::magic::magic_with(&tally.inner, selection, params)
2115        .map(|inner| PyMagicOutput { inner })
2116        .map_err(|e| PyValueError::new_err(e.to_string()))
2117}
2118
2119/// Walker alias table for discrete sampling.
2120#[pyclass(name = "AliasTable")]
2121struct PyAliasTable {
2122    inner: nucleide_vr_tools::sampling::AliasTable,
2123}
2124
2125#[pymethods]
2126impl PyAliasTable {
2127    /// Build from a probability density (normalized internally).
2128    #[new]
2129    fn new(pdf: Vec<f64>) -> PyResult<Self> {
2130        nucleide_vr_tools::sampling::AliasTable::new(&pdf)
2131            .map(|inner| PyAliasTable { inner })
2132            .map_err(|e| PyValueError::new_err(e.to_string()))
2133    }
2134    /// Sample an index from two uniform random numbers.
2135    fn sample(&self, r1: f64, r2: f64) -> usize {
2136        self.inner.sample(r1, r2)
2137    }
2138    #[getter]
2139    fn pdf(&self) -> Vec<f64> {
2140        self.inner.pdf().to_vec()
2141    }
2142    fn __len__(&self) -> usize {
2143        self.inner.len()
2144    }
2145}
2146
2147/// Mesh source sampler over a meshtal tally (ANALOG/UNIFORM/USER modes).
2148#[pyclass(name = "MeshSourceSampler")]
2149struct PyMeshSourceSampler {
2150    inner: nucleide_vr_tools::sampling::MeshSourceSampler,
2151}
2152
2153#[pymethods]
2154impl PyMeshSourceSampler {
2155    /// mode: "analog" | "uniform" | "user" (user requires user_pdf).
2156    #[new]
2157    #[pyo3(signature = (tally, mode, user_pdf=None))]
2158    fn new(tally: &PyMeshTally, mode: &str, user_pdf: Option<Vec<f64>>) -> PyResult<Self> {
2159        let user = if matches!(mode, "user") {
2160            Some(user_pdf.ok_or_else(|| PyValueError::new_err("user mode needs user_pdf"))?)
2161        } else {
2162            None
2163        };
2164        let m = match mode {
2165            "analog" => nucleide_vr_tools::sampling::Mode::Analog,
2166            "uniform" => nucleide_vr_tools::sampling::Mode::Uniform,
2167            "user" => nucleide_vr_tools::sampling::Mode::User,
2168            other => {
2169                return Err(PyValueError::new_err(format!(
2170                    "mode must be analog|uniform|user, got `{other}`"
2171                )))
2172            }
2173        };
2174        nucleide_vr_tools::sampling::MeshSourceSampler::new(&tally.inner, m, user.as_deref())
2175            .map(|inner| PyMeshSourceSampler { inner })
2176            .map_err(|e| PyValueError::new_err(e.to_string()))
2177    }
2178    /// Sample a voxel; returns dict(index, i, j, k, weight).
2179    fn sample(&self, r1: f64, r2: f64) -> BTreeMap<String, f64> {
2180        let s = self.inner.sample(r1, r2);
2181        let mut d = BTreeMap::new();
2182        d.insert("index".into(), s.index as f64);
2183        d.insert("i".into(), s.i as f64);
2184        d.insert("j".into(), s.j as f64);
2185        d.insert("k".into(), s.k as f64);
2186        d.insert("weight".into(), s.weight);
2187        d
2188    }
2189    /// The bias mode this sampler was constructed with.
2190    fn mode(&self) -> &'static str {
2191        match self.inner.mode() {
2192            nucleide_vr_tools::sampling::Mode::Analog => "analog",
2193            nucleide_vr_tools::sampling::Mode::Uniform => "uniform",
2194            nucleide_vr_tools::sampling::Mode::User => "user",
2195        }
2196    }
2197    /// Number of voxels in the sampling domain.
2198    fn num_voxels(&self) -> usize {
2199        self.inner.num_voxels()
2200    }
2201    /// Length of the underlying alias table (one entry per voxel).
2202    fn table_len(&self) -> usize {
2203        self.inner.table().len()
2204    }
2205}
2206
2207/// Write a SurfSrc file back to disk. `tracks` defaults to re-reading the
2208/// original file's tracks.
2209#[pyfunction]
2210#[pyo3(signature = (ssw, path, tracks=None))]
2211fn write_ssw(
2212    ssw: &PySurfSrc,
2213    path: &str,
2214    tracks: Option<Vec<BTreeMap<String, f64>>>,
2215) -> PyResult<()> {
2216    let header = ssw.inner.header.clone();
2217    let track_data: Vec<nucleide_mcnp_io::surfsrc::TrackData> = match tracks {
2218        Some(dict_tracks) => dict_tracks
2219            .iter()
2220            .map(|d| {
2221                let g = |k: &str| d.get(k).copied().unwrap_or(0.0);
2222                let mut record = vec![0.0f64; nucleide_mcnp_io::surfsrc::TrackData::RECORD_WIDTH];
2223                record[0] = g("nps");
2224                record[1] = g("bitarray");
2225                record[2] = g("wgt");
2226                record[3] = g("erg");
2227                record[4] = g("tme");
2228                record[5] = g("x");
2229                record[6] = g("y");
2230                record[7] = g("z");
2231                record[8] = g("u");
2232                record[9] = g("v");
2233                record[10] = g("cs");
2234                nucleide_mcnp_io::surfsrc::TrackData::from_record(record)
2235            })
2236            .collect(),
2237        None => ssw
2238            .inner
2239            .read_tracklist()
2240            .map_err(|e| PyValueError::new_err(e.to_string()))?,
2241    };
2242    let mut f = std::fs::File::create(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
2243    nucleide_mcnp_io::surfsrc::write_to(&mut f, &header, &track_data)
2244        .map_err(|e| PyValueError::new_err(e.to_string()))
2245}
2246
2247/// Generate MCNP input-deck text from a structured mesh.
2248#[pyfunction]
2249fn mesh_to_geom(
2250    x_bounds: Vec<f64>,
2251    y_bounds: Vec<f64>,
2252    z_bounds: Vec<f64>,
2253    cell_materials: Vec<Option<(String, f64)>>,
2254    title_card: &str,
2255) -> String {
2256    let opts = nucleide_mcnp_io::deck::DeckOptions {
2257        title_card: title_card.to_string(),
2258        frac_type: nucleide_mcnp_io::deck::FracType::Mass,
2259    };
2260    nucleide_mcnp_io::deck::mesh_to_geom(&x_bounds, &y_bounds, &z_bounds, &cell_materials, &opts)
2261}
2262
2263// ---------------------------------------------------------------------------
2264// ALARA I/O (thin glue over `alara-io`; solver stays out of scope)
2265// ---------------------------------------------------------------------------
2266
2267/// Parse an ALARA input deck into plain Python containers.
2268///
2269/// Returns a dict with `block_kinds` (list[str] in file order), `geometry`
2270/// (str | None), `mixtures` (list of {name, entries}), `fluxes` (list of
2271/// {name, file, scale, skip, format}), `cooling_times_s` (list[float]),
2272/// `schedules`, `pulse_histories`, `outputs`, and `truncation`.
2273#[pyfunction]
2274fn alara_parse_deck(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
2275    let owned = text.to_owned();
2276    let deck = py
2277        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
2278        .map_err(ala_err)?;
2279    Ok(deck_to_py(py, &deck))
2280}
2281
2282fn ala_err(e: nucleide_alara_io::Error) -> PyErr {
2283    PyValueError::new_err(e.to_string())
2284}
2285
2286fn deck_to_py(py: Python<'_>, deck: &nucleide_alara_io::AlaraDeck) -> Py<PyAny> {
2287    use pyo3::types::PyDict;
2288    let out = PyDict::new(py);
2289    let block_kinds: Vec<&str> = deck.block_kinds();
2290    out.set_item("block_kinds", block_kinds).ok();
2291    out.set_item("geometry", deck.geometry.as_ref().map(|g| g.kind.clone()))
2292        .ok();
2293    let mixtures: Vec<Py<PyAny>> = deck.mixtures.iter().map(|m| mixture_to_py(py, m)).collect();
2294    out.set_item("mixtures", mixtures).ok();
2295    let fluxes: Vec<Py<PyAny>> = deck.fluxes.iter().map(|f| fluxdef_to_py(py, f)).collect();
2296    out.set_item("fluxes", fluxes).ok();
2297    out.set_item(
2298        "cooling_times_s",
2299        deck.cooling
2300            .as_ref()
2301            .map(|c| c.times_s.clone())
2302            .unwrap_or_default(),
2303    )
2304    .ok();
2305    let schedules: Vec<Py<PyAny>> = deck
2306        .schedules
2307        .iter()
2308        .map(|s| {
2309            let d = PyDict::new(py);
2310            let items: Vec<Vec<String>> = s.items.iter().map(|it| it.tokens.clone()).collect();
2311            d.set_item("name", &s.name).ok();
2312            d.set_item("items", items).ok();
2313            d.into_any().unbind()
2314        })
2315        .collect();
2316    out.set_item("schedules", schedules).ok();
2317    let histories: Vec<Py<PyAny>> = deck
2318        .pulse_histories
2319        .iter()
2320        .map(|h| {
2321            let d = PyDict::new(py);
2322            let levels: Vec<Py<PyAny>> = h
2323                .levels
2324                .iter()
2325                .map(|l| {
2326                    let e = PyDict::new(py);
2327                    e.set_item("pulses", l.pulses).ok();
2328                    e.set_item("delay_s", l.delay_s).ok();
2329                    e.into_any().unbind()
2330                })
2331                .collect();
2332            d.set_item("name", &h.name).ok();
2333            d.set_item("levels", levels).ok();
2334            d.into_any().unbind()
2335        })
2336        .collect();
2337    out.set_item("pulse_histories", histories).ok();
2338    let outputs: Vec<Py<PyAny>> = deck
2339        .outputs
2340        .iter()
2341        .map(|o| {
2342            let d = PyDict::new(py);
2343            d.set_item("resolution", &o.resolution).ok();
2344            d.set_item("entries", o.entries.clone()).ok();
2345            d.into_any().unbind()
2346        })
2347        .collect();
2348    out.set_item("outputs", outputs).ok();
2349    out.set_item("truncation", deck.truncation.as_ref().map(|t| t.tolerance))
2350        .ok();
2351    out.into_any().unbind()
2352}
2353
2354fn mixture_to_py(py: Python<'_>, mix: &nucleide_alara_io::deck::Mixture) -> Py<PyAny> {
2355    use pyo3::types::PyDict;
2356    let entries: Vec<Py<PyAny>> = mix
2357        .entries
2358        .iter()
2359        .map(|e| mixture_entry_to_py(py, e))
2360        .collect();
2361    let d = PyDict::new(py);
2362    d.set_item("name", &mix.name).ok();
2363    d.set_item("entries", entries).ok();
2364    d.into_any().unbind()
2365}
2366
2367fn mixture_entry_to_py(py: Python<'_>, entry: &nucleide_alara_io::deck::MixtureEntry) -> Py<PyAny> {
2368    use nucleide_alara_io::deck::MixtureEntry as E;
2369    use pyo3::types::PyDict;
2370    let d = PyDict::new(py);
2371    match entry {
2372        E::Material {
2373            name,
2374            rel_density,
2375            vol_fraction,
2376        } => {
2377            d.set_item("kind", "material").ok();
2378            d.set_item("name", name).ok();
2379            d.set_item("rel_density", *rel_density).ok();
2380            d.set_item("vol_fraction", *vol_fraction).ok();
2381        }
2382        E::Element {
2383            symbol,
2384            rel_density,
2385            vol_fraction,
2386        } => {
2387            d.set_item("kind", "element").ok();
2388            d.set_item("symbol", symbol).ok();
2389            d.set_item("rel_density", *rel_density).ok();
2390            d.set_item("vol_fraction", *vol_fraction).ok();
2391        }
2392        E::Like {
2393            mixture,
2394            rel_density,
2395        } => {
2396            d.set_item("kind", "like").ok();
2397            d.set_item("mixture", mixture).ok();
2398            d.set_item("rel_density", *rel_density).ok();
2399        }
2400        E::Target { target_kind, name } => {
2401            d.set_item("kind", "target").ok();
2402            d.set_item("target_kind", target_kind).ok();
2403            d.set_item("name", name).ok();
2404        }
2405    }
2406    d.into_any().unbind()
2407}
2408
2409fn fluxdef_to_py(py: Python<'_>, flux: &nucleide_alara_io::deck::FluxDef) -> Py<PyAny> {
2410    use pyo3::types::PyDict;
2411    let d = PyDict::new(py);
2412    d.set_item("name", &flux.name).ok();
2413    d.set_item("file", &flux.file).ok();
2414    d.set_item("scale", flux.scale).ok();
2415    d.set_item("skip", flux.skip).ok();
2416    d.set_item("format", &flux.format).ok();
2417    d.into_any().unbind()
2418}
2419
2420/// Parse an ALARA default-format group-flux file into plain containers.
2421///
2422/// Returns a dict with `name`, `groups_per_interval`, `num_intervals`,
2423/// `totals` (per-interval sums), `total` (grand sum), and `intervals`.
2424#[pyfunction]
2425fn alara_parse_flux(py: Python<'_>, text: &str, name: &str) -> PyResult<Py<PyAny>> {
2426    let owned_text = text.to_owned();
2427    let owned_name = name.to_owned();
2428    let spectra = py
2429        .detach(move || nucleide_alara_io::FluxSpectra::parse(&owned_name, &owned_text))
2430        .map_err(ala_err)?;
2431    use pyo3::types::PyDict;
2432    let d = PyDict::new(py);
2433    d.set_item("name", spectra.name.clone()).ok();
2434    d.set_item("groups_per_interval", spectra.groups_per_interval)
2435        .ok();
2436    d.set_item("num_intervals", spectra.num_intervals()).ok();
2437    let totals: Vec<f64> = spectra.intervals.iter().map(|iv| iv.iter().sum()).collect();
2438    d.set_item("totals", totals).ok();
2439    d.set_item("total", spectra.total()).ok();
2440    d.set_item("intervals", spectra.intervals.clone()).ok();
2441    Ok(d.into_any().unbind())
2442}
2443
2444/// Parse an ALARA activation-output listing into a list of row dicts.
2445///
2446/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
2447/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
2448/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
2449#[pyfunction]
2450fn alara_parse_output(
2451    py: Python<'_>,
2452    text: &str,
2453    run_lbl: &str,
2454) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2455    let owned_text = text.to_owned();
2456    let owned_lbl = run_lbl.to_owned();
2457    let rows = py
2458        .detach(move || {
2459            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl).map(|f| f.rows)
2460        })
2461        .map_err(ala_err)?;
2462    Ok(rows
2463        .iter()
2464        .map(|r| {
2465            let mut d = BTreeMap::new();
2466            d.insert(
2467                "time_s".to_string(),
2468                r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
2469            );
2470            d.insert(
2471                "time_label".to_string(),
2472                r.time_label
2473                    .clone()
2474                    .into_pyobject(py)
2475                    .unwrap()
2476                    .unbind()
2477                    .into_any(),
2478            );
2479            d.insert(
2480                "nuclide".to_string(),
2481                r.nuclide
2482                    .clone()
2483                    .into_pyobject(py)
2484                    .unwrap()
2485                    .unbind()
2486                    .into_any(),
2487            );
2488            d.insert(
2489                "half_life_s".to_string(),
2490                r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
2491            );
2492            d.insert(
2493                "run_lbl".to_string(),
2494                r.run_lbl
2495                    .clone()
2496                    .into_pyobject(py)
2497                    .unwrap()
2498                    .unbind()
2499                    .into_any(),
2500            );
2501            d.insert(
2502                "block".to_string(),
2503                r.block
2504                    .as_str()
2505                    .into_pyobject(py)
2506                    .unwrap()
2507                    .unbind()
2508                    .into_any(),
2509            );
2510            d.insert(
2511                "block_name".to_string(),
2512                r.block_name
2513                    .clone()
2514                    .into_pyobject(py)
2515                    .unwrap()
2516                    .unbind()
2517                    .into_any(),
2518            );
2519            d.insert(
2520                "block_num".to_string(),
2521                r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
2522            );
2523            d.insert(
2524                "variable".to_string(),
2525                r.variable
2526                    .as_str()
2527                    .into_pyobject(py)
2528                    .unwrap()
2529                    .unbind()
2530                    .into_any(),
2531            );
2532            d.insert(
2533                "var_unit".to_string(),
2534                r.var_unit
2535                    .clone()
2536                    .into_pyobject(py)
2537                    .unwrap()
2538                    .unbind()
2539                    .into_any(),
2540            );
2541            d.insert(
2542                "value".to_string(),
2543                r.value.into_pyobject(py).unwrap().unbind().into_any(),
2544            );
2545            d
2546        })
2547        .collect())
2548}
2549
2550/// Expand a deck's schedule hierarchy into flat irradiation/cooling steps.
2551///
2552/// Choice: takes deck text (plus optional top schedule name) instead of JSON
2553/// schedule/history blobs, so callers reuse the already-parsed deck blocks
2554/// without a parallel JSON schema. Returns a list of
2555/// {duration_s, flux, is_cooling} dicts.
2556#[pyfunction]
2557#[pyo3(signature = (deck_text, top=None))]
2558fn alara_expand_schedule(
2559    py: Python<'_>,
2560    deck_text: &str,
2561    top: Option<&str>,
2562) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2563    let owned_text = deck_text.to_owned();
2564    let owned_top = top.map(str::to_owned);
2565    let steps = py
2566        .detach(move || expand_deck_schedules(&owned_text, owned_top.as_deref()))
2567        .map_err(PyValueError::new_err)?;
2568    Ok(steps
2569        .into_iter()
2570        .map(|s| {
2571            let mut d = BTreeMap::new();
2572            let cooling = s.is_cooling();
2573            d.insert(
2574                "duration_s".to_string(),
2575                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
2576            );
2577            d.insert(
2578                "flux".to_string(),
2579                s.flux
2580                    .clone()
2581                    .into_pyobject(py)
2582                    .unwrap()
2583                    .unbind()
2584                    .into_any(),
2585            );
2586            d.insert(
2587                "is_cooling".to_string(),
2588                pyo3::types::PyBool::new(py, cooling)
2589                    .to_owned()
2590                    .into_any()
2591                    .unbind(),
2592            );
2593            d
2594        })
2595        .collect())
2596}
2597
2598fn expand_deck_schedules(
2599    deck_text: &str,
2600    top: Option<&str>,
2601) -> Result<Vec<nucleide_alara_io::FlatStep>, String> {
2602    let deck = nucleide_alara_io::AlaraDeck::parse(deck_text).map_err(|e| e.to_string())?;
2603    let mut scheds = Vec::with_capacity(deck.schedules.len());
2604    for raw in &deck.schedules {
2605        let mut items = Vec::with_capacity(raw.items.len());
2606        for entry in &raw.items {
2607            items.push(
2608                parse_deck_sched_item(&entry.tokens)
2609                    .map_err(|m| format!("schedule `{}` line {}: {m}", raw.name, entry.line))?,
2610            );
2611        }
2612        scheds.push(nucleide_alara_io::schedule::ScheduleDef {
2613            name: raw.name.clone(),
2614            items,
2615        });
2616    }
2617    let histories: Vec<nucleide_alara_io::schedule::PulseHistory> = deck
2618        .pulse_histories
2619        .iter()
2620        .map(|h| nucleide_alara_io::schedule::PulseHistory {
2621            name: h.name.clone(),
2622            levels: h
2623                .levels
2624                .iter()
2625                .map(|l| nucleide_alara_io::schedule::PulseLevel {
2626                    count: l.pulses,
2627                    delay_s: l.delay_s,
2628                })
2629                .collect(),
2630        })
2631        .collect();
2632    match top {
2633        Some(name) => {
2634            nucleide_alara_io::expand_from(name, &scheds, &histories).map_err(|e| e.to_string())
2635        }
2636        None => nucleide_alara_io::expand(&scheds, &histories).map_err(|e| e.to_string()),
2637    }
2638}
2639
2640fn parse_deck_sched_item(tokens: &[String]) -> Result<nucleide_alara_io::SchedItem, String> {
2641    match tokens {
2642        [op_text, op_unit, flux, history, delay_text, delay_unit] => {
2643            let op: f64 = op_text
2644                .parse()
2645                .map_err(|_| format!("expected operating time, found `{op_text}`"))?;
2646            let delay: f64 = delay_text
2647                .parse()
2648                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2649            let op_time_s =
2650                nucleide_alara_io::parse_time_to_seconds(op, op_unit).map_err(|e| e.to_string())?;
2651            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2652                .map_err(|e| e.to_string())?;
2653            Ok(nucleide_alara_io::SchedItem::Pulse {
2654                op_time_s,
2655                flux: flux.clone(),
2656                history: history.clone(),
2657                delay_s,
2658            })
2659        }
2660        [name, history, delay_text, delay_unit] => {
2661            let delay: f64 = delay_text
2662                .parse()
2663                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2664            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2665                .map_err(|e| e.to_string())?;
2666            Ok(nucleide_alara_io::SchedItem::SubSchedule {
2667                name: name.clone(),
2668                history: history.clone(),
2669                delay_s,
2670            })
2671        }
2672        _ => Err(format!(
2673            "expected 4- or 6-token schedule item, found {}",
2674            tokens.join(" ")
2675        )),
2676    }
2677}
2678
2679// ---------------------------------------------------------------------------
2680// Data accessors, input parsing, enrichment, materials
2681// ---------------------------------------------------------------------------
2682
2683/// Half-life [s] for a nucid integer or name string.
2684#[pyfunction]
2685fn half_life(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2686    lookup(key, nucleide_nuclei::data::half_life)
2687}
2688
2689/// Decay constant lambda = ln2 / t_half [1/s].
2690#[pyfunction]
2691fn decay_constant(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2692    lookup(key, nucleide_nuclei::data::decay_constant)
2693}
2694
2695/// Neutron-capture Q value computed from AME2020 masses [MeV].
2696#[pyfunction]
2697fn q_value_capture(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2698    lookup(key, nucleide_nuclei::data::q_value_neutron_capture)
2699}
2700
2701/// Alpha-decay Q value from AME2020 masses [MeV].
2702#[pyfunction]
2703fn q_value_alpha(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2704    lookup(key, nucleide_nuclei::data::q_value_alpha)
2705}
2706
2707/// Parse MCNP material cards from an input deck.
2708/// Returns a list of dicts: {number, fractions: {NuclideName: frac},
2709/// fraction_type: "atom"|"mass", density, comments}.
2710#[pyfunction]
2711fn read_inp(path: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2712    let mats = nucleide_mcnp_io::inp::materials_from_file(path)
2713        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2714    Python::attach(|py| {
2715        Ok(mats
2716            .into_iter()
2717            .map(|m| {
2718                let mut d = BTreeMap::new();
2719                d.insert(
2720                    "number".to_string(),
2721                    m.number.into_pyobject(py).unwrap().unbind().into_any(),
2722                );
2723                let fr: BTreeMap<String, f64> = m
2724                    .fractions
2725                    .iter()
2726                    .map(|(id, f)| (id.to_name(), *f))
2727                    .collect();
2728                d.insert(
2729                    "fractions".to_string(),
2730                    fr.into_pyobject(py).unwrap().unbind().into_any(),
2731                );
2732                d.insert(
2733                    "fraction_type".to_string(),
2734                    match m.fraction_type {
2735                        nucleide_mcnp_io::inp::FracKind::Atom => "atom",
2736                        nucleide_mcnp_io::inp::FracKind::Mass => "mass",
2737                    }
2738                    .into_pyobject(py)
2739                    .unwrap()
2740                    .unbind()
2741                    .into_any(),
2742                );
2743                d.insert(
2744                    "density".to_string(),
2745                    m.density.into_pyobject(py).unwrap().unbind().into_any(),
2746                );
2747                d.insert(
2748                    "comments".to_string(),
2749                    m.comments
2750                        .join(" ")
2751                        .into_pyobject(py)
2752                        .unwrap()
2753                        .unbind()
2754                        .into_any(),
2755                );
2756                d
2757            })
2758            .collect())
2759    })
2760}
2761
2762fn comp_to_material(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
2763    let mut mat = nucleide_material::Material::new();
2764    for (name, grams) in &comp {
2765        let id = nucleide_nuclei::NuclideId::from_name(name)
2766            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
2767        mat.add_nuclide(id, *grams);
2768    }
2769    Ok(mat)
2770}
2771
2772/// Expand a chemical formula into a natural-isotope composition dict
2773/// ({nuclide_name: atom_fraction}) using AME2020 masses + abundances.
2774#[pyfunction]
2775fn from_formula(formula: &str) -> PyResult<BTreeMap<String, f64>> {
2776    use nucleide_material::AbundanceProvider;
2777    let parsed = nucleide_material::parse_formula(formula)
2778        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2779    // Build a temporary element-count material then expand via abundances:
2780    let mut nat = Vec::new();
2781    for (z, count) in &parsed {
2782        if let Some(isotopes) = nucleide_material::NaturalAbundances.natural_isotopes(*z) {
2783            for (id, frac) in isotopes {
2784                nat.push((id, frac * count));
2785            }
2786        }
2787    }
2788    let total: f64 = nat.iter().map(|(_, c)| c).sum();
2789    if total <= 0.0 {
2790        return Err(PyValueError::new_err("empty formula expansion"));
2791    }
2792    let mut out: BTreeMap<String, f64> = BTreeMap::new();
2793    for (id, atoms) in nat {
2794        *out.entry(id.to_name()).or_insert(0.0) += atoms / total;
2795    }
2796    Ok(out)
2797}
2798
2799/// Activity [Bq] per nuclide plus whole-material specific activity.
2800/// Returns {name: Bq} entries and "specific" = Bq/g of the composition.
2801#[pyfunction]
2802fn activity(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
2803    let mat = comp_to_material(comp)?;
2804    let analytics = nucleide_material::Analytics {
2805        masses: &nucleide_material::Ame2020,
2806        decays: &nucleide_material::ChainDecays,
2807    };
2808    let per_nuc = mat
2809        .activity(&analytics)
2810        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2811    let specific = mat
2812        .specific_activity(&analytics)
2813        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2814    let mut out: BTreeMap<String, f64> = per_nuc
2815        .into_iter()
2816        .map(|(id, v)| (id.to_name(), v))
2817        .collect();
2818    out.insert("specific".to_string(), specific);
2819    Ok(out)
2820}
2821
2822/// Serialize a composition dictionary to a `<material>` XML fragment.
2823#[pyfunction]
2824fn to_xml(comp: BTreeMap<String, f64>, name: &str, density: f64, units: &str) -> PyResult<String> {
2825    let mat = comp_to_material(comp)?;
2826    mat.to_xml(name, density, units)
2827        .map_err(|e| PyValueError::new_err(e.to_string()))
2828}
2829
2830/// Enrichment cascade with numeric multicomponent solving.
2831#[pyclass(name = "Cascade")]
2832struct PyCascade {
2833    inner: std::sync::Mutex<nucleide_enrichment::Cascade>,
2834}
2835
2836#[pymethods]
2837impl PyCascade {
2838    /// Natural-uranium default cascade (alpha=1.05, Mstar=236, j=U235, k=U238).
2839    #[staticmethod]
2840    fn default_uranium() -> Self {
2841        Self {
2842            inner: std::sync::Mutex::new(nucleide_enrichment::default_uranium_cascade()),
2843        }
2844    }
2845
2846    /// Build a cascade from full parameters. `mat_feed` is a dict of
2847    /// nuclide-name strings to mass fractions.
2848    #[new]
2849    #[allow(non_snake_case)]
2850    #[allow(clippy::too_many_arguments)]
2851    fn new(
2852        alpha: f64,
2853        Mstar: f64,
2854        j: u32,
2855        k: u32,
2856        N: f64,
2857        M: f64,
2858        x_feed_j: f64,
2859        x_prod_j: f64,
2860        x_tail_j: f64,
2861        mat_feed: BTreeMap<String, f64>,
2862    ) -> PyResult<Self> {
2863        let mut feed = BTreeMap::new();
2864        for (name, frac) in mat_feed {
2865            let id = NuclideId::from_name(&name).map_err(wrap_nucid_err)?;
2866            feed.insert(id, frac);
2867        }
2868        let casc = nucleide_enrichment::Cascade {
2869            alpha,
2870            Mstar,
2871            j: NuclideId::from_nucid(j),
2872            k: NuclideId::from_nucid(k),
2873            N,
2874            M,
2875            x_feed_j,
2876            x_prod_j,
2877            x_tail_j,
2878            mat_feed: nucleide_enrichment::Stream::with_total_mass(feed, 1.0),
2879            mat_prod: nucleide_enrichment::Stream::new(),
2880            mat_tail: nucleide_enrichment::Stream::new(),
2881            l_t_per_feed: 0.0,
2882            swu_per_feed: 0.0,
2883            swu_per_prod: 0.0,
2884        };
2885        Ok(Self {
2886            inner: std::sync::Mutex::new(casc),
2887        })
2888    }
2889
2890    /// Solve via the numeric fixed-point + secant scheme in place.
2891    #[pyo3(signature = (tolerance=None, max_iterations=None))]
2892    fn solve(&self, tolerance: Option<f64>, max_iterations: Option<u32>) -> PyResult<()> {
2893        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
2894        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
2895        let mut c = self
2896            .inner
2897            .lock()
2898            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2899        *c = nucleide_enrichment::solve_numeric(&c, tol, iters)
2900            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2901        Ok(())
2902    }
2903
2904    /// Solve and optimize `M*` for a multicomponent feed in place.
2905    #[pyo3(signature = (tolerance=None, max_iterations=None))]
2906    fn solve_multicomponent(
2907        &self,
2908        tolerance: Option<f64>,
2909        max_iterations: Option<u32>,
2910    ) -> PyResult<()> {
2911        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
2912        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
2913        let mut c = self
2914            .inner
2915            .lock()
2916            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2917        *c = nucleide_enrichment::multicomponent(&c, tol, iters)
2918            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2919        Ok(())
2920    }
2921
2922    #[getter]
2923    fn alpha(&self) -> PyResult<f64> {
2924        Ok(self
2925            .inner
2926            .lock()
2927            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2928            .alpha)
2929    }
2930    #[getter]
2931    #[allow(non_snake_case)]
2932    fn Mstar(&self) -> PyResult<f64> {
2933        Ok(self
2934            .inner
2935            .lock()
2936            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2937            .Mstar)
2938    }
2939    #[getter]
2940    #[allow(non_snake_case)]
2941    fn N(&self) -> PyResult<f64> {
2942        Ok(self
2943            .inner
2944            .lock()
2945            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2946            .N)
2947    }
2948    #[getter]
2949    #[allow(non_snake_case)]
2950    fn M(&self) -> PyResult<f64> {
2951        Ok(self
2952            .inner
2953            .lock()
2954            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2955            .M)
2956    }
2957    #[getter]
2958    fn x_feed_j(&self) -> PyResult<f64> {
2959        Ok(self
2960            .inner
2961            .lock()
2962            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2963            .x_feed_j)
2964    }
2965    #[getter]
2966    fn x_prod_j(&self) -> PyResult<f64> {
2967        Ok(self
2968            .inner
2969            .lock()
2970            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2971            .x_prod_j)
2972    }
2973    #[getter]
2974    fn x_tail_j(&self) -> PyResult<f64> {
2975        Ok(self
2976            .inner
2977            .lock()
2978            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2979            .x_tail_j)
2980    }
2981    #[getter]
2982    fn l_t_per_feed(&self) -> PyResult<f64> {
2983        Ok(self
2984            .inner
2985            .lock()
2986            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2987            .l_t_per_feed)
2988    }
2989    #[getter]
2990    fn swu_per_feed(&self) -> PyResult<f64> {
2991        Ok(self
2992            .inner
2993            .lock()
2994            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2995            .swu_per_feed)
2996    }
2997    #[getter]
2998    fn swu_per_prod(&self) -> PyResult<f64> {
2999        Ok(self
3000            .inner
3001            .lock()
3002            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3003            .swu_per_prod)
3004    }
3005    /// Feed composition as {nuclide_name: mass_fraction}.
3006    #[getter]
3007    fn mat_feed(&self) -> PyResult<BTreeMap<String, f64>> {
3008        Ok(self
3009            .inner
3010            .lock()
3011            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3012            .mat_feed
3013            .comp
3014            .iter()
3015            .map(|(id, frac)| (id.to_name(), *frac))
3016            .collect())
3017    }
3018    /// Product composition as {nuclide_name: mass_fraction}.
3019    #[getter]
3020    fn mat_prod(&self) -> PyResult<BTreeMap<String, f64>> {
3021        Ok(self
3022            .inner
3023            .lock()
3024            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3025            .mat_prod
3026            .comp
3027            .iter()
3028            .map(|(id, frac)| (id.to_name(), *frac))
3029            .collect())
3030    }
3031    /// Tails composition as {nuclide_name: mass_fraction}.
3032    #[getter]
3033    fn mat_tail(&self) -> PyResult<BTreeMap<String, f64>> {
3034        Ok(self
3035            .inner
3036            .lock()
3037            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3038            .mat_tail
3039            .comp
3040            .iter()
3041            .map(|(id, frac)| (id.to_name(), *frac))
3042            .collect())
3043    }
3044    /// Separative work per product [kg SWU/kg] from the key assays.
3045    fn separative_work_per_product(&self) -> PyResult<f64> {
3046        let c = self
3047            .inner
3048            .lock()
3049            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3050        Ok(nucleide_enrichment::swu_per_prod(
3051            c.x_feed_j, c.x_prod_j, c.x_tail_j,
3052        ))
3053    }
3054
3055    fn __repr__(&self) -> PyResult<String> {
3056        let c = self
3057            .inner
3058            .lock()
3059            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3060        Ok(format!(
3061            "Cascade(alpha={}, Mstar={}, x_prod_j={:.5})",
3062            c.alpha, c.Mstar, c.x_prod_j
3063        ))
3064    }
3065}
3066
3067/// Dirac separation potential `V(x) = (2x - 1) ln(x / (1 - x))`.
3068///
3069/// Thin wrapper over `nucleide_enrichment::value_func`.
3070#[pyfunction]
3071fn enrichment_value_func(x: f64) -> f64 {
3072    nucleide_enrichment::value_func(x)
3073}
3074
3075/// SWU per unit mass of feed for assays `x_feed`, `x_prod`, `x_tail`.
3076///
3077/// Thin wrapper over `nucleide_enrichment::swu_per_feed`.
3078#[pyfunction]
3079fn enrichment_swu_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3080    nucleide_enrichment::swu_per_feed(x_feed, x_prod, x_tail)
3081}
3082
3083/// SWU per unit mass of product for assays `x_feed`, `x_prod`, `x_tail`.
3084///
3085/// Thin wrapper over `nucleide_enrichment::swu_per_prod`.
3086#[pyfunction]
3087fn enrichment_swu_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3088    nucleide_enrichment::swu_per_prod(x_feed, x_prod, x_tail)
3089}
3090
3091/// SWU per unit mass of tails for assays `x_feed`, `x_prod`, `x_tail`.
3092///
3093/// Thin wrapper over `nucleide_enrichment::swu_per_tail`.
3094#[pyfunction]
3095fn enrichment_swu_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3096    nucleide_enrichment::swu_per_tail(x_feed, x_prod, x_tail)
3097}
3098
3099/// PNNL/DOE Materials Compendium library (411 named materials).
3100#[pyclass(name = "MaterialsCompendium")]
3101struct PyMaterialsCompendium {
3102    inner: nucleide_material::MaterialsLibrary,
3103}
3104
3105#[pymethods]
3106impl PyMaterialsCompendium {
3107    /// Load from the official MaterialsCompendium.json.
3108    #[staticmethod]
3109    fn load(path: &str) -> PyResult<Self> {
3110        nucleide_material::MaterialsLibrary::from_file(path)
3111            .map(|inner| PyMaterialsCompendium { inner })
3112            .map_err(|e| PyValueError::new_err(e.to_string()))
3113    }
3114
3115    fn __len__(&self) -> usize {
3116        self.inner.len()
3117    }
3118
3119    /// All display names in file order.
3120    fn names(&self) -> Vec<String> {
3121        self.inner.names().into_iter().map(String::from).collect()
3122    }
3123
3124    /// Case-insensitive lookup by name; returns
3125    /// {name, mat_num, density, fractions: {ZAID: weight_fraction}} or None.
3126    /// With as_material=True fractions are keyed by nuclide name instead.
3127    #[pyo3(signature = (name, as_material=false))]
3128    #[allow(clippy::type_complexity)]
3129    fn get(&self, name: &str, as_material: bool) -> PyResult<Option<BTreeMap<String, Py<PyAny>>>> {
3130        let entry = match self.inner.get(name) {
3131            Some(e) => e,
3132            None => return Ok(None),
3133        };
3134        // Material conversion needs no GIL; do it before attaching.
3135        let named_fractions = if as_material {
3136            Some(
3137                entry
3138                    .to_material()
3139                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
3140            )
3141        } else {
3142            None
3143        };
3144
3145        Ok(Python::attach(|py| {
3146            let mut d: BTreeMap<String, Py<PyAny>> = BTreeMap::new();
3147            d.insert(
3148                "name".into(),
3149                entry
3150                    .name
3151                    .as_str()
3152                    .into_pyobject(py)
3153                    .unwrap()
3154                    .unbind()
3155                    .into_any(),
3156            );
3157            d.insert(
3158                "mat_num".into(),
3159                entry.mat_num.into_pyobject(py).unwrap().unbind().into_any(),
3160            );
3161            d.insert(
3162                "density".into(),
3163                entry.density.into_pyobject(py).unwrap().unbind().into_any(),
3164            );
3165            match &named_fractions {
3166                Some(mat) => {
3167                    let fr: BTreeMap<String, f64> =
3168                        mat.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
3169                    d.insert(
3170                        "fractions".into(),
3171                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3172                    );
3173                }
3174                None => {
3175                    let fr = entry.weight_fractions();
3176                    d.insert(
3177                        "fractions".into(),
3178                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3179                    );
3180                }
3181            }
3182            Some(d)
3183        }))
3184    }
3185}
3186
3187// ---------------------------------------------------------------------------
3188// CCCC I/O (thin glue over `cccc-io`; no solver)
3189// ---------------------------------------------------------------------------
3190
3191/// Parse ISOTXS text into plain Python containers.
3192///
3193/// Returns a dict with `nuclides` (list of {label, zaid, groups, total_xs}
3194/// in file order).
3195#[pyfunction]
3196fn isotxs_parse(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3197    let owned = text.to_owned();
3198    let lib = py
3199        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3200        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3201    Ok(isotxs_to_py(py, &lib))
3202}
3203
3204fn isotxs_to_py(py: Python<'_>, lib: &nucleide_cccc_io::IsotxsLib) -> Py<PyAny> {
3205    use pyo3::types::PyDict;
3206    let out = PyDict::new(py);
3207    let nuclides: Vec<Py<PyAny>> = lib
3208        .nuclides
3209        .iter()
3210        .map(|n| {
3211            let d = PyDict::new(py);
3212            d.set_item("label", &n.label).ok();
3213            d.set_item("zaid", &n.zaid).ok();
3214            d.set_item("groups", n.groups).ok();
3215            d.set_item("total_xs", n.total_xs.clone()).ok();
3216            d.into_any().unbind()
3217        })
3218        .collect();
3219    out.set_item("nuclides", nuclides).ok();
3220    out.into_any().unbind()
3221}
3222
3223/// Parse an RTFLUX/ATFLUX/RZFLUX flux file into plain containers.
3224///
3225/// `kind` selects the expected header keyword (`rtflux`|`atflux`|`rzflux`,
3226/// case-insensitive). Returns a dict with `kind`, `groups`, `per_point`,
3227/// `npoints`, `values`, and `total`.
3228#[pyfunction]
3229#[pyo3(signature = (text, kind="rtflux"))]
3230fn rtflux_parse(py: Python<'_>, text: &str, kind: &str) -> PyResult<Py<PyAny>> {
3231    let flux_kind = match kind.to_ascii_lowercase().as_str() {
3232        "rtflux" => nucleide_cccc_io::rtflux::FluxKind::Rtflux,
3233        "atflux" => nucleide_cccc_io::rtflux::FluxKind::Atflux,
3234        "rzflux" => nucleide_cccc_io::rtflux::FluxKind::Rzflux,
3235        other => {
3236            return Err(PyValueError::new_err(format!(
3237                "kind must be rtflux|atflux|rzflux, got `{other}`"
3238            )))
3239        }
3240    };
3241    let owned = text.to_owned();
3242    let flux = py
3243        .detach(move || nucleide_cccc_io::FluxFile::parse(flux_kind, &owned))
3244        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3245    use pyo3::types::PyDict;
3246    let d = PyDict::new(py);
3247    d.set_item("kind", flux.kind.keyword()).ok();
3248    d.set_item("groups", flux.groups).ok();
3249    d.set_item("per_point", flux.per_point).ok();
3250    d.set_item("npoints", flux.npoints()).ok();
3251    d.set_item("values", flux.values.clone()).ok();
3252    d.set_item("total", flux.total()).ok();
3253    Ok(d.into_any().unbind())
3254}
3255
3256fn partisn_deck_from_dict(
3257    deck: &Bound<'_, pyo3::types::PyDict>,
3258) -> PyResult<nucleide_cccc_io::PartisnDeck> {
3259    let title: String = match deck.get_item("title")? {
3260        Some(v) => v
3261            .extract()
3262            .map_err(|_| PyValueError::new_err("partisn deck `title` must be str"))?,
3263        None => return Err(PyValueError::new_err("partisn deck missing `title`")),
3264    };
3265    let dim: u8 = match deck.get_item("dim")? {
3266        Some(v) => v
3267            .extract()
3268            .map_err(|_| PyValueError::new_err("partisn deck `dim` must be 1, 2, or 3"))?,
3269        None => return Err(PyValueError::new_err("partisn deck missing `dim`")),
3270    };
3271    let zones_value = match deck.get_item("zones")? {
3272        Some(v) => v,
3273        None => return Err(PyValueError::new_err("partisn deck missing `zones`")),
3274    };
3275    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = zones_value
3276        .extract()
3277        .map_err(|_| PyValueError::new_err("partisn deck `zones` must be a list of dicts"))?;
3278    let mut zones = Vec::with_capacity(zone_dicts.len());
3279    for z in &zone_dicts {
3280        let id: u32 = match z.get_item("id")? {
3281            Some(v) => v
3282                .extract()
3283                .map_err(|_| PyValueError::new_err("partisn zone `id` must be int"))?,
3284            None => return Err(PyValueError::new_err("partisn zone missing `id`")),
3285        };
3286        let material: String = match z.get_item("material")? {
3287            Some(v) => v
3288                .extract()
3289                .map_err(|_| PyValueError::new_err("partisn zone `material` must be str"))?,
3290            None => return Err(PyValueError::new_err("partisn zone missing `material`")),
3291        };
3292        let isotxs_labels: Vec<String> = match z.get_item("isotxs_labels")? {
3293            Some(v) => v.extract().map_err(|_| {
3294                PyValueError::new_err("partisn zone `isotxs_labels` must be a list of str")
3295            })?,
3296            None => {
3297                return Err(PyValueError::new_err(
3298                    "partisn zone missing `isotxs_labels`",
3299                ))
3300            }
3301        };
3302        let density: f64 = match z.get_item("density")? {
3303            Some(v) => v
3304                .extract()
3305                .map_err(|_| PyValueError::new_err("partisn zone `density` must be float"))?,
3306            None => return Err(PyValueError::new_err("partisn zone missing `density`")),
3307        };
3308        zones.push(nucleide_cccc_io::partisn::PartisnZone {
3309            id,
3310            material,
3311            isotxs_labels,
3312            density,
3313        });
3314    }
3315    let source: Option<String> = match deck.get_item("source")? {
3316        Some(v) if v.is_none() => None,
3317        Some(v) => Some(
3318            v.extract()
3319                .map_err(|_| PyValueError::new_err("partisn deck `source` must be str or None"))?,
3320        ),
3321        None => None,
3322    };
3323    Ok(nucleide_cccc_io::PartisnDeck {
3324        title,
3325        dim,
3326        zones,
3327        source,
3328    })
3329}
3330
3331/// Render a PARTISN deck dict to PARTISN input text.
3332///
3333/// Deck shape: {title: str, dim: 1|2|3, zones: [{id, material,
3334/// isotxs_labels, density}], source: str | None}.
3335#[pyfunction]
3336fn partisn_render(py: Python<'_>, deck: &Bound<'_, pyo3::types::PyDict>) -> PyResult<String> {
3337    let rust_deck = partisn_deck_from_dict(deck)?;
3338    Ok(py.detach(move || rust_deck.render()))
3339}
3340
3341/// Validate a PARTISN deck dict against ISOTXS text.
3342///
3343/// Raises `ValueError` when `dim` is not 1/2/3 or a zone names an ISOTXS
3344/// label absent from the library.
3345#[pyfunction]
3346fn partisn_validate(
3347    py: Python<'_>,
3348    deck: &Bound<'_, pyo3::types::PyDict>,
3349    isotxs_text: &str,
3350) -> PyResult<()> {
3351    let rust_deck = partisn_deck_from_dict(deck)?;
3352    let owned = isotxs_text.to_owned();
3353    let lib = py
3354        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3355        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3356    rust_deck
3357        .validate(&lib)
3358        .map_err(|e| PyValueError::new_err(e.to_string()))
3359}
3360
3361// ---------------------------------------------------------------------------
3362// FISPACT-II output (thin glue over `fispact-io`; reuses ResponseFrame)
3363// ---------------------------------------------------------------------------
3364
3365fn fispact_row_to_map(
3366    py: Python<'_>,
3367    r: &nucleide_alara_io::output::ResponseRow,
3368) -> BTreeMap<String, Py<PyAny>> {
3369    let mut d = BTreeMap::new();
3370    d.insert(
3371        "time_s".to_string(),
3372        r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
3373    );
3374    d.insert(
3375        "time_label".to_string(),
3376        r.time_label
3377            .clone()
3378            .into_pyobject(py)
3379            .unwrap()
3380            .unbind()
3381            .into_any(),
3382    );
3383    d.insert(
3384        "nuclide".to_string(),
3385        r.nuclide
3386            .clone()
3387            .into_pyobject(py)
3388            .unwrap()
3389            .unbind()
3390            .into_any(),
3391    );
3392    d.insert(
3393        "half_life_s".to_string(),
3394        r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
3395    );
3396    d.insert(
3397        "run_lbl".to_string(),
3398        r.run_lbl
3399            .clone()
3400            .into_pyobject(py)
3401            .unwrap()
3402            .unbind()
3403            .into_any(),
3404    );
3405    d.insert(
3406        "block".to_string(),
3407        r.block
3408            .as_str()
3409            .into_pyobject(py)
3410            .unwrap()
3411            .unbind()
3412            .into_any(),
3413    );
3414    d.insert(
3415        "block_name".to_string(),
3416        r.block_name
3417            .clone()
3418            .into_pyobject(py)
3419            .unwrap()
3420            .unbind()
3421            .into_any(),
3422    );
3423    d.insert(
3424        "block_num".to_string(),
3425        r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
3426    );
3427    d.insert(
3428        "variable".to_string(),
3429        r.variable
3430            .as_str()
3431            .into_pyobject(py)
3432            .unwrap()
3433            .unbind()
3434            .into_any(),
3435    );
3436    d.insert(
3437        "var_unit".to_string(),
3438        r.var_unit
3439            .clone()
3440            .into_pyobject(py)
3441            .unwrap()
3442            .unbind()
3443            .into_any(),
3444    );
3445    d.insert(
3446        "value".to_string(),
3447        r.value.into_pyobject(py).unwrap().unbind().into_any(),
3448    );
3449    d
3450}
3451
3452/// Parse a FISPACT-II inventory listing into a list of row dicts.
3453///
3454/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
3455/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
3456/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
3457#[pyfunction]
3458fn fispact_parse_output(
3459    py: Python<'_>,
3460    text: &str,
3461    run_lbl: &str,
3462) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3463    let owned_text = text.to_owned();
3464    let owned_lbl = run_lbl.to_owned();
3465    let rows = py
3466        .detach(move || {
3467            nucleide_fispact_io::parse_to_frame(&owned_text, &owned_lbl).map(|f| f.rows)
3468        })
3469        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3470    Ok(rows.iter().map(|r| fispact_row_to_map(py, r)).collect())
3471}
3472
3473// ---------------------------------------------------------------------------
3474// ORIGEN TAPE readers (thin glue over `origen-io`; scoped TAPE5/6/9)
3475// ---------------------------------------------------------------------------
3476
3477/// Parse ORIGEN TAPE5 input-echo text into plain containers.
3478///
3479/// Returns a dict with `titles` (list[str]), `irradiation_steps`
3480/// (list of {flux, days}), and `materials` (list of {name, entries:
3481/// [{nuclide, grams}]}).
3482#[pyfunction]
3483fn origen_parse_tape5(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3484    let owned = text.to_owned();
3485    let tape = py
3486        .detach(move || nucleide_origen_io::Tape5::parse(&owned))
3487        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3488    use pyo3::types::PyDict;
3489    let out = PyDict::new(py);
3490    out.set_item("titles", tape.titles.clone()).ok();
3491    let steps: Vec<Py<PyAny>> = tape
3492        .irradiation_steps
3493        .iter()
3494        .map(|s| {
3495            let d = PyDict::new(py);
3496            d.set_item("flux", s.flux).ok();
3497            d.set_item("days", s.days).ok();
3498            d.into_any().unbind()
3499        })
3500        .collect();
3501    out.set_item("irradiation_steps", steps).ok();
3502    let materials: Vec<Py<PyAny>> = tape
3503        .materials
3504        .iter()
3505        .map(|m| {
3506            let d = PyDict::new(py);
3507            d.set_item("name", &m.name).ok();
3508            let entries: Vec<Py<PyAny>> = m
3509                .grams
3510                .iter()
3511                .map(|(nuclide, grams)| {
3512                    let e = PyDict::new(py);
3513                    e.set_item("nuclide", nuclide).ok();
3514                    e.set_item("grams", *grams).ok();
3515                    e.into_any().unbind()
3516                })
3517                .collect();
3518            d.set_item("entries", entries).ok();
3519            d.into_any().unbind()
3520        })
3521        .collect();
3522    out.set_item("materials", materials).ok();
3523    Ok(out.into_any().unbind())
3524}
3525
3526/// Parse ORIGEN TAPE6 output-inventory text into plain containers.
3527///
3528/// Returns a dict with `records` (list of {nuclide, grams, activity_bq} in
3529/// file order) and `total_activity` (sum over records).
3530#[pyfunction]
3531fn origen_parse_tape6(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3532    let owned = text.to_owned();
3533    let tape = py
3534        .detach(move || nucleide_origen_io::Tape6::parse(&owned))
3535        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3536    use pyo3::types::PyDict;
3537    let out = PyDict::new(py);
3538    let records: Vec<Py<PyAny>> = tape
3539        .records
3540        .iter()
3541        .map(|r| {
3542            let d = PyDict::new(py);
3543            d.set_item("nuclide", &r.nuclide).ok();
3544            d.set_item("grams", r.grams).ok();
3545            d.set_item("activity_bq", r.activity_bq).ok();
3546            d.into_any().unbind()
3547        })
3548        .collect();
3549    out.set_item("records", records).ok();
3550    out.set_item("total_activity", tape.total_activity()).ok();
3551    Ok(out.into_any().unbind())
3552}
3553
3554/// Parse ORIGEN TAPE9 decay-constant text into a list of row dicts.
3555///
3556/// Each entry is {nuclide, decay_const} in file order.
3557#[pyfunction]
3558fn origen_parse_tape9(py: Python<'_>, text: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3559    let owned = text.to_owned();
3560    let entries = py
3561        .detach(move || nucleide_origen_io::Tape9Entry::parse(&owned))
3562        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3563    Ok(entries
3564        .iter()
3565        .map(|e| {
3566            let mut d = BTreeMap::new();
3567            d.insert(
3568                "nuclide".to_string(),
3569                e.nuclide
3570                    .clone()
3571                    .into_pyobject(py)
3572                    .unwrap()
3573                    .unbind()
3574                    .into_any(),
3575            );
3576            d.insert(
3577                "decay_const".to_string(),
3578                e.decay_const.into_pyobject(py).unwrap().unbind().into_any(),
3579            );
3580            d
3581        })
3582        .collect())
3583}
3584
3585// ---------------------------------------------------------------------------
3586// R2S workflow builder (thin glue over `r2s`; no transport/activation solve)
3587// ---------------------------------------------------------------------------
3588
3589fn r2s_workflow_to_py(py: Python<'_>, workflow: &nucleide_r2s::R2sWorkflow) -> Py<PyAny> {
3590    use pyo3::types::PyDict;
3591    let out = PyDict::new(py);
3592    let steps: Vec<Py<PyAny>> = workflow
3593        .steps
3594        .iter()
3595        .map(|s| {
3596            let d = PyDict::new(py);
3597            d.set_item("zone", &s.zone).ok();
3598            d.set_item("flux", &s.flux).ok();
3599            d.into_any().unbind()
3600        })
3601        .collect();
3602    out.set_item("steps", steps).ok();
3603    out.set_item("cooling_s", workflow.cooling_s.clone()).ok();
3604    out.set_item("top_schedule", &workflow.top_schedule).ok();
3605    out.into_any().unbind()
3606}
3607
3608fn r2s_workflow_from_dict(
3609    workflow: &Bound<'_, pyo3::types::PyDict>,
3610) -> PyResult<nucleide_r2s::R2sWorkflow> {
3611    let steps_value = match workflow.get_item("steps")? {
3612        Some(v) => v,
3613        None => return Err(PyValueError::new_err("r2s workflow missing `steps`")),
3614    };
3615    let step_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = steps_value
3616        .extract()
3617        .map_err(|_| PyValueError::new_err("r2s workflow `steps` must be a list of dicts"))?;
3618    let mut steps = Vec::with_capacity(step_dicts.len());
3619    for s in &step_dicts {
3620        let zone: String = match s.get_item("zone")? {
3621            Some(v) => v
3622                .extract()
3623                .map_err(|_| PyValueError::new_err("r2s step `zone` must be str"))?,
3624            None => return Err(PyValueError::new_err("r2s step missing `zone`")),
3625        };
3626        let flux: String = match s.get_item("flux")? {
3627            Some(v) => v
3628                .extract()
3629                .map_err(|_| PyValueError::new_err("r2s step `flux` must be str"))?,
3630            None => return Err(PyValueError::new_err("r2s step missing `flux`")),
3631        };
3632        steps.push(nucleide_r2s::R2sStep { zone, flux });
3633    }
3634    let cooling_s: Vec<f64> = match workflow.get_item("cooling_s")? {
3635        Some(v) => v.extract().map_err(|_| {
3636            PyValueError::new_err("r2s workflow `cooling_s` must be a list of float")
3637        })?,
3638        None => return Err(PyValueError::new_err("r2s workflow missing `cooling_s`")),
3639    };
3640    let top_schedule: String = match workflow.get_item("top_schedule")? {
3641        Some(v) => v
3642            .extract()
3643            .map_err(|_| PyValueError::new_err("r2s workflow `top_schedule` must be str"))?,
3644        None => return Err(PyValueError::new_err("r2s workflow missing `top_schedule`")),
3645    };
3646    Ok(nucleide_r2s::R2sWorkflow {
3647        steps,
3648        cooling_s,
3649        top_schedule,
3650    })
3651}
3652
3653/// Derive an R2S workflow summary from an ALARA deck.
3654///
3655/// Returns a dict with `steps` (list of {zone, flux}), `cooling_s`
3656/// (list[float]), and `top_schedule` (str).
3657#[pyfunction]
3658fn r2s_from_deck(py: Python<'_>, deck_text: &str) -> PyResult<Py<PyAny>> {
3659    let owned = deck_text.to_owned();
3660    let workflow = py
3661        .detach(move || {
3662            let deck = nucleide_alara_io::AlaraDeck::parse(&owned)
3663                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3664            nucleide_r2s::R2sWorkflow::from_deck(&deck)
3665        })
3666        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3667    Ok(r2s_workflow_to_py(py, &workflow))
3668}
3669
3670/// Validate an R2S workflow dict against an ALARA deck.
3671///
3672/// Raises `ValueError` when a step zone/flux is unknown or cooling histories
3673/// are missing.
3674#[pyfunction]
3675fn r2s_validate(
3676    py: Python<'_>,
3677    workflow: &Bound<'_, pyo3::types::PyDict>,
3678    deck_text: &str,
3679) -> PyResult<()> {
3680    let rust_workflow = r2s_workflow_from_dict(workflow)?;
3681    let owned = deck_text.to_owned();
3682    let deck = py
3683        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
3684        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3685    rust_workflow
3686        .validate_against(&deck)
3687        .map_err(|e| PyValueError::new_err(e.to_string()))
3688}
3689
3690/// Expand an ALARA deck's irradiation hierarchy into flat steps via R2S.
3691///
3692/// Returns a list of {duration_s, flux, is_cooling} dicts. When `top` is
3693/// given it overrides the workflow's discovered top schedule.
3694#[pyfunction]
3695#[pyo3(signature = (deck_text, top=None))]
3696fn r2s_expand(
3697    py: Python<'_>,
3698    deck_text: &str,
3699    top: Option<&str>,
3700) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3701    let owned_text = deck_text.to_owned();
3702    let owned_top = top.map(str::to_owned);
3703    let steps = py
3704        .detach(move || {
3705            let deck = nucleide_alara_io::AlaraDeck::parse(&owned_text)
3706                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3707            let mut workflow = nucleide_r2s::R2sWorkflow::from_deck(&deck)?;
3708            if let Some(top) = owned_top {
3709                workflow.top_schedule = top;
3710            }
3711            workflow.expand(&deck, &[])
3712        })
3713        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3714    Ok(steps
3715        .into_iter()
3716        .map(|s| {
3717            let mut d = BTreeMap::new();
3718            let cooling = s.is_cooling();
3719            d.insert(
3720                "duration_s".to_string(),
3721                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
3722            );
3723            d.insert(
3724                "flux".to_string(),
3725                s.flux.into_pyobject(py).unwrap().unbind().into_any(),
3726            );
3727            d.insert(
3728                "is_cooling".to_string(),
3729                pyo3::types::PyBool::new(py, cooling)
3730                    .to_owned()
3731                    .into_any()
3732                    .unbind(),
3733            );
3734            d
3735        })
3736        .collect())
3737}
3738
3739/// Assemble a uniform-split photon source summary for `zone`.
3740///
3741/// Parses an ALARA activation-output listing, sums shutdown
3742/// `SpecificActivity` over the zone's nuclide rows (skipping `total`
3743/// aggregates), and splits the total uniformly over `groups` energy groups.
3744/// Returns a dict with `zone`, `groups` (list[float]), and `total`.
3745///
3746/// Approximation: the uniform split preserves only the total shutdown
3747/// strength; real decay photons follow the nuclide- and energy-dependent
3748/// lines in ALARA `.photonSrc` spectra.
3749#[pyfunction]
3750fn r2s_assemble(
3751    py: Python<'_>,
3752    output_text: &str,
3753    run_lbl: &str,
3754    zone: &str,
3755    groups: usize,
3756) -> PyResult<Py<PyAny>> {
3757    let owned_text = output_text.to_owned();
3758    let owned_lbl = run_lbl.to_owned();
3759    let owned_zone = zone.to_owned();
3760    let source = py
3761        .detach(move || {
3762            let frame = nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
3763                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3764            Ok::<_, nucleide_r2s::Error>(nucleide_r2s::photon::assemble(
3765                &frame,
3766                &owned_zone,
3767                groups,
3768            ))
3769        })
3770        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3771    use pyo3::types::PyDict;
3772    let out = PyDict::new(py);
3773    out.set_item("zone", source.zone.clone()).ok();
3774    out.set_item("groups", source.groups.clone()).ok();
3775    out.set_item("total", source.total()).ok();
3776    Ok(out.into_any().unbind())
3777}
3778
3779/// Map zone totals onto voxels (`zone_of_voxel` holds zone indices).
3780///
3781/// `totals` carries one total source strength per zone; with `split=False`
3782/// every voxel copies its zone total (tag-as-attribute), with `split=True`
3783/// each zone total is divided conservatively over its voxels. Returns a
3784/// dict with `n_zones`, `zone_of_voxel`, `source_strength`,
3785/// `decay_time_s` (all shutdown `0.0`), and `total`. Thin wrapper over
3786/// `nucleide-r2s` `tag_zone_totals` / `split_zone_totals`.
3787#[pyfunction]
3788#[pyo3(signature = (totals, zone_of_voxel, split=false))]
3789fn r2s_tag_zone_strength(
3790    py: Python<'_>,
3791    totals: Vec<f64>,
3792    zone_of_voxel: Vec<usize>,
3793    split: bool,
3794) -> PyResult<Py<PyAny>> {
3795    let zones: Vec<nucleide_r2s::photon::ZonePhotonSource> = totals
3796        .into_iter()
3797        .enumerate()
3798        .map(|(i, total)| {
3799            let groups = if total == 0.0 {
3800                Vec::new()
3801            } else {
3802                vec![total]
3803            };
3804            nucleide_r2s::photon::ZonePhotonSource {
3805                zone: format!("zone{i}"),
3806                groups,
3807            }
3808        })
3809        .collect();
3810    let tags = if split {
3811        nucleide_r2s::tags::split_zone_totals(&zones, &zone_of_voxel)
3812    } else {
3813        nucleide_r2s::tags::tag_zone_totals(&zones, &zone_of_voxel)
3814    }
3815    .map_err(|e| PyValueError::new_err(e.to_string()))?;
3816    use pyo3::types::PyDict;
3817    let out = PyDict::new(py);
3818    out.set_item("n_zones", tags.n_zones).ok();
3819    out.set_item("zone_of_voxel", tags.zone_of_voxel.clone())
3820        .ok();
3821    out.set_item("source_strength", tags.source_strength.clone())
3822        .ok();
3823    out.set_item("decay_time_s", tags.decay_time_s.clone()).ok();
3824    out.set_item("total", tags.total_strength()).ok();
3825    Ok(out.into_any().unbind())
3826}
3827
3828/// Select and sum `.photonSrc` group spectra for `nuclides` at `time_s`.
3829///
3830/// Parses ALARA photon-source text, keeps rows matching the named nuclides
3831/// at exactly `time_s` seconds (shutdown `0.0`), and adds them element-wise
3832/// in ALARA group order. Returns a dict with `groups` (matching
3833/// `{nuclide, time_s, strengths}` rows), `sums`, and `total`. No rescaling:
3834/// strengths keep the file's normalization. Thin wrapper over
3835/// `nucleide-r2s` `photon_groups_at` / `sum_group_strengths`.
3836#[pyfunction]
3837fn r2s_photon_group_sums(
3838    py: Python<'_>,
3839    photon_text: &str,
3840    nuclides: Vec<String>,
3841    time_s: f64,
3842) -> PyResult<Py<PyAny>> {
3843    let source = nucleide_alara_io::photon::PhotonSource::from_str(photon_text)
3844        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3845    let names: Vec<&str> = nuclides.iter().map(String::as_str).collect();
3846    let at = nucleide_r2s::tags::photon_groups_at(&source, &names, time_s);
3847    let sums = nucleide_r2s::tags::sum_group_strengths(&at)
3848        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3849    use pyo3::types::PyDict;
3850    let out = PyDict::new(py);
3851    let rows: Vec<Py<PyAny>> = at
3852        .iter()
3853        .map(|g| {
3854            let d = PyDict::new(py);
3855            d.set_item("nuclide", g.nuclide.clone()).ok();
3856            d.set_item("time_s", g.time_s).ok();
3857            d.set_item("strengths", g.strengths.clone()).ok();
3858            d.into_any().unbind()
3859        })
3860        .collect();
3861    out.set_item("groups", rows).ok();
3862    out.set_item("sums", sums.clone()).ok();
3863    out.set_item("total", sums.iter().sum::<f64>()).ok();
3864    Ok(out.into_any().unbind())
3865}
3866
3867fn snapshot_dict_str(
3868    zone: &Bound<'_, pyo3::types::PyDict>,
3869    key: &str,
3870    what: &str,
3871) -> PyResult<String> {
3872    match zone.get_item(key)? {
3873        Some(v) => v
3874            .extract()
3875            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
3876        None => Err(PyValueError::new_err(format!(
3877            "snapshot {what} missing `{key}`"
3878        ))),
3879    }
3880}
3881
3882fn snapshot_dict_opt_str(
3883    zone: &Bound<'_, pyo3::types::PyDict>,
3884    key: &str,
3885    what: &str,
3886) -> PyResult<Option<String>> {
3887    match zone.get_item(key)? {
3888        Some(v) if v.is_none() => Ok(None),
3889        Some(v) => v
3890            .extract::<String>()
3891            .map(Some)
3892            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
3893        None => Ok(None),
3894    }
3895}
3896
3897fn snapshot_dict_f64(
3898    zone: &Bound<'_, pyo3::types::PyDict>,
3899    key: &str,
3900    what: &str,
3901) -> PyResult<f64> {
3902    match zone.get_item(key)? {
3903        Some(v) => v
3904            .extract()
3905            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
3906        None => Err(PyValueError::new_err(format!(
3907            "snapshot {what} missing `{key}`"
3908        ))),
3909    }
3910}
3911
3912fn snapshot_dict_opt_f64(
3913    zone: &Bound<'_, pyo3::types::PyDict>,
3914    key: &str,
3915    what: &str,
3916) -> PyResult<Option<f64>> {
3917    match zone.get_item(key)? {
3918        Some(v) if v.is_none() => Ok(None),
3919        Some(v) => v
3920            .extract::<f64>()
3921            .map(Some)
3922            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
3923        None => Ok(None),
3924    }
3925}
3926
3927fn snapshot_zone_from_dict(
3928    zone: &Bound<'_, pyo3::types::PyDict>,
3929) -> PyResult<nucleide_r2s::snapshot::SnapshotZone> {
3930    let id = snapshot_dict_str(zone, "id", "zone")?;
3931    let volume_cm3 = snapshot_dict_f64(zone, "volume_cm3", "zone")?;
3932    let composition: BTreeMap<String, f64> = match zone.get_item("composition")? {
3933        Some(v) => v.extract().map_err(|_| {
3934            PyValueError::new_err("snapshot zone `composition` must be a dict of str to float")
3935        })?,
3936        None => return Err(PyValueError::new_err("snapshot zone missing `composition`")),
3937    };
3938    Ok(nucleide_r2s::snapshot::SnapshotZone {
3939        zone: id,
3940        volume_cm3,
3941        zbottom_cm: snapshot_dict_opt_f64(zone, "zbottom_cm", "zone")?,
3942        ztop_cm: snapshot_dict_opt_f64(zone, "ztop_cm", "zone")?,
3943        material: snapshot_dict_opt_str(zone, "material", "zone")?,
3944        xs_type: snapshot_dict_opt_str(zone, "xs_type", "zone")?,
3945        temperature_c: snapshot_dict_opt_f64(zone, "temperature_C", "zone")?,
3946        composition: composition.into_iter().collect(),
3947        flux_name: snapshot_dict_opt_str(zone, "flux", "zone")?,
3948    })
3949}
3950
3951fn snapshot_input_from_dict(
3952    snapshot: &Bound<'_, pyo3::types::PyDict>,
3953) -> PyResult<nucleide_r2s::snapshot::SnapshotInput> {
3954    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("zones")? {
3955        Some(v) => v
3956            .extract()
3957            .map_err(|_| PyValueError::new_err("snapshot `zones` must be a list of dicts"))?,
3958        None => return Err(PyValueError::new_err("snapshot missing `zones`")),
3959    };
3960    let mut zones = Vec::with_capacity(zone_dicts.len());
3961    for z in &zone_dicts {
3962        zones.push(snapshot_zone_from_dict(z)?);
3963    }
3964    let flux_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("flux_defs")? {
3965        Some(v) => v
3966            .extract()
3967            .map_err(|_| PyValueError::new_err("snapshot `flux_defs` must be a list of dicts"))?,
3968        None => return Err(PyValueError::new_err("snapshot missing `flux_defs`")),
3969    };
3970    let mut flux_defs = Vec::with_capacity(flux_dicts.len());
3971    for f in &flux_dicts {
3972        flux_defs.push(nucleide_r2s::snapshot::SnapshotFluxDef {
3973            name: snapshot_dict_str(f, "name", "flux")?,
3974            file: snapshot_dict_str(f, "file", "flux")?,
3975            scale: snapshot_dict_f64(f, "scale", "flux")?,
3976        });
3977    }
3978    let cooling_s: Vec<f64> = match snapshot.get_item("cooling_s")? {
3979        Some(v) => v
3980            .extract()
3981            .map_err(|_| PyValueError::new_err("snapshot `cooling_s` must be a list of float"))?,
3982        None => return Err(PyValueError::new_err("snapshot missing `cooling_s`")),
3983    };
3984    Ok(nucleide_r2s::snapshot::SnapshotInput {
3985        zones,
3986        flux_defs,
3987        cooling_s,
3988        schedule_text: snapshot_dict_opt_str(snapshot, "schedule_text", "snapshot")?,
3989        output: snapshot_dict_opt_str(snapshot, "output", "snapshot")?,
3990    })
3991}
3992
3993/// Build an R2S workflow bundle from a versionless ARMI DB snapshot dict.
3994///
3995/// `snapshot` mirrors `nucleide_r2s::snapshot::SnapshotInput`: `zones` (list
3996/// of `{id, volume_cm3, composition: {ARMI-name: ndens}}` with optional
3997/// `zbottom_cm`/`ztop_cm`/`material`/`xs_type`/`temperature_C`/`flux`),
3998/// `flux_defs` (list of `{name, file, scale}`), `cooling_s` (list[float]),
3999/// plus optional `schedule_text` and `output`. Returns `{workflow, deck,
4000/// decks}`: the workflow summary (same shape as `r2s_from_deck`), the
4001/// canonical template deck text, and one canonical deck text per step.
4002///
4003/// Composition keys follow the emit ARMI-input rule (post-expansion nuclide
4004/// keys; elemental keys, bare `AM242`, and unknown names are `ValueError`s);
4005/// densities are atoms/barn-cm. Empty `cooling_s` is a `ValueError` via
4006/// workflow validation. Raises `ValueError` on any invalid input or dangling
4007/// cross-reference.
4008#[pyfunction]
4009fn r2s_from_snapshot(
4010    py: Python<'_>,
4011    snapshot: &Bound<'_, pyo3::types::PyDict>,
4012) -> PyResult<Py<PyAny>> {
4013    let input = snapshot_input_from_dict(snapshot)?;
4014    let (workflow, template, decks) = py
4015        .detach(move || nucleide_r2s::snapshot::snapshot_workflow(&input))
4016        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4017    use pyo3::types::PyDict;
4018    let out = PyDict::new(py);
4019    out.set_item("workflow", r2s_workflow_to_py(py, &workflow))
4020        .ok();
4021    out.set_item("deck", template.to_string()).ok();
4022    let deck_texts: Vec<String> = decks.iter().map(ToString::to_string).collect();
4023    out.set_item("decks", deck_texts).ok();
4024    Ok(out.into_any().unbind())
4025}
4026
4027// ---------------------------------------------------------------------------
4028// 0.3.0 series driver, data accessors, list helpers
4029// ---------------------------------------------------------------------------
4030//
4031// Thin facade only (core tables and integrators live in `nucleide-nuclei` /
4032// `nucleide-material` / `nucleide-depletion`; nothing duplicated here):
4033//
4034// - `deplete_series` wraps the core `integrate` series (`predictor`/`cecm`/
4035//   `cf4`), omitting the core `t = 0` row so there is one output per step.
4036// - `simple_xs` / `scattering_length` / `decay_energy` / `decay_heat` are
4037//   thin wrappers over the vendored TSV tables + material analytics.
4038// - `MeshTally::to_list` / `totals_list` are plain-copy helpers alongside the
4039//   landed zero-copy NumPy bridge (`result_array()` / `totals_array()`):
4040//   `numpy = "0.28"` is a bindings-only
4041//   dependency (abi3-py310 inherited from the workspace PyO3).
4042
4043/// Supported `deplete_series` integrators (core `Integrator` variants).
4044fn parse_integrator(name: &str) -> PyResult<nucleide_depletion::Integrator> {
4045    use nucleide_depletion::Integrator as I;
4046    if name.eq_ignore_ascii_case("predictor") {
4047        return Ok(I::Predictor);
4048    }
4049    if name.eq_ignore_ascii_case("cecm") {
4050        return Ok(I::Cecm);
4051    }
4052    if name.eq_ignore_ascii_case("cf4") {
4053        return Ok(I::Cf4);
4054    }
4055    Err(PyValueError::new_err(format!(
4056        "unsupported integrator `{name}` (supported: predictor, cecm, cf4)"
4057    )))
4058}
4059
4060/// Solve a multi-step depletion series with the chosen core integrator.
4061///
4062/// Thin wrapper over `nucleide_depletion::integrate`: one [`Step`] per `dt`
4063/// (per-step `rates`/`rates_list`, `None` meaning decay-only), `n0` keyed by
4064/// nuclide name. `method` selects the solver kernel (`"cram16"`,
4065/// `"cram48"`, `"bateman"`, `"bateman_hp"`, default `"cram48"` — an
4066/// explicitly non-default `method` overrides `order`; Bateman steps with
4067/// live rates fall back to CRAM-48). Returns a dict with `times`
4068/// (cumulative seconds, one entry per step — the core `t = 0` initial row is
4069/// omitted so `atoms[k]` matches a single `deplete` call over `dts[k]`),
4070/// `atoms`, `activity` ([Bq]), and `decay_heat` ([W] per nuclide via the
4071/// shared chain → ENDF/B-VII.1 → 0.0 energy resolution).
4072#[pyfunction]
4073#[pyo3(signature = (chain, n0, dts, rates=None, rates_list=None, integrator="predictor", order=48, method="cram48"))]
4074#[allow(clippy::too_many_arguments)]
4075fn deplete_series(
4076    chain: &PyChain,
4077    n0: BTreeMap<String, f64>,
4078    dts: Vec<f64>,
4079    rates: Option<RateMap>,
4080    rates_list: Option<Vec<Option<RateMap>>>,
4081    integrator: &str,
4082    order: u8,
4083    method: &str,
4084) -> PyResult<Py<PyAny>> {
4085    use nucleide_depletion::{DepletionSystem, ReactionRates, Step};
4086    let integrator = parse_integrator(integrator)?;
4087    let method = resolve_method(order, method)?;
4088    if let Some(list) = &rates_list {
4089        if list.len() != dts.len() {
4090            return Err(PyValueError::new_err(format!(
4091                "rates_list has {} entries but dts has {}",
4092                list.len(),
4093                dts.len()
4094            )));
4095        }
4096    }
4097    if dts.is_empty() {
4098        return Err(PyValueError::new_err("dts must not be empty"));
4099    }
4100    // Atom vector in chain order; unknown names fail loudly like `deplete`.
4101    let mut n0_vec = vec![0.0; chain.inner.len()];
4102    for (name, value) in &n0 {
4103        let idx = chain.inner.index_of(name).ok_or_else(|| {
4104            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
4105        })?;
4106        n0_vec[idx] = *value;
4107    }
4108    let empty = BTreeMap::new();
4109    let mut steps = Vec::with_capacity(dts.len());
4110    for (i, dt) in dts.iter().enumerate() {
4111        let step_rates = rates_list
4112            .as_ref()
4113            .and_then(|list| list[i].as_ref())
4114            .or(rates.as_ref())
4115            .unwrap_or(&empty);
4116        let rs = split_rates(step_rates, &chain.inner)?;
4117        steps.push(Step::new(*dt, rs));
4118    }
4119    // Template system: `integrate` rebuilds the matrix per step from the
4120    // chain + step rates; the template's own rates are unused.
4121    let template = DepletionSystem::build((*chain.inner).clone(), &ReactionRates::new())
4122        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4123    // NOTE: plain (GIL held) call by design, matching the other CRAM
4124    // bindings; batch sizes here are small.
4125    let series =
4126        nucleide_depletion::integrate_with_method(&template, &n0_vec, &steps, integrator, method)
4127            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4128    let names: Vec<&str> = template
4129        .chain
4130        .nuclides
4131        .iter()
4132        .map(|nuc| nuc.name.as_str())
4133        .collect();
4134    let keyed = |rows: &[Vec<f64>]| -> Vec<BTreeMap<String, f64>> {
4135        rows.iter()
4136            .map(|row| {
4137                names
4138                    .iter()
4139                    .zip(row)
4140                    .map(|(name, v)| ((*name).to_string(), *v))
4141                    .collect()
4142            })
4143            .collect()
4144    };
4145    // Skip the t = 0 initial row: one output entry per requested step.
4146    let atoms = keyed(&series.atoms[1..]);
4147    let activity = keyed(&series.activity[1..]);
4148    let decay_heat = keyed(&series.decay_heat[1..]);
4149    let times = series.times[1..].to_vec();
4150    Ok(Python::attach(|py| {
4151        use pyo3::types::PyDict;
4152        let out = PyDict::new(py);
4153        out.set_item("times", &times).ok();
4154        out.set_item("atoms", &atoms).ok();
4155        out.set_item("activity", &activity).ok();
4156        out.set_item("decay_heat", &decay_heat).ok();
4157        out.into_any().unbind()
4158    }))
4159}
4160
4161/// Thermal/fast cross sections [barn] for a nuclide name.
4162///
4163/// Screening-level values from the `nucleide-nuclei` table (thermal 2200 m/s
4164/// total + 14-MeV total); `None` for nuclides outside the table.
4165#[pyfunction]
4166fn simple_xs(name: &str) -> PyResult<Option<(f64, f64)>> {
4167    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4168    Ok(nucleide_nuclei::data::simple_xs_by_name(name))
4169}
4170
4171/// Coherent scattering length [fm] for a nuclide name.
4172///
4173/// First element of the `nucleide-nuclei` (coherent, incoherent) pair;
4174/// `None` for nuclides outside the table.
4175#[pyfunction]
4176fn scattering_length(name: &str) -> PyResult<Option<f64>> {
4177    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4178    Ok(nucleide_nuclei::data::scattering_length_by_name(name).map(|(b_coh, _)| b_coh))
4179}
4180
4181/// Mean decay energy per disintegration [MeV] for a nuclide name.
4182///
4183/// Screening-level placeholder values (NOT ENSDF); `None` when unknown.
4184#[pyfunction]
4185fn decay_energy(name: &str) -> PyResult<Option<f64>> {
4186    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4187    Ok(nucleide_nuclei::data::decay_energy_mev_by_name(name))
4188}
4189
4190/// Evaluated decay branches for a nuclide name.
4191///
4192/// One `(progeny GNDS name, branching fraction, mode)` tuple per kept
4193/// ENDF/B-VIII.0 branch (SF/fission branches dropped); empty when the
4194/// nuclide has no branch rows (stable nuclides).
4195#[pyfunction]
4196fn decay_branches(name: &str) -> PyResult<Vec<(String, f64, String)>> {
4197    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4198    Ok(nucleide_nuclei::data::decay_branches_by_name(name)
4199        .unwrap_or_default()
4200        .into_iter()
4201        .map(|b| {
4202            (
4203                nucleide_nuclei::NuclideId::from_nucid(b.progeny).to_name(),
4204                b.branching_fraction,
4205                b.mode.as_str().to_string(),
4206            )
4207        })
4208        .collect())
4209}
4210
4211/// Branching fraction from parent to progeny (GNDS names), if tabulated.
4212///
4213/// Evaluated ENDF/B-VIII.0 value, verbatim; `None` when the branch is
4214/// absent (including dropped SF branches). Named apart from the
4215/// chain-scoped `branching_fraction` (which takes a chain argument).
4216#[pyfunction]
4217fn decay_branch_fraction(parent: &str, progeny: &str) -> PyResult<Option<f64>> {
4218    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4219    NuclideId::from_name(progeny).map_err(wrap_nucid_err)?;
4220    Ok(nucleide_nuclei::data::branching_fraction_by_name(
4221        parent, progeny,
4222    ))
4223}
4224
4225/// Evaluated fission product yields for a nuclide name.
4226///
4227/// One `(energy_eV, [(daughter GNDS name, yield, uncertainty), ...])` tuple
4228/// per incident-energy set, lowest energy first; empty when the parent has
4229/// no evaluation for the requested `origin`/`kind`. `origin` is `n`
4230/// (neutron-induced, default) or `sf` (spontaneous); `kind` is
4231/// `independent` (MF8/MT454, default — what depletion consumes) or
4232/// `cumulative` (MF8/MT459). An uncertainty of `0.0` means the tape
4233/// evaluates none (the zero-yield rows of this sublibrary).
4234type PyFissionYieldSets = Vec<(f64, Vec<(String, f64, f64)>)>;
4235
4236#[pyfunction]
4237#[pyo3(signature = (parent, origin="n", kind="independent"))]
4238fn fission_yields(parent: &str, origin: &str, kind: &str) -> PyResult<PyFissionYieldSets> {
4239    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4240    let origin = nucleide_nuclei::data::FissionYieldOrigin::parse(origin).ok_or_else(|| {
4241        PyValueError::new_err(format!(
4242            "unknown fission-yield origin `{origin}` (expected `n` or `sf`)"
4243        ))
4244    })?;
4245    let kind = nucleide_nuclei::data::FissionYieldKind::parse(kind).ok_or_else(|| {
4246        PyValueError::new_err(format!(
4247            "unknown fission-yield kind `{kind}` (expected `independent` or `cumulative`)"
4248        ))
4249    })?;
4250    Ok(
4251        nucleide_nuclei::data::fission_yields_by_name(parent, origin, kind)
4252            .unwrap_or_default()
4253            .into_iter()
4254            .map(|set| {
4255                (
4256                    set.energy_ev,
4257                    set.products
4258                        .into_iter()
4259                        .map(|p| {
4260                            (
4261                                nucleide_nuclei::NuclideId::from_nucid(p.progeny).to_name(),
4262                                p.yield_fraction,
4263                                p.uncertainty,
4264                            )
4265                        })
4266                        .collect(),
4267                )
4268            })
4269            .collect(),
4270    )
4271}
4272
4273/// Independent neutron-induced fission yield of one daughter (GNDS names).
4274///
4275/// Uses the parent's lowest-energy yield set — the OpenMC
4276/// `get_default_fission_yields` depletion convention. `None` when either
4277/// nuclide is outside the table; the uncertainty and the other energy sets
4278/// are available through `fission_yields`.
4279#[pyfunction]
4280fn fission_yield(parent: &str, progeny: &str) -> PyResult<Option<f64>> {
4281    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4282    NuclideId::from_name(progeny).map_err(wrap_nucid_err)?;
4283    Ok(nucleide_nuclei::data::fission_yield_by_name(
4284        parent, progeny,
4285    ))
4286}
4287
4288/// Normalize a nuclide name in any accepted dialect to canonical GNDS form.
4289///
4290/// Accepts symbol-first (`Pu241`, `Pu-241`, `Ba137m`), mass-first (`241Pu`,
4291/// `40K`), and isomer suffix letters (`Ir-192n` → second isomer); see
4292/// `nucleide_nuclei::dialects::normalize_nuclide_name`.
4293#[pyfunction]
4294fn normalize_nuclide(name: &str) -> PyResult<String> {
4295    Ok(nucleide_nuclei::dialects::normalize_nuclide_name(name)
4296        .map_err(|e| PyValueError::new_err(e.to_string()))?
4297        .to_name())
4298}
4299
4300/// Decay heat [W] of a composition dict ({nuclide name: grams}).
4301///
4302/// Screening-level estimate via `Material::total_decay_heat` (Ame2020 masses,
4303/// ENDF/B-VIII.0 decay constants, placeholder decay energies). Stable
4304/// nuclides (known mass, no decay constant) contribute 0. Errors when a
4305/// nuclide lacks mass data, or a radioactive nuclide lacks energy data.
4306#[pyfunction]
4307fn decay_heat(comp: BTreeMap<String, f64>) -> PyResult<f64> {
4308    let mat = comp_to_material(comp)?;
4309    let analytics = nucleide_material::Analytics {
4310        masses: &nucleide_material::Ame2020,
4311        decays: &nucleide_material::ChainDecays,
4312    };
4313    mat.total_decay_heat(&analytics, &nucleide_material::DecayEnergies)
4314        .map_err(|e| PyValueError::new_err(e.to_string()))
4315}
4316
4317fn parse_dose_pathway(s: &str) -> PyResult<nucleide_material::DosePathway> {
4318    nucleide_material::DosePathway::parse(s).ok_or_else(|| {
4319        PyValueError::new_err(format!(
4320            "unknown dose pathway `{s}` (supported: air, soil, ingest, inhale)"
4321        ))
4322    })
4323}
4324
4325fn parse_dose_source(s: &str) -> PyResult<nucleide_material::DoseSource> {
4326    nucleide_material::DoseSource::parse(s).ok_or_else(|| {
4327        PyValueError::new_err(format!(
4328            "unknown dose source `{s}` (supported: EPA, DOE, GENII)"
4329        ))
4330    })
4331}
4332
4333/// Raw dose factor for a nuclide name, pathway, and source.
4334///
4335/// Pathway is one of `air`/`soil`/`ingest`/`inhale` (`ext_air`/`ext_soil`
4336/// aliases accepted); source is one of `EPA`/`DOE`/`GENII` (default `EPA`,
4337/// matching PyNE source id 0). Returns `None` when the nuclide has no row;
4338/// GENII/DOE air resolve to `-1.0` (PyNE missing-air sentinel).
4339#[pyfunction]
4340#[pyo3(signature = (name, pathway, source="EPA"))]
4341fn dose_factor(name: &str, pathway: &str, source: &str) -> PyResult<Option<f64>> {
4342    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4343    let p = parse_dose_pathway(pathway)?;
4344    let s = parse_dose_source(source)?;
4345    Ok(nucleide_nuclei::data::dose_factor_by_name(name, p, s))
4346}
4347
4348/// Total dose per gram of a composition dict ({nuclide name: grams}).
4349///
4350/// Thin wrapper over `Material::total_dose_per_g` (Ame2020 masses,
4351/// ENDF/B-VIII.0 decay constants, HNF-5636/PyNE dose factors). Pathway is one
4352/// of `air`/`soil`/`ingest`/`inhale`; source is `EPA`/`DOE`/`GENII` (default
4353/// `EPA`). Units follow the table: air `mrem/h per g per m^3`, soil
4354/// `mrem/h per g per m^2`, ingest/inhale `mrem per g`. Screening-level only —
4355/// not for safety decisions. Stable nuclides (known mass, no decay constant)
4356/// contribute 0 without a dose-factor lookup. Errors when a nuclide lacks
4357/// mass data, or a radioactive nuclide lacks dose data (including `-1`
4358/// GENII/DOE air sentinels).
4359#[pyfunction]
4360#[pyo3(signature = (comp, pathway, source="EPA"))]
4361fn dose_per_g(comp: BTreeMap<String, f64>, pathway: &str, source: &str) -> PyResult<f64> {
4362    let mat = comp_to_material(comp)?;
4363    let analytics = nucleide_material::Analytics {
4364        masses: &nucleide_material::Ame2020,
4365        decays: &nucleide_material::ChainDecays,
4366    };
4367    let p = parse_dose_pathway(pathway)?;
4368    let s = parse_dose_source(source)?;
4369    mat.total_dose_per_g(&analytics, &nucleide_material::DoseFactors, p, s)
4370        .map_err(|e| PyValueError::new_err(e.to_string()))
4371}
4372
4373/// Split a composition dict into product and tails dicts by per-nuclide
4374/// separation efficiency.
4375///
4376/// `comp` maps nuclide names to grams; `effs` maps nuclide names to
4377/// efficiencies in `[0, 1]` (unlisted nuclides go entirely to tails).
4378/// Returns `(product, tails)` with per-nuclide mass conserved. Thin wrapper
4379/// over `Material::separate`.
4380#[pyfunction]
4381#[allow(clippy::type_complexity)]
4382fn separate_material(
4383    comp: BTreeMap<String, f64>,
4384    effs: BTreeMap<String, f64>,
4385) -> PyResult<(BTreeMap<String, f64>, BTreeMap<String, f64>)> {
4386    let mat = comp_to_material(comp)?;
4387    let mut table = Vec::with_capacity(effs.len());
4388    for (name, eff) in &effs {
4389        let id = NuclideId::from_name(name)
4390            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
4391        table.push((id, *eff));
4392    }
4393    let (product, tails) = mat
4394        .separate(&table)
4395        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4396    let named =
4397        |m: nucleide_material::Material| m.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
4398    Ok((named(product), named(tails)))
4399}
4400
4401/// Blend composition dicts at fixed ratios with explicit normalization.
4402///
4403/// `parts` holds `(comp, ratio)` pairs; ratios are normalized by their sum
4404/// and the output is the weighted average. Errors on empty, all-zero, or
4405/// negative ratios (never a silent uniform split). Thin wrapper over
4406/// `Material::blend`.
4407#[pyfunction]
4408fn blend_material(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
4409    let mats: Vec<nucleide_material::Material> = parts
4410        .iter()
4411        .map(|(comp, _)| comp_to_material(comp.clone()))
4412        .collect::<PyResult<_>>()?;
4413    let refs: Vec<(&nucleide_material::Material, f64)> =
4414        mats.iter().zip(parts.iter().map(|(_, r)| *r)).collect();
4415    let out = nucleide_material::Material::blend(&refs)
4416        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4417    Ok(out.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect())
4418}
4419
4420/// One-sided upper Page CUSUM change detector with Welford statistics.
4421///
4422/// Thin stateful wrapper over `nucleide_material::Cusum`: `update(x)`
4423/// feeds one observation and returns the alarm status; `status()` reads it
4424/// without consuming input; `statistic()` reads the CUSUM value;
4425/// `reset()` drops all observations (tuning kept). Non-finite inputs to
4426/// `update` are ignored.
4427#[pyclass(name = "Cusum")]
4428struct PyCusum {
4429    inner: nucleide_material::Cusum,
4430}
4431
4432#[pymethods]
4433impl PyCusum {
4434    /// Build a detector (`ref_shift_k = 0.5`, `alarm_h = 4.0`,
4435    /// `startup = 10` by default).
4436    #[new]
4437    #[pyo3(signature = (ref_shift_k=0.5, alarm_h=4.0, startup=10))]
4438    fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> PyResult<Self> {
4439        nucleide_material::Cusum::new(ref_shift_k, alarm_h, startup)
4440            .map(|inner| Self { inner })
4441            .map_err(|e| PyValueError::new_err(e.to_string()))
4442    }
4443
4444    /// Feed one observation; returns the resulting alarm status.
4445    fn update(&mut self, x: f64) -> bool {
4446        self.inner.update(x)
4447    }
4448
4449    /// Whether the detector is currently alarmed.
4450    fn status(&self) -> bool {
4451        self.inner.status()
4452    }
4453
4454    /// Current CUSUM statistic (`>= 0`).
4455    fn statistic(&self) -> f64 {
4456        self.inner.statistic()
4457    }
4458
4459    /// Running observation count.
4460    fn count(&self) -> usize {
4461        self.inner.count()
4462    }
4463
4464    /// Running mean of the observations seen so far.
4465    fn mean(&self) -> f64 {
4466        self.inner.mean()
4467    }
4468
4469    /// Running sample variance (`0` with fewer than 2 points).
4470    fn variance(&self) -> f64 {
4471        self.inner.variance()
4472    }
4473
4474    /// Running sample standard deviation.
4475    fn std(&self) -> f64 {
4476        self.inner.std()
4477    }
4478
4479    /// Drop all observations; tuning parameters are kept.
4480    fn reset(&mut self) {
4481        self.inner.reset();
4482    }
4483}
4484
4485// ---------------------------------------------------------------------------
4486// 0.3.0 Tier 1: deck round-trip, decay inventories, ARMI dialects, checks
4487// ---------------------------------------------------------------------------
4488
4489/// A parsed MCNP input deck with format-preserving write-back.
4490#[pyclass(name = "DeckProblem")]
4491struct PyDeckProblem {
4492    inner: std::sync::Mutex<nucleide_mcnp_io::problem::DeckProblem>,
4493}
4494
4495fn deck_cell_dict(cell: &nucleide_mcnp_io::cell::CellCard) -> BTreeMap<String, String> {
4496    let mut d = BTreeMap::new();
4497    d.insert("num".to_string(), cell.num.to_string());
4498    d.insert("mat".to_string(), cell.mat.to_string());
4499    d.insert(
4500        "dens".to_string(),
4501        cell.dens.map(|v| v.to_string()).unwrap_or_default(),
4502    );
4503    d.insert("geom".to_string(), cell.geom.render());
4504    d.insert("params".to_string(), cell.params.join(" "));
4505    d
4506}
4507
4508#[pymethods]
4509impl PyDeckProblem {
4510    /// Parse a deck from text.
4511    #[staticmethod]
4512    fn loads(text: &str) -> PyResult<Self> {
4513        nucleide_mcnp_io::problem::parse_deck(text)
4514            .map(|inner| Self {
4515                inner: std::sync::Mutex::new(inner),
4516            })
4517            .map_err(|e| PyValueError::new_err(e.to_string()))
4518    }
4519
4520    /// Message (first) line.
4521    #[getter]
4522    fn message(&self) -> PyResult<String> {
4523        Ok(self
4524            .inner
4525            .lock()
4526            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4527            .message
4528            .clone())
4529    }
4530
4531    /// Title card (second line).
4532    #[getter]
4533    fn title(&self) -> PyResult<String> {
4534        Ok(self
4535            .inner
4536            .lock()
4537            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4538            .title
4539            .clone())
4540    }
4541
4542    /// Cell cards as `{num, mat, dens, geom, params}` dicts (`dens` is `""`
4543    /// for void cells).
4544    #[getter]
4545    fn cells(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4546        Ok(self
4547            .inner
4548            .lock()
4549            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4550            .cells
4551            .iter()
4552            .map(deck_cell_dict)
4553            .collect())
4554    }
4555
4556    /// Surface cards as `{num, reflecting, transform, periodic, kind, coeffs}`
4557    /// dicts (`transform`/`periodic` are `""` when absent).
4558    #[getter]
4559    fn surfs(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4560        Ok(self
4561            .inner
4562            .lock()
4563            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4564            .surfs
4565            .iter()
4566            .map(|s| {
4567                let mut d = BTreeMap::new();
4568                d.insert("num".to_string(), s.num.to_string());
4569                d.insert("reflecting".to_string(), s.reflecting.to_string());
4570                d.insert(
4571                    "transform".to_string(),
4572                    s.transform.map(|v| v.to_string()).unwrap_or_default(),
4573                );
4574                d.insert(
4575                    "periodic".to_string(),
4576                    s.periodic.map(|v| v.to_string()).unwrap_or_default(),
4577                );
4578                d.insert("kind".to_string(), s.kind.keyword().to_string());
4579                d.insert(
4580                    "coeffs".to_string(),
4581                    s.coeffs
4582                        .iter()
4583                        .map(|v| v.to_string())
4584                        .collect::<Vec<_>>()
4585                        .join(" "),
4586                );
4587                d
4588            })
4589            .collect())
4590    }
4591
4592    /// Material numbers in file order.
4593    #[getter]
4594    fn material_numbers(&self) -> PyResult<Vec<u32>> {
4595        Ok(self
4596            .inner
4597            .lock()
4598            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4599            .materials
4600            .iter()
4601            .map(|m| m.number)
4602            .collect())
4603    }
4604
4605    /// Data-card names in file order (`MODE`, `M1`, `KCODE`, ...).
4606    #[getter]
4607    fn data_names(&self) -> PyResult<Vec<String>> {
4608        Ok(self
4609            .inner
4610            .lock()
4611            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4612            .data
4613            .iter()
4614            .map(|d| d.name.clone())
4615            .collect())
4616    }
4617
4618    /// Serialize back to MCNP input text (byte-identical when unedited).
4619    fn dumps(&self) -> PyResult<String> {
4620        let guard = self
4621            .inner
4622            .lock()
4623            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
4624        Ok(nucleide_mcnp_io::problem::write_deck(&guard))
4625    }
4626
4627    /// Set a cell's density (re-renders that card canonically).
4628    fn set_cell_density(&self, cell: u32, dens: f64) -> PyResult<()> {
4629        self.inner
4630            .lock()
4631            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4632            .set_cell_density(cell, dens)
4633            .map_err(|e| PyValueError::new_err(e.to_string()))
4634    }
4635
4636    /// Set a cell's material number (re-renders that card canonically).
4637    fn set_cell_material(&self, cell: u32, mat: u32) -> PyResult<()> {
4638        self.inner
4639            .lock()
4640            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4641            .set_cell_material(cell, mat)
4642            .map_err(|e| PyValueError::new_err(e.to_string()))
4643    }
4644
4645    /// Typed `MODE` card as `{particles}` (`particles` is space-joined).
4646    #[getter]
4647    fn mode(&self) -> PyResult<BTreeMap<String, String>> {
4648        let mode = self
4649            .inner
4650            .lock()
4651            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4652            .mode()
4653            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4654        let mut d = BTreeMap::new();
4655        d.insert("particles".to_string(), mode.particles.join(" "));
4656        Ok(d)
4657    }
4658
4659    /// Typed `TRn` cards as `{number, displacement, rotation, in_degrees,
4660    /// main_to_aux, hidden}` dicts (vectors are space-joined).
4661    #[getter]
4662    fn transforms(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4663        let transforms = self
4664            .inner
4665            .lock()
4666            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4667            .transforms()
4668            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4669        Ok(transforms
4670            .iter()
4671            .map(|t| {
4672                let mut d = BTreeMap::new();
4673                d.insert("number".to_string(), t.number.to_string());
4674                d.insert(
4675                    "displacement".to_string(),
4676                    t.displacement
4677                        .iter()
4678                        .map(|v| v.to_string())
4679                        .collect::<Vec<_>>()
4680                        .join(" "),
4681                );
4682                d.insert(
4683                    "rotation".to_string(),
4684                    t.rotation
4685                        .iter()
4686                        .map(|v| v.to_string())
4687                        .collect::<Vec<_>>()
4688                        .join(" "),
4689                );
4690                d.insert("in_degrees".to_string(), t.is_in_degrees.to_string());
4691                d.insert("main_to_aux".to_string(), t.is_main_to_aux.to_string());
4692                d.insert("hidden".to_string(), t.hidden.to_string());
4693                d
4694            })
4695            .collect())
4696    }
4697
4698    /// Auto-created universes as `{number, cells, not_truncated}` dicts
4699    /// (cell lists are space-joined).
4700    #[getter]
4701    fn universes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4702        let universes = self
4703            .inner
4704            .lock()
4705            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4706            .universes()
4707            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4708        Ok(universes
4709            .iter()
4710            .map(|u| {
4711                let mut d = BTreeMap::new();
4712                d.insert("number".to_string(), u.number.to_string());
4713                d.insert(
4714                    "cells".to_string(),
4715                    u.cells
4716                        .iter()
4717                        .map(|v| v.to_string())
4718                        .collect::<Vec<_>>()
4719                        .join(" "),
4720                );
4721                d.insert(
4722                    "not_truncated".to_string(),
4723                    u.not_truncated
4724                        .iter()
4725                        .map(|v| v.to_string())
4726                        .collect::<Vec<_>>()
4727                        .join(" "),
4728                );
4729                d
4730            })
4731            .collect())
4732    }
4733
4734    /// Cell `LAT` assignments as `{cell, lattice}` dicts.
4735    #[getter]
4736    fn lattices(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4737        let lattices = self
4738            .inner
4739            .lock()
4740            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4741            .lattices()
4742            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4743        Ok(lattices
4744            .iter()
4745            .map(|l| {
4746                let mut d = BTreeMap::new();
4747                d.insert("cell".to_string(), l.cell.to_string());
4748                d.insert("lattice".to_string(), l.lattice.to_string());
4749                d
4750            })
4751            .collect())
4752    }
4753
4754    /// Cell `FILL` assignments as `{cell, kind, universe, min_index,
4755    /// max_index, universes, transform, hidden_transform, in_degrees}` dicts
4756    /// (`kind` is `single` or `matrix`; matrix empties render as `-`).
4757    #[getter]
4758    fn fills(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4759        use nucleide_mcnp_io::semantic::{FillTarget, FillTransform};
4760        let fills = self
4761            .inner
4762            .lock()
4763            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4764            .fills()
4765            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4766        Ok(fills
4767            .iter()
4768            .map(|f| {
4769                let mut d = BTreeMap::new();
4770                d.insert("cell".to_string(), f.cell.to_string());
4771                match &f.target {
4772                    FillTarget::Single(u) => {
4773                        d.insert("kind".to_string(), "single".to_string());
4774                        d.insert("universe".to_string(), u.to_string());
4775                        d.insert("min_index".to_string(), String::new());
4776                        d.insert("max_index".to_string(), String::new());
4777                        d.insert("universes".to_string(), String::new());
4778                    }
4779                    FillTarget::Matrix {
4780                        min_index,
4781                        max_index,
4782                        universes,
4783                    } => {
4784                        d.insert("kind".to_string(), "matrix".to_string());
4785                        d.insert("universe".to_string(), String::new());
4786                        d.insert(
4787                            "min_index".to_string(),
4788                            min_index
4789                                .iter()
4790                                .map(|v| v.to_string())
4791                                .collect::<Vec<_>>()
4792                                .join(" "),
4793                        );
4794                        d.insert(
4795                            "max_index".to_string(),
4796                            max_index
4797                                .iter()
4798                                .map(|v| v.to_string())
4799                                .collect::<Vec<_>>()
4800                                .join(" "),
4801                        );
4802                        d.insert(
4803                            "universes".to_string(),
4804                            universes
4805                                .iter()
4806                                .map(|u| {
4807                                    u.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
4808                                })
4809                                .collect::<Vec<_>>()
4810                                .join(" "),
4811                        );
4812                    }
4813                }
4814                match &f.transform {
4815                    None => {
4816                        d.insert("transform".to_string(), String::new());
4817                        d.insert("hidden_transform".to_string(), String::new());
4818                    }
4819                    Some(FillTransform::Reference(n)) => {
4820                        d.insert("transform".to_string(), n.to_string());
4821                        d.insert("hidden_transform".to_string(), String::new());
4822                    }
4823                    Some(FillTransform::Hidden(t)) => {
4824                        d.insert("transform".to_string(), String::new());
4825                        let mut coords: Vec<String> =
4826                            t.displacement.iter().map(|v| v.to_string()).collect();
4827                        coords.extend(t.rotation.iter().map(|v| v.to_string()));
4828                        d.insert("hidden_transform".to_string(), coords.join(" "));
4829                    }
4830                }
4831                d.insert("in_degrees".to_string(), f.in_degrees.to_string());
4832                d
4833            })
4834            .collect())
4835    }
4836
4837    /// Cell importance entries as `{cell, particle, value}` dicts.
4838    #[getter]
4839    fn importances(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4840        let importances = self
4841            .inner
4842            .lock()
4843            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4844            .importances()
4845            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4846        Ok(importances
4847            .iter()
4848            .map(|v| {
4849                let mut d = BTreeMap::new();
4850                d.insert("cell".to_string(), v.cell.to_string());
4851                d.insert("particle".to_string(), v.particle.clone());
4852                d.insert("value".to_string(), v.value.to_string());
4853                d
4854            })
4855            .collect())
4856    }
4857
4858    /// Manual cell volumes as `{cell, volume}` dicts.
4859    #[getter]
4860    fn volumes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4861        let volumes = self
4862            .inner
4863            .lock()
4864            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4865            .volumes()
4866            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4867        Ok(volumes
4868            .iter()
4869            .map(|v| {
4870                let mut d = BTreeMap::new();
4871                d.insert("cell".to_string(), v.cell.to_string());
4872                d.insert("volume".to_string(), v.volume.to_string());
4873                d
4874            })
4875            .collect())
4876    }
4877
4878    /// Typed tallies as `{number, type, particles, entries, fm, e_bins}` dicts
4879    /// (lists are space-joined, absent groups are `""`).
4880    #[getter]
4881    fn tallies(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4882        let tallies = self
4883            .inner
4884            .lock()
4885            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4886            .tallies()
4887            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4888        Ok(tallies
4889            .iter()
4890            .map(|t| {
4891                let mut d = BTreeMap::new();
4892                d.insert("number".to_string(), t.number.to_string());
4893                d.insert("type".to_string(), t.tally_type.to_string());
4894                d.insert("particles".to_string(), t.particles.join(","));
4895                d.insert("entries".to_string(), t.entries.join(" "));
4896                d.insert("fm".to_string(), t.fm.clone().unwrap_or_default().join(" "));
4897                d.insert(
4898                    "e_bins".to_string(),
4899                    t.e_bins.clone().unwrap_or_default().join(" "),
4900                );
4901                d
4902            })
4903            .collect())
4904    }
4905
4906    /// Validate every L3 semantic rule (duplicate numbers, dangling links,
4907    /// redundant definitions, write-time state, lattice/fill cross-checks).
4908    fn validate(&self) -> PyResult<()> {
4909        self.inner
4910            .lock()
4911            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4912            .validate()
4913            .map_err(|e| PyValueError::new_err(e.to_string()))
4914    }
4915
4916    /// Non-fatal validation notes (particle/mode mismatches).
4917    fn validation_notes(&self) -> PyResult<Vec<String>> {
4918        Ok(self
4919            .inner
4920            .lock()
4921            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4922            .validation_notes())
4923    }
4924
4925    /// Set the `MODE` card particles (re-renders that card canonically).
4926    fn set_mode(&self, particles: Vec<String>) -> PyResult<()> {
4927        self.inner
4928            .lock()
4929            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4930            .set_mode(particles)
4931            .map_err(|e| PyValueError::new_err(e.to_string()))
4932    }
4933
4934    /// Set a cell's universe (`not_truncated` writes `U=-n`).
4935    fn set_cell_universe(&self, cell: u32, universe: u32, not_truncated: bool) -> PyResult<()> {
4936        self.inner
4937            .lock()
4938            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4939            .set_cell_universe(cell, universe, not_truncated)
4940            .map_err(|e| PyValueError::new_err(e.to_string()))
4941    }
4942
4943    /// Set (`1`/`2`) or clear (`None`) a cell's lattice.
4944    fn set_cell_lattice(&self, cell: u32, lattice: Option<u8>) -> PyResult<()> {
4945        self.inner
4946            .lock()
4947            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4948            .set_cell_lattice(cell, lattice)
4949            .map_err(|e| PyValueError::new_err(e.to_string()))
4950    }
4951
4952    /// Set a cell's fill to a single universe.
4953    fn set_cell_fill(&self, cell: u32, universe: u32) -> PyResult<()> {
4954        self.inner
4955            .lock()
4956            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4957            .set_cell_fill(cell, universe)
4958            .map_err(|e| PyValueError::new_err(e.to_string()))
4959    }
4960}
4961
4962/// Parse an MCNP input deck file into a [`PyDeckProblem`].
4963#[pyfunction]
4964fn read_deck(path: &str) -> PyResult<PyDeckProblem> {
4965    nucleide_mcnp_io::problem::parse_deck_file(path)
4966        .map(|inner| PyDeckProblem {
4967            inner: std::sync::Mutex::new(inner),
4968        })
4969        .map_err(|e| PyValueError::new_err(e.to_string()))
4970}
4971
4972/// Parse MCNP input deck text into a [`PyDeckProblem`].
4973#[pyfunction]
4974fn parse_deck(text: &str) -> PyResult<PyDeckProblem> {
4975    PyDeckProblem::loads(text)
4976}
4977
4978/// A unit-aware decay inventory over a depletion chain.
4979#[pyclass(name = "Inventory")]
4980struct PyInventory {
4981    chain: std::sync::Arc<nucleide_depletion::Chain>,
4982    atoms: BTreeMap<String, f64>,
4983}
4984
4985fn inventory_sys(
4986    chain: &nucleide_depletion::Chain,
4987    rates: &RateMap,
4988) -> PyResult<nucleide_depletion::DepletionSystem> {
4989    let rs = split_rates(rates, chain)?;
4990    nucleide_depletion::DepletionSystem::build(chain.clone(), &rs)
4991        .map_err(|e| PyValueError::new_err(e.to_string()))
4992}
4993
4994fn parse_quantity_unit(unit: &str) -> PyResult<nucleide_depletion::QuantityUnit> {
4995    nucleide_depletion::QuantityUnit::from_str(unit)
4996        .map_err(|e| PyValueError::new_err(format!("{e:?}")))
4997}
4998
4999#[pymethods]
5000impl PyInventory {
5001    /// Build from quantities in `units` (atom counts, `Bq`/`Ci` activity,
5002    /// `g`/`kg` mass, `mol`, ... — see `QuantityUnit`).
5003    #[new]
5004    #[pyo3(signature = (chain, comp, units="atoms"))]
5005    fn new(chain: &PyChain, comp: BTreeMap<String, f64>, units: &str) -> PyResult<Self> {
5006        let unit = parse_quantity_unit(units)?;
5007        let sys = inventory_sys(&chain.inner, &BTreeMap::new())?;
5008        let inv = nucleide_depletion::DecayInventory::from_units(&comp, unit, &sys)
5009            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5010        Ok(Self {
5011            chain: chain.inner.clone(),
5012            atoms: inv.atoms,
5013        })
5014    }
5015
5016    /// Atom counts by nuclide name.
5017    fn numbers(&self) -> BTreeMap<String, f64> {
5018        self.atoms.clone()
5019    }
5020
5021    /// Decay over `dt` in `time_unit` (`s`, `m`, `h`, `d`, `y`); optional
5022    /// one-group `rates` (`"Name:reaction"` keys), CRAM `order`, and solver
5023    /// `method` (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
5024    /// default `"cram48"` — an explicitly non-default `method` overrides
5025    /// `order`). Unlike the decay-only core inventory, this honors `rates`;
5026    /// a Bateman `method` with live rates falls back to CRAM-48.
5027    #[pyo3(signature = (dt, time_unit="s", rates=None, order=48, method="cram48"))]
5028    fn decay(
5029        &self,
5030        dt: f64,
5031        time_unit: &str,
5032        rates: Option<RateMap>,
5033        order: u8,
5034        method: &str,
5035    ) -> PyResult<Self> {
5036        let method = resolve_method(order, method)?;
5037        let unit = nucleide_depletion::inventory::time_unit_from_str(time_unit)
5038            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5039        let seconds = dt * unit.as_seconds();
5040        let empty = BTreeMap::new();
5041        let step_rates = rates.as_ref().unwrap_or(&empty);
5042        let template = inventory_sys(&self.chain, step_rates)?;
5043        // Route through the core series: predictor over one step equals the
5044        // single-kernel solve, and rates/method stay honored.
5045        let steps = vec![nucleide_depletion::Step::new(
5046            seconds,
5047            split_rates(step_rates, &self.chain)?,
5048        )];
5049        let series = nucleide_depletion::integrate_with_method(
5050            &template,
5051            &chain_vec(&self.chain, &self.atoms)?,
5052            &steps,
5053            nucleide_depletion::Integrator::Predictor,
5054            method,
5055        )
5056        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5057        let names: Vec<String> = self.chain.nuclides.iter().map(|n| n.name.clone()).collect();
5058        let atoms = names
5059            .iter()
5060            .zip(series.atoms.last().cloned().unwrap_or_default())
5061            .map(|(n, v)| (n.clone(), v))
5062            .collect();
5063        Ok(Self {
5064            chain: self.chain.clone(),
5065            atoms,
5066        })
5067    }
5068
5069    /// Activity per nuclide in `units`.
5070    fn activities(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5071        let unit = parse_quantity_unit(units)?;
5072        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5073        let inv = nucleide_depletion::DecayInventory {
5074            atoms: self.atoms.clone(),
5075        };
5076        inv.activities(&sys, unit)
5077            .map_err(|e| PyValueError::new_err(e.to_string()))
5078    }
5079
5080    /// Mass per nuclide in `units`.
5081    fn masses(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5082        let unit = parse_quantity_unit(units)?;
5083        let inv = nucleide_depletion::DecayInventory {
5084            atoms: self.atoms.clone(),
5085        };
5086        inv.masses(unit)
5087            .map_err(|e| PyValueError::new_err(e.to_string()))
5088    }
5089
5090    /// Moles per nuclide in `units`.
5091    fn moles(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5092        let unit = parse_quantity_unit(units)?;
5093        let inv = nucleide_depletion::DecayInventory {
5094            atoms: self.atoms.clone(),
5095        };
5096        inv.moles(unit)
5097            .map_err(|e| PyValueError::new_err(e.to_string()))
5098    }
5099
5100    /// Activity fractions by nuclide name.
5101    fn activity_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5102        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5103        let inv = nucleide_depletion::DecayInventory {
5104            atoms: self.atoms.clone(),
5105        };
5106        inv.activity_fractions(&sys)
5107            .map_err(|e| PyValueError::new_err(e.to_string()))
5108    }
5109
5110    /// Mass fractions by nuclide name.
5111    fn mass_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5112        let inv = nucleide_depletion::DecayInventory {
5113            atoms: self.atoms.clone(),
5114        };
5115        inv.mass_fractions()
5116            .map_err(|e| PyValueError::new_err(e.to_string()))
5117    }
5118
5119    /// Mole fractions by nuclide name.
5120    fn mole_fractions(&self) -> BTreeMap<String, f64> {
5121        nucleide_depletion::DecayInventory {
5122            atoms: self.atoms.clone(),
5123        }
5124        .mole_fractions()
5125    }
5126
5127    /// Human-readable half-lives (`"3.2 d"`, `"stable"`, `"unknown"`).
5128    fn half_lives_readable(&self) -> BTreeMap<String, String> {
5129        nucleide_depletion::DecayInventory {
5130            atoms: self.atoms.clone(),
5131        }
5132        .half_lives_readable()
5133    }
5134
5135    /// Add two inventories (atom counts sum).
5136    fn add(&self, other: &Self) -> Self {
5137        let a = nucleide_depletion::DecayInventory {
5138            atoms: self.atoms.clone(),
5139        };
5140        let b = nucleide_depletion::DecayInventory {
5141            atoms: other.atoms.clone(),
5142        };
5143        Self {
5144            chain: self.chain.clone(),
5145            atoms: a.add(&b).atoms,
5146        }
5147    }
5148
5149    /// Subtract (clamped at zero).
5150    fn sub(&self, other: &Self) -> Self {
5151        let a = nucleide_depletion::DecayInventory {
5152            atoms: self.atoms.clone(),
5153        };
5154        let b = nucleide_depletion::DecayInventory {
5155            atoms: other.atoms.clone(),
5156        };
5157        Self {
5158            chain: self.chain.clone(),
5159            atoms: a.sub(&b).atoms,
5160        }
5161    }
5162
5163    /// Scale by a scalar.
5164    fn mul(&self, scalar: f64) -> Self {
5165        let a = nucleide_depletion::DecayInventory {
5166            atoms: self.atoms.clone(),
5167        };
5168        Self {
5169            chain: self.chain.clone(),
5170            atoms: a.mul(scalar).atoms,
5171        }
5172    }
5173
5174    /// Divide by a scalar.
5175    fn div(&self, scalar: f64) -> Self {
5176        let a = nucleide_depletion::DecayInventory {
5177            atoms: self.atoms.clone(),
5178        };
5179        Self {
5180            chain: self.chain.clone(),
5181            atoms: a.div(scalar).atoms,
5182        }
5183    }
5184
5185    /// Serialize as `nuclide,atoms` CSV rows.
5186    fn to_csv(&self) -> String {
5187        nucleide_depletion::DecayInventory {
5188            atoms: self.atoms.clone(),
5189        }
5190        .to_csv()
5191    }
5192
5193    /// Parse `to_csv` output back into an inventory over `chain`.
5194    #[staticmethod]
5195    fn from_csv(chain: &PyChain, text: &str) -> PyResult<Self> {
5196        // Validate names against the chain (core from_csv is chain-free).
5197        let inv = nucleide_depletion::DecayInventory::from_csv(text)
5198            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5199        for name in inv.atoms.keys() {
5200            if chain.inner.index_of(name).is_none() {
5201                return Err(PyValueError::new_err(format!(
5202                    "unknown nuclide `{name}` for this chain"
5203                )));
5204            }
5205        }
5206        Ok(Self {
5207            chain: chain.inner.clone(),
5208            atoms: inv.atoms,
5209        })
5210    }
5211}
5212
5213/// Atom vector in chain order for an inventory map (unknown names error).
5214fn chain_vec(
5215    chain: &nucleide_depletion::Chain,
5216    atoms: &BTreeMap<String, f64>,
5217) -> PyResult<Vec<f64>> {
5218    let mut vec = vec![0.0; chain.len()];
5219    for (name, value) in atoms {
5220        let idx = chain.index_of(name).ok_or_else(|| {
5221            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
5222        })?;
5223        vec[idx] = *value;
5224    }
5225    Ok(vec)
5226}
5227
5228/// Time-integrated decays per nuclide over one step (chain order → names).
5229#[pyfunction]
5230#[pyo3(signature = (chain, n0, dt, rates=None))]
5231fn cumulative_decays(
5232    chain: &PyChain,
5233    n0: BTreeMap<String, f64>,
5234    dt: f64,
5235    rates: Option<RateMap>,
5236) -> PyResult<BTreeMap<String, f64>> {
5237    let empty = BTreeMap::new();
5238    let sys = inventory_sys(&chain.inner, rates.as_ref().unwrap_or(&empty))?;
5239    let vec = chain_vec(&chain.inner, &n0)?;
5240    let out = nucleide_depletion::cumulative_decays(&sys, &vec, dt)
5241        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5242    Ok(chain
5243        .inner
5244        .nuclides
5245        .iter()
5246        .zip(out)
5247        .map(|(nuc, v)| (nuc.name.clone(), v))
5248        .collect())
5249}
5250
5251/// `(child, branching_ratio, decay_mode)` triples for a chain nuclide.
5252#[pyfunction]
5253fn progeny(chain: &PyChain, name: &str) -> Vec<(String, f64, String)> {
5254    nucleide_depletion::progeny(&chain.inner, name)
5255}
5256
5257/// Branching fraction from parent to child, if the decay exists.
5258#[pyfunction]
5259fn branching_fraction(chain: &PyChain, parent: &str, child: &str) -> Option<f64> {
5260    nucleide_depletion::branching_fraction(&chain.inner, parent, child)
5261}
5262
5263/// Decay-mode label from parent to child, if the decay exists.
5264#[pyfunction]
5265fn decay_mode(chain: &PyChain, parent: &str, child: &str) -> Option<String> {
5266    nucleide_depletion::decay_mode(&chain.inner, parent, child)
5267}
5268
5269/// `(parent, child, branching_ratio, decay_mode)` edges of a chain.
5270#[pyfunction]
5271fn chain_edges(chain: &PyChain) -> Vec<(String, String, f64, String)> {
5272    nucleide_depletion::chain_edges(&chain.inner)
5273}
5274
5275/// Parse an ARMI nuclide label (`nU235`, `92235`, ...) into a [`PyNuclide`].
5276#[pyfunction]
5277fn armi_to_nucid(name: &str) -> PyResult<PyNuclide> {
5278    nucleide_nuclei::armi::armi_name_to_nucid(name)
5279        .map(|inner| PyNuclide { inner })
5280        .map_err(|e| PyValueError::new_err(e.to_string()))
5281}
5282
5283/// Render a nuclide in ARMI database-label form.
5284#[pyfunction]
5285fn nucid_to_armi(nuclide: &PyNuclide) -> String {
5286    nucleide_nuclei::armi::nucid_to_armi_label(nuclide.inner)
5287}
5288
5289/// Parse an MCC3-style nuclide label into a [`PyNuclide`].
5290#[pyfunction]
5291fn mcc3_to_nucid(name: &str) -> PyResult<PyNuclide> {
5292    nucleide_nuclei::armi::mcc3_to_nucid(name)
5293        .map(|inner| PyNuclide { inner })
5294        .map_err(|e| PyValueError::new_err(e.to_string()))
5295}
5296
5297/// Truncated-label collisions in a composition at DIF3D/MC2 widths.
5298///
5299/// `comp` maps nuclide names to grams; `widths` defaults to `[6, 8]`.
5300/// Returns `[{truncated, width, members}]`.
5301#[pyfunction]
5302#[pyo3(signature = (comp, widths=None))]
5303fn check_labels(
5304    comp: BTreeMap<String, f64>,
5305    widths: Option<Vec<usize>>,
5306) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5307    let mat = comp_to_material(comp)?;
5308    let widths = widths.unwrap_or_else(|| nucleide_material::DEFAULT_WIDTHS.to_vec());
5309    let collisions = nucleide_material::check_labels(&mat, &widths);
5310    Python::attach(|py| {
5311        Ok(collisions
5312            .into_iter()
5313            .map(|c| {
5314                let mut d = BTreeMap::new();
5315                d.insert(
5316                    "truncated".to_string(),
5317                    c.truncated.into_pyobject(py).unwrap().unbind().into_any(),
5318                );
5319                d.insert(
5320                    "width".to_string(),
5321                    c.width.into_pyobject(py).unwrap().unbind().into_any(),
5322                );
5323                let members: Vec<String> = c.members.iter().map(|id| id.to_name()).collect();
5324                d.insert(
5325                    "members".to_string(),
5326                    members.into_pyobject(py).unwrap().unbind().into_any(),
5327                );
5328                d
5329            })
5330            .collect())
5331    })
5332}
5333
5334/// Conservation audit of a composition: `[{kind, detail}]` (empty = clean).
5335#[pyfunction]
5336fn audit_material(comp: BTreeMap<String, f64>) -> PyResult<Vec<BTreeMap<String, String>>> {
5337    let mat = comp_to_material(comp)?;
5338    Ok(nucleide_material::audit(&mat, &nucleide_material::Ame2020)
5339        .into_iter()
5340        .map(|issue| {
5341            let mut d = BTreeMap::new();
5342            d.insert("kind".to_string(), format!("{:?}", issue.kind));
5343            d.insert("detail".to_string(), issue.detail);
5344            d
5345        })
5346        .collect())
5347}
5348
5349/// Emit one composition through all five code dialects (MCNP, Serpent, FLUKA,
5350/// ALARA, PARTISN). Returns `{code: card_text}`.
5351///
5352/// `comp` maps nuclide names to grams; `density` is mass density [g/cm³] for
5353/// dialects that need one (falls back to none — Serpent/FLUKA/PARTISN error
5354/// without it).
5355#[pyfunction]
5356#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5357#[allow(clippy::too_many_arguments)]
5358fn emit_cards(
5359    comp: BTreeMap<String, f64>,
5360    name: &str,
5361    density: Option<f64>,
5362    mcnp_number: u32,
5363    xs_suffix: &str,
5364    serpent_lib: &str,
5365    fluka_fid: u32,
5366    partisn_zone: u32,
5367) -> PyResult<BTreeMap<String, String>> {
5368    let (emitted, _) = emit_drift_inner(
5369        comp,
5370        name,
5371        density,
5372        mcnp_number,
5373        xs_suffix,
5374        serpent_lib,
5375        fluka_fid,
5376        partisn_zone,
5377    )?;
5378    Ok(emitted
5379        .into_iter()
5380        .map(|e| (e.code.to_string(), e.text))
5381        .collect())
5382}
5383
5384/// Mass-drift report for one composition across all five code dialects.
5385/// Returns `[{code, mass_in, mass_out, rel_drift, dropped: [{nuclide, mass,
5386/// reason}], reparsed}]`.
5387#[pyfunction]
5388#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5389#[allow(clippy::too_many_arguments)]
5390fn emit_drift_table(
5391    comp: BTreeMap<String, f64>,
5392    name: &str,
5393    density: Option<f64>,
5394    mcnp_number: u32,
5395    xs_suffix: &str,
5396    serpent_lib: &str,
5397    fluka_fid: u32,
5398    partisn_zone: u32,
5399) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5400    let (_, table) = emit_drift_inner(
5401        comp,
5402        name,
5403        density,
5404        mcnp_number,
5405        xs_suffix,
5406        serpent_lib,
5407        fluka_fid,
5408        partisn_zone,
5409    )?;
5410    drift_table_to_py(table)
5411}
5412
5413fn drift_table_to_py(
5414    table: nucleide_emit::DriftTable,
5415) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5416    Python::attach(|py| {
5417        Ok(table
5418            .rows
5419            .into_iter()
5420            .map(|r| {
5421                let mut d = BTreeMap::new();
5422                d.insert(
5423                    "code".to_string(),
5424                    r.code
5425                        .to_string()
5426                        .into_pyobject(py)
5427                        .unwrap()
5428                        .unbind()
5429                        .into_any(),
5430                );
5431                d.insert(
5432                    "mass_in".to_string(),
5433                    r.mass_in.into_pyobject(py).unwrap().unbind().into_any(),
5434                );
5435                d.insert(
5436                    "mass_out".to_string(),
5437                    r.mass_out.into_pyobject(py).unwrap().unbind().into_any(),
5438                );
5439                d.insert(
5440                    "rel_drift".to_string(),
5441                    r.rel_drift.into_pyobject(py).unwrap().unbind().into_any(),
5442                );
5443                let dropped: Vec<BTreeMap<String, Py<PyAny>>> = r
5444                    .dropped
5445                    .into_iter()
5446                    .map(|x| {
5447                        let mut dd = BTreeMap::new();
5448                        dd.insert(
5449                            "nuclide".to_string(),
5450                            x.id.to_name()
5451                                .into_pyobject(py)
5452                                .unwrap()
5453                                .unbind()
5454                                .into_any(),
5455                        );
5456                        dd.insert(
5457                            "mass".to_string(),
5458                            x.mass.into_pyobject(py).unwrap().unbind().into_any(),
5459                        );
5460                        dd.insert(
5461                            "reason".to_string(),
5462                            x.reason.into_pyobject(py).unwrap().unbind().into_any(),
5463                        );
5464                        dd
5465                    })
5466                    .collect();
5467                d.insert(
5468                    "dropped".to_string(),
5469                    dropped.into_pyobject(py).unwrap().unbind().into_any(),
5470                );
5471                d.insert(
5472                    "reparsed".to_string(),
5473                    pyo3::types::PyBool::new(py, r.reparsed)
5474                        .to_owned()
5475                        .into_any()
5476                        .unbind(),
5477                );
5478                d
5479            })
5480            .collect())
5481    })
5482}
5483
5484#[allow(clippy::too_many_arguments)]
5485fn emit_drift_inner(
5486    comp: BTreeMap<String, f64>,
5487    name: &str,
5488    density: Option<f64>,
5489    mcnp_number: u32,
5490    xs_suffix: &str,
5491    serpent_lib: &str,
5492    fluka_fid: u32,
5493    partisn_zone: u32,
5494) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5495    let mut mat = comp_to_material(comp)?;
5496    mat.set_density(density);
5497    emit_drift_with_mat(
5498        mat,
5499        name,
5500        mcnp_number,
5501        xs_suffix,
5502        serpent_lib,
5503        fluka_fid,
5504        partisn_zone,
5505    )
5506}
5507
5508#[allow(clippy::too_many_arguments)]
5509fn emit_drift_with_mat(
5510    mat: nucleide_material::Material,
5511    name: &str,
5512    mcnp_number: u32,
5513    xs_suffix: &str,
5514    serpent_lib: &str,
5515    fluka_fid: u32,
5516    partisn_zone: u32,
5517) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5518    let mut opts = nucleide_emit::EmitOptions::new(name);
5519    opts.mcnp_number = mcnp_number;
5520    opts.xs_suffix = xs_suffix.to_string();
5521    opts.serpent_lib = serpent_lib.to_string();
5522    opts.fluka_fid = fluka_fid;
5523    opts.partisn_zone = partisn_zone;
5524    nucleide_emit::emit_drift(&mat, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
5525}
5526
5527#[allow(clippy::too_many_arguments)]
5528fn emit_armi_drift_inner(
5529    comp: BTreeMap<String, f64>,
5530    name: &str,
5531    density: Option<f64>,
5532    mcnp_number: u32,
5533    xs_suffix: &str,
5534    serpent_lib: &str,
5535    fluka_fid: u32,
5536    partisn_zone: u32,
5537) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5538    // `from_armi_mass_fracs` sets the density exactly like `emit_drift_inner`
5539    // (`set_density(density)`), so the material is emission-ready here.
5540    let mat = nucleide_emit::armi::from_armi_mass_fracs(comp, density)
5541        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5542    emit_drift_with_mat(
5543        mat,
5544        name,
5545        mcnp_number,
5546        xs_suffix,
5547        serpent_lib,
5548        fluka_fid,
5549        partisn_zone,
5550    )
5551}
5552
5553/// Emit one ARMI-keyed composition through all five code dialects (MCNP,
5554/// Serpent, FLUKA, ALARA, PARTISN). Returns `{code: card_text}`.
5555///
5556/// `comp` maps ARMI nuclide keys (`nU235`, `92235`, `U-2355`, ...) to grams;
5557/// keys resolve via `nucleide_emit::armi::from_armi_mass_fracs` (elemental
5558/// keys, bare `AM242`, and negative/non-finite masses are `ValueError`s).
5559/// `density` is the hot mass density [g/cm³] for dialects that need one.
5560#[pyfunction]
5561#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5562#[allow(clippy::too_many_arguments)]
5563fn emit_armi_cards(
5564    comp: BTreeMap<String, f64>,
5565    name: &str,
5566    density: Option<f64>,
5567    mcnp_number: u32,
5568    xs_suffix: &str,
5569    serpent_lib: &str,
5570    fluka_fid: u32,
5571    partisn_zone: u32,
5572) -> PyResult<BTreeMap<String, String>> {
5573    let (emitted, _) = emit_armi_drift_inner(
5574        comp,
5575        name,
5576        density,
5577        mcnp_number,
5578        xs_suffix,
5579        serpent_lib,
5580        fluka_fid,
5581        partisn_zone,
5582    )?;
5583    Ok(emitted
5584        .into_iter()
5585        .map(|e| (e.code.to_string(), e.text))
5586        .collect())
5587}
5588
5589/// Mass-drift report for one ARMI-keyed composition across all five code
5590/// dialects. Returns `[{code, mass_in, mass_out, rel_drift, dropped:
5591/// [{nuclide, mass, reason}], reparsed}]`.
5592#[pyfunction]
5593#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5594#[allow(clippy::too_many_arguments)]
5595fn emit_armi_drift_table(
5596    comp: BTreeMap<String, f64>,
5597    name: &str,
5598    density: Option<f64>,
5599    mcnp_number: u32,
5600    xs_suffix: &str,
5601    serpent_lib: &str,
5602    fluka_fid: u32,
5603    partisn_zone: u32,
5604) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5605    let (_, table) = emit_armi_drift_inner(
5606        comp,
5607        name,
5608        density,
5609        mcnp_number,
5610        xs_suffix,
5611        serpent_lib,
5612        fluka_fid,
5613        partisn_zone,
5614    )?;
5615    drift_table_to_py(table)
5616}
5617
5618// ---------------------------------------------------------------------------
5619// Point kinetics (thin glue over `nucleide-kinetics`; solver stays in core)
5620// ---------------------------------------------------------------------------
5621
5622/// Parse a reactivity-spec dict into the core [`Reactivity`].
5623///
5624/// `kind` selects the schedule (`"constant"`, `"step"`, `"impulse"`,
5625/// `"ramp"`, `"polyline"`); all reactivities are in Δk and all times in
5626/// seconds. Keys per kind: constant (`rho`); step (`t_step`, `rho_init`,
5627/// `rho_final`); impulse (`t_start`, `t_end`, `rho_init`, `rho_max`); ramp
5628/// (`t_start`, `t_end`, `rho_init`, `rho_rise`, `rho_final`); polyline
5629/// (`times`, `values`).
5630fn parse_reactivity(
5631    spec: &BTreeMap<String, Py<PyAny>>,
5632    py: Python<'_>,
5633) -> PyResult<nucleide_kinetics::Reactivity> {
5634    use nucleide_kinetics::Reactivity as R;
5635    let kind: String = get_str(spec, py, "kind", "reactivity spec needs a `kind`")?;
5636    let num = |key: &str| -> PyResult<f64> { get_num(spec, py, key) };
5637    let vec = |key: &str| -> PyResult<Vec<f64>> { get_vec(spec, py, key) };
5638    let r = match kind.as_str() {
5639        "constant" => R::Constant { rho: num("rho")? },
5640        "step" => R::Step {
5641            t_step: num("t_step")?,
5642            rho_init: num("rho_init")?,
5643            rho_final: num("rho_final")?,
5644        },
5645        "impulse" => R::Impulse {
5646            t_start: num("t_start")?,
5647            t_end: num("t_end")?,
5648            rho_init: num("rho_init")?,
5649            rho_max: num("rho_max")?,
5650        },
5651        "ramp" => R::Ramp {
5652            t_start: num("t_start")?,
5653            t_end: num("t_end")?,
5654            rho_init: num("rho_init")?,
5655            rho_rise: num("rho_rise")?,
5656            rho_final: num("rho_final")?,
5657        },
5658        "polyline" => R::Polyline {
5659            times: vec("times")?,
5660            values: vec("values")?,
5661        },
5662        other => {
5663            return Err(PyValueError::new_err(format!(
5664                "unknown reactivity kind `{other}` (supported: constant, step, impulse, ramp, polyline)"
5665            )))
5666        }
5667    };
5668    r.validate()
5669        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5670    Ok(r)
5671}
5672
5673fn get_str(
5674    spec: &BTreeMap<String, Py<PyAny>>,
5675    py: Python<'_>,
5676    key: &str,
5677    missing: &str,
5678) -> PyResult<String> {
5679    spec.get(key)
5680        .ok_or_else(|| PyValueError::new_err(missing.to_string()))?
5681        .extract::<String>(py)
5682        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a string")))
5683}
5684
5685fn get_num(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<f64> {
5686    spec.get(key)
5687        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
5688        .extract::<f64>(py)
5689        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
5690}
5691
5692fn get_vec(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<Vec<f64>> {
5693    spec.get(key)
5694        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
5695        .extract::<Vec<f64>>(py)
5696        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a list of numbers")))
5697}
5698
5699fn kinetics_params(
5700    betas: Vec<f64>,
5701    lambdas: Vec<f64>,
5702    lambda_gen: f64,
5703) -> PyResult<nucleide_kinetics::KineticParams> {
5704    nucleide_kinetics::KineticParams::new(betas, lambdas, lambda_gen)
5705        .map_err(|e| PyValueError::new_err(e.to_string()))
5706}
5707
5708/// Solve a prescribed-reactivity point-kinetics transient.
5709///
5710/// Thin wrapper over `nucleide_kinetics::solve`: `betas`/`lambdas`/`Lambda`
5711/// carry the delayed-neutron data (see `KineticParams::from_ifp` for the
5712/// OpenMC provenance note — decay constants are caller-supplied), `rho` is
5713/// a spec dict (see `parse_reactivity`), `t` the output grid in seconds,
5714/// `n0` the initial neutron level, `C0` the optional initial precursors
5715/// (defaults to equilibrium). `method` is `"trapezoidal"` (default) or
5716/// `"backward_euler"`. Returns a dict with `times`, `n`, `C`
5717/// (`[time][group]`), and the echo of the initial state (`n0`, `C0`).
5718#[pyfunction]
5719#[pyo3(signature = (betas, lambdas, lambda_gen, rho, t, n0, c0=None, method="trapezoidal", rtol=1e-9, atol=1e-12, dt_min=1e-14, dt_max=None, max_steps=1000000))]
5720#[allow(clippy::too_many_arguments)]
5721fn kinetics_solve(
5722    py: Python<'_>,
5723    betas: Vec<f64>,
5724    lambdas: Vec<f64>,
5725    lambda_gen: f64,
5726    rho: BTreeMap<String, Py<PyAny>>,
5727    t: Vec<f64>,
5728    n0: f64,
5729    c0: Option<Vec<f64>>,
5730    method: &str,
5731    rtol: f64,
5732    atol: f64,
5733    dt_min: f64,
5734    dt_max: Option<f64>,
5735    max_steps: usize,
5736) -> PyResult<Py<PyAny>> {
5737    use nucleide_kinetics::{Method as M, SolverOptions};
5738    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5739    let rho = parse_reactivity(&rho, py)?;
5740    let grid =
5741        nucleide_kinetics::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
5742    let state = nucleide_kinetics::State::new(&params, n0, c0)
5743        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5744    let method = if method.eq_ignore_ascii_case("trapezoidal") {
5745        M::Trapezoidal
5746    } else if method.eq_ignore_ascii_case("backward_euler") {
5747        M::BackwardEuler
5748    } else {
5749        return Err(PyValueError::new_err(format!(
5750            "unknown kinetics method `{method}` (supported: trapezoidal, backward_euler)"
5751        )));
5752    };
5753    let opts = SolverOptions {
5754        method,
5755        rtol,
5756        atol,
5757        dt_min,
5758        dt_max: dt_max.unwrap_or(f64::INFINITY),
5759        max_steps,
5760    };
5761    let sol = nucleide_kinetics::solve(&params, &rho, &grid, &state, &opts)
5762        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5763    use pyo3::types::PyDict;
5764    let out = PyDict::new(py);
5765    out.set_item("times", &sol.times).ok();
5766    out.set_item("n", &sol.n).ok();
5767    out.set_item("C", &sol.c).ok();
5768    out.set_item("n0", sol.initial.n0).ok();
5769    out.set_item("C0", &sol.initial.c0).ok();
5770    Ok(out.into_any().unbind())
5771}
5772
5773/// Equilibrium precursor populations `C_i = beta_i/(lambda_i*Lambda)*n0`.
5774#[pyfunction]
5775fn kinetics_equilibrium(
5776    betas: Vec<f64>,
5777    lambdas: Vec<f64>,
5778    lambda_gen: f64,
5779    n0: f64,
5780) -> PyResult<Vec<f64>> {
5781    kinetics_params(betas, lambdas, lambda_gen)?
5782        .equilibrium_precursors(n0)
5783        .map_err(|e| PyValueError::new_err(e.to_string()))
5784}
5785
5786/// Initial rate `dn/dt` at `t = 0` for the given schedule and initials.
5787#[pyfunction]
5788#[pyo3(signature = (betas, lambdas, lambda_gen, rho, n0, c0=None))]
5789fn kinetics_initial_rate(
5790    py: Python<'_>,
5791    betas: Vec<f64>,
5792    lambdas: Vec<f64>,
5793    lambda_gen: f64,
5794    rho: BTreeMap<String, Py<PyAny>>,
5795    n0: f64,
5796    c0: Option<Vec<f64>>,
5797) -> PyResult<f64> {
5798    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5799    let rho = parse_reactivity(&rho, py)?;
5800    let state = nucleide_kinetics::State::new(&params, n0, c0)
5801        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5802    Ok(nucleide_kinetics::solve::initial_rate(
5803        &params, &rho, &state,
5804    ))
5805}
5806
5807/// Inhour right-hand side `rho(omega)` [Δk] for the given data.
5808#[pyfunction]
5809fn kinetics_inhour_rho(
5810    betas: Vec<f64>,
5811    lambdas: Vec<f64>,
5812    lambda_gen: f64,
5813    omega: f64,
5814) -> PyResult<f64> {
5815    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5816    nucleide_kinetics::rho_of_omega(&params, omega)
5817        .map_err(|e| PyValueError::new_err(e.to_string()))
5818}
5819
5820/// Stable period `T = 1/omega` [s] at reactivity `rho` [Δk] (`0 < rho < beta`).
5821#[pyfunction]
5822fn kinetics_stable_period(
5823    betas: Vec<f64>,
5824    lambdas: Vec<f64>,
5825    lambda_gen: f64,
5826    rho: f64,
5827) -> PyResult<f64> {
5828    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5829    nucleide_kinetics::stable_period(&params, rho).map_err(|e| PyValueError::new_err(e.to_string()))
5830}
5831
5832/// Prompt-jump estimate `n_before*(beta - rho_before)/(beta - rho_after)`.
5833///
5834/// Needs `rho_after < beta_total`; `beta_total` is the caller's total
5835/// delayed fraction (pass `sum(betas)`).
5836#[pyfunction]
5837fn kinetics_prompt_jump(
5838    n_before: f64,
5839    rho_before: f64,
5840    rho_after: f64,
5841    beta_total: f64,
5842) -> PyResult<f64> {
5843    nucleide_kinetics::prompt_jump(n_before, rho_before, rho_after, beta_total)
5844        .map_err(|e| PyValueError::new_err(e.to_string()))
5845}
5846
5847// ---------------------------------------------------------------------------
5848// Spectroscopy (thin glue over `nucleide-spectroscopy`; algorithms stay in core)
5849// ---------------------------------------------------------------------------
5850
5851/// Rectangular smoothing (E1): `m` must be odd and at least 3.
5852#[pyfunction]
5853fn spectroscopy_rect_smooth(counts: Vec<f64>, m: i64) -> PyResult<Vec<f64>> {
5854    let w = usize::try_from(m).map_err(|_| {
5855        PyValueError::new_err(format!("spectroscopy: smoothing width {m} is less than 3"))
5856    })?;
5857    nucleide_spectroscopy::rect_smooth(&counts, w).map_err(|e| PyValueError::new_err(e.to_string()))
5858}
5859
5860/// Five-point smoothing (E2); the first/last two channels are copied.
5861#[pyfunction]
5862fn spectroscopy_five_point_smooth(counts: Vec<f64>) -> PyResult<Vec<f64>> {
5863    nucleide_spectroscopy::five_point_smooth(&counts)
5864        .map_err(|e| PyValueError::new_err(e.to_string()))
5865}
5866
5867/// Background under a peak (E3, `m == 1` only).
5868#[pyfunction]
5869fn spectroscopy_calc_bg(
5870    counts: Vec<f64>,
5871    channels: Vec<f64>,
5872    c1: i64,
5873    c2: i64,
5874    m: i64,
5875) -> PyResult<f64> {
5876    nucleide_spectroscopy::calc_bg(&counts, &channels, c1, c2, m)
5877        .map_err(|e| PyValueError::new_err(e.to_string()))
5878}
5879
5880/// Gross counts between two channels, half-open (E4, excludes `c2`).
5881#[pyfunction]
5882fn spectroscopy_gross_count(
5883    counts: Vec<f64>,
5884    channels: Vec<f64>,
5885    c1: i64,
5886    c2: i64,
5887) -> PyResult<f64> {
5888    nucleide_spectroscopy::gross_count(&counts, &channels, c1, c2)
5889        .map_err(|e| PyValueError::new_err(e.to_string()))
5890}
5891
5892/// Net counts: gross minus background (E5).
5893#[pyfunction]
5894fn spectroscopy_net_counts(
5895    counts: Vec<f64>,
5896    channels: Vec<f64>,
5897    c1: i64,
5898    c2: i64,
5899    m: i64,
5900) -> PyResult<f64> {
5901    nucleide_spectroscopy::net_counts(&counts, &channels, c1, c2, m)
5902        .map_err(|e| PyValueError::new_err(e.to_string()))
5903}
5904
5905/// Energy per channel from the `[a0, a1, a2]` fit (E6).
5906#[pyfunction]
5907fn spectroscopy_energy_bins(channels: Vec<f64>, calib_e_fit: Vec<f64>) -> PyResult<Vec<f64>> {
5908    nucleide_spectroscopy::energy_bins(&channels, &calib_e_fit)
5909        .map_err(|e| PyValueError::new_err(e.to_string()))
5910}
5911
5912/// Detector efficiency at `energy_mev` (E7, energy in MeV, `eff_fit` 1 or 2).
5913#[pyfunction]
5914fn spectroscopy_detector_efficiency(
5915    energy_mev: f64,
5916    eff_coeff: Vec<f64>,
5917    eff_fit: i64,
5918) -> PyResult<f64> {
5919    nucleide_spectroscopy::detector_efficiency(energy_mev, &eff_coeff, eff_fit)
5920        .map_err(|e| PyValueError::new_err(e.to_string()))
5921}
5922
5923/// Efficiency-coefficient fit (E7-fit): log-space weighted least squares over
5924/// caller `(energies_mev, effs, weights)` points with `order + 1` coefficients
5925/// under the `eff_fit` 1 (`(ln E)^j`) or 2 (`(1/E)^j`) basis. Thin wrapper
5926/// over `nucleide-spectroscopy` `fit_efficiency` (which solves through the
5927/// workspace `nucleide-linalg` least-squares kernel).
5928#[pyfunction]
5929#[pyo3(signature = (energies, effs, weights, order, eff_fit=1))]
5930fn spectroscopy_fit_efficiency(
5931    energies: Vec<f64>,
5932    effs: Vec<f64>,
5933    weights: Vec<f64>,
5934    order: usize,
5935    eff_fit: i64,
5936) -> PyResult<Vec<f64>> {
5937    nucleide_spectroscopy::fit_efficiency(&energies, &effs, &weights, order, eff_fit)
5938        .map_err(|e| PyValueError::new_err(e.to_string()))
5939}
5940
5941/// Fetch one caller-supplied atomic constant or raise a `ValueError`.
5942fn atomic_key(atomic: &BTreeMap<String, f64>, key: &str) -> PyResult<f64> {
5943    atomic
5944        .get(key)
5945        .copied()
5946        .ok_or_else(|| PyValueError::new_err(format!("atomic constants missing `{key}`")))
5947}
5948
5949/// X-ray lines (E8) as `[(energy_kev, intensity); Ka1, Ka2, Kb, L]`.
5950///
5951/// `atomic` carries the nine caller-supplied constants (`k_shell_fluor`,
5952/// `l_shell_fluor`, `prob`, `kb_to_ka`, `ka2_to_ka1`, `ka1_en_kev`,
5953/// `ka2_en_kev`, `kb_en_kev`, `l_en_kev`). `None` (or NaN, the upstream
5954/// sentinel) marks a conversion absent. Upstream exposes no combined
5955/// function for this routine — only a material method — so this explicit
5956/// entry point is the documented Nucleide surface.
5957#[pyfunction]
5958#[pyo3(signature = (atomic, k_conv=None, l_conv=None))]
5959fn spectroscopy_xray_lines(
5960    atomic: BTreeMap<String, f64>,
5961    k_conv: Option<f64>,
5962    l_conv: Option<f64>,
5963) -> PyResult<Vec<(f64, f64)>> {
5964    let data = nucleide_spectroscopy::AtomicData {
5965        k_shell_fluor: atomic_key(&atomic, "k_shell_fluor")?,
5966        l_shell_fluor: atomic_key(&atomic, "l_shell_fluor")?,
5967        prob: atomic_key(&atomic, "prob")?,
5968        kb_to_ka: atomic_key(&atomic, "kb_to_ka")?,
5969        ka2_to_ka1: atomic_key(&atomic, "ka2_to_ka1")?,
5970        ka1_en_kev: atomic_key(&atomic, "ka1_en_kev")?,
5971        ka2_en_kev: atomic_key(&atomic, "ka2_en_kev")?,
5972        kb_en_kev: atomic_key(&atomic, "kb_en_kev")?,
5973        l_en_kev: atomic_key(&atomic, "l_en_kev")?,
5974    };
5975    // NaN plays the upstream "conversion absent" sentinel role.
5976    let present = |v: Option<f64>| v.filter(|x| !x.is_nan());
5977    Ok(
5978        nucleide_spectroscopy::xray_lines(&data, present(k_conv), present(l_conv))
5979            .iter()
5980            .map(|l| (l.energy_kev, l.intensity))
5981            .collect(),
5982    )
5983}
5984
5985/// SDEF decay-source card (E9) as `(normalized_bins, card_text)`.
5986///
5987/// `lines` carries caller-supplied `(energy_mev, intensity)` pairs; every
5988/// energy and intensity is an input (no evaluated data is vendored).
5989/// Intensities are merged at duplicate energies, sorted ascending, and
5990/// normalized to probabilities summing to 1.0. The card keeps the upstream
5991/// monoenergetic point-source field order (`POS`, optional `VEC ... DIR=1`,
5992/// `ERG`, `WGT`, `PAR`); one surviving line renders inline `ERG=<E>`, while
5993/// several render the discrete-distribution form `ERG=D1` with paired
5994/// `SI1 L` / `SP1 D` cards. That distribution syntax is parser-verified
5995/// surface only — MCNP sampling semantics are the caller's responsibility.
5996/// `particle` parses through the `nucleide-nuclei` dialect (`"Neutron"`,
5997/// `"Photon"`, `"Electron"`, ...); `version` is 5 or 6 and selects the
5998/// `PAR=` designator.
5999#[pyfunction]
6000#[pyo3(signature = (lines, x=0.0, y=0.0, z=0.0, u=0.0, v=0.0, w=0.0, weight=1.0, particle="Neutron", version=5))]
6001#[allow(clippy::too_many_arguments)]
6002fn spectroscopy_sdef_decay_source(
6003    lines: Vec<(f64, f64)>,
6004    x: f64,
6005    y: f64,
6006    z: f64,
6007    u: f64,
6008    v: f64,
6009    w: f64,
6010    weight: f64,
6011    particle: &str,
6012    version: u32,
6013) -> PyResult<(Vec<(f64, f64)>, String)> {
6014    let particle = particle
6015        .parse::<nucleide_nuclei::particles::ParticleId>()
6016        .map_err(|e| PyValueError::new_err(format!("spectroscopy: particle {e}")))?;
6017    let source = nucleide_spectroscopy::PointSource {
6018        x,
6019        y,
6020        z,
6021        u,
6022        v,
6023        w,
6024        weight,
6025        particle,
6026    };
6027    nucleide_spectroscopy::sdef_card(&lines, &source, version)
6028        .map_err(|e| PyValueError::new_err(e.to_string()))
6029}
6030
6031/// Render a parsed spectrum as a Python dict.
6032fn spectrum_to_py(
6033    py: Python<'_>,
6034    spec: &nucleide_spectroscopy::GammaSpectrum,
6035) -> PyResult<Py<PyAny>> {
6036    use pyo3::types::PyDict;
6037    let d = PyDict::new(py);
6038    let s = &spec.spectrum;
6039    d.set_item("spec_name", &s.spec_name)?;
6040    d.set_item("start_chan_num", s.start_chan_num)?;
6041    d.set_item("num_channels", s.num_channels)?;
6042    d.set_item("channels", &s.channels)?;
6043    d.set_item("counts", &s.counts)?;
6044    d.set_item("ebin", &s.ebin)?;
6045    d.set_item("real_time", spec.real_time)?;
6046    d.set_item("live_time", spec.live_time)?;
6047    d.set_item("dead_time", spec.dead_time())?;
6048    d.set_item("det_id", &spec.det_id)?;
6049    d.set_item("det_descp", &spec.det_descp)?;
6050    d.set_item("start_date", &spec.start_date)?;
6051    d.set_item("start_time", &spec.start_time)?;
6052    d.set_item("calib_e_fit", &spec.calib_e_fit)?;
6053    d.set_item("calib_fwhm_fit", &spec.calib_fwhm_fit)?;
6054    d.set_item("file_name", &spec.file_name)?;
6055    Ok(d.into_any().unbind())
6056}
6057
6058/// Parse dollar-format `.spe` text (first line must be `$SPEC_ID:`).
6059#[pyfunction]
6060fn spectroscopy_parse_dollar_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
6061    let spec = nucleide_spectroscopy::parse_dollar_spe(text, "")
6062        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6063    spectrum_to_py(py, &spec)
6064}
6065
6066/// Parse plain-format `.spe` text (rejects the `$SPEC_ID:` magic).
6067#[pyfunction]
6068fn spectroscopy_parse_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
6069    let spec = nucleide_spectroscopy::parse_plain_spe(text, "")
6070        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6071    spectrum_to_py(py, &spec)
6072}
6073
6074/// Read a dollar-format `.spe` file.
6075#[pyfunction]
6076fn spectroscopy_read_dollar_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
6077    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
6078    let spec = nucleide_spectroscopy::parse_dollar_spe(&text, path)
6079        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6080    spectrum_to_py(py, &spec)
6081}
6082
6083/// Read a plain-format `.spe` file.
6084#[pyfunction]
6085fn spectroscopy_read_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
6086    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
6087    let spec = nucleide_spectroscopy::parse_plain_spe(&text, path)
6088        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6089    spectrum_to_py(py, &spec)
6090}
6091
6092/// Parse decay-lines interchange TSV text into `(energy_MeV, intensity)`
6093/// pairs (`#` comments and blank lines skipped; E9 normalization stays in
6094/// `sdef_decay_source`).
6095#[pyfunction]
6096fn spectroscopy_parse_lines_tsv(text: &str) -> PyResult<Vec<(f64, f64)>> {
6097    nucleide_spectroscopy::parse_lines_tsv(text).map_err(|e| PyValueError::new_err(e.to_string()))
6098}
6099
6100/// Read a decay-lines interchange TSV file (same grammar as
6101/// `spectroscopy_parse_lines_tsv`).
6102#[pyfunction]
6103fn spectroscopy_read_decay_lines(path: &str) -> PyResult<Vec<(f64, f64)>> {
6104    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
6105    nucleide_spectroscopy::parse_lines_tsv(&text).map_err(|e| PyValueError::new_err(e.to_string()))
6106}
6107
6108// ---------------------------------------------------------------------------
6109// UQ-lite sampling kernel (thin glue over `linalg`; decay-only sub-scope)
6110// ---------------------------------------------------------------------------
6111
6112fn uq_sample_err(e: nucleide_linalg::sample::SampleError) -> PyErr {
6113    PyValueError::new_err(e.to_string())
6114}
6115
6116fn uq_decay_err(e: nucleide_linalg::decay::DecayError) -> PyErr {
6117    PyValueError::new_err(e.to_string())
6118}
6119
6120/// Seeded multivariate-normal draws over a caller-supplied covariance block.
6121///
6122/// Returns a dict with `samples` (list of `n` row lists), `method`
6123/// (`"cholesky"` or `"eigen_clip"`), and the unclipped `min_eigen` /
6124/// `max_eigen` (`None` on the Cholesky path). Same
6125/// `(mean, cov, n, seed)` inputs always yield identical samples. Thin
6126/// wrapper over `nucleide-linalg` `sample`.
6127#[pyfunction]
6128fn uq_sample_mvn(
6129    py: Python<'_>,
6130    mean: Vec<f64>,
6131    cov: Vec<Vec<f64>>,
6132    n: usize,
6133    seed: u64,
6134) -> PyResult<Py<PyAny>> {
6135    use pyo3::types::PyDict;
6136    let set = nucleide_linalg::sample::sample_mvn(&mean, &cov, n, seed).map_err(uq_sample_err)?;
6137    let d = PyDict::new(py);
6138    d.set_item("samples", set.samples)?;
6139    d.set_item("method", set.method.name())?;
6140    match &set.method {
6141        nucleide_linalg::sample::FactorMethod::Cholesky => {
6142            d.set_item("min_eigen", py.None())?;
6143            d.set_item("max_eigen", py.None())?;
6144        }
6145        nucleide_linalg::sample::FactorMethod::EigenClip {
6146            min_eigen,
6147            max_eigen,
6148        } => {
6149            d.set_item("min_eigen", *min_eigen)?;
6150            d.set_item("max_eigen", *max_eigen)?;
6151        }
6152    }
6153    Ok(d.into_any().unbind())
6154}
6155
6156/// Sample mean over draws (one entry per dimension).
6157#[pyfunction]
6158fn uq_sample_mean(samples: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
6159    nucleide_linalg::sample::sample_mean(&samples).map_err(uq_sample_err)
6160}
6161
6162/// Unbiased sample covariance (`1/(n-1)`, matching SANDY `Samples.get_cov`).
6163#[pyfunction]
6164fn uq_sample_cov(samples: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
6165    nucleide_linalg::sample::sample_cov(&samples).map_err(uq_sample_err)
6166}
6167
6168/// Sample mean/covariance convergence diagnostics à la SANDY.
6169///
6170/// Returns a dict with `mean_err_max`, `cov_err_fro`, the echoed
6171/// `mean_tol`/`cov_tol`, and `passed`. Thin wrapper over
6172/// `nucleide-linalg` `sample`.
6173#[pyfunction]
6174fn uq_check_convergence(
6175    py: Python<'_>,
6176    mean: Vec<f64>,
6177    cov: Vec<Vec<f64>>,
6178    samples: Vec<Vec<f64>>,
6179    mean_tol: f64,
6180    cov_tol: f64,
6181) -> PyResult<Py<PyAny>> {
6182    use pyo3::types::PyDict;
6183    let rep = nucleide_linalg::sample::check_convergence(&mean, &cov, &samples, mean_tol, cov_tol)
6184        .map_err(uq_sample_err)?;
6185    let d = PyDict::new(py);
6186    d.set_item("mean_err_max", rep.mean_err_max)?;
6187    d.set_item("cov_err_fro", rep.cov_err_fro)?;
6188    d.set_item("mean_tol", rep.mean_tol)?;
6189    d.set_item("cov_tol", rep.cov_tol)?;
6190    d.set_item("passed", rep.passed)?;
6191    Ok(d.into_any().unbind())
6192}
6193
6194/// Perturb one parent's kept branch fractions with relative deltas,
6195/// preserving the incoming `1 - BR(SF)` deficit by renormalisation.
6196#[pyfunction]
6197fn uq_perturb_branches(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
6198    nucleide_linalg::decay::perturb_branches(&base, &rel).map_err(uq_decay_err)
6199}
6200
6201/// Perturb decay energies under `convention`
6202/// (`"relative"`/`"absolute"`/`"lognormal"`); negative results clamp to zero
6203/// (a no-op for lognormal draws, which stay positive for non-negative bases).
6204#[pyfunction]
6205fn uq_perturb_energies(base: Vec<f64>, delta: Vec<f64>, convention: &str) -> PyResult<Vec<f64>> {
6206    let conv = nucleide_linalg::sample::PerturbConvention::parse(convention)
6207        .map_err(PyValueError::new_err)?;
6208    nucleide_linalg::decay::perturb_energies(&base, &delta, conv).map_err(uq_decay_err)
6209}
6210
6211/// Seeded log-normal draws: `x ~ N(mean_log, cov_log)` via the shared MVN
6212/// factor path and RNG, then `y = exp(x)` elementwise.
6213///
6214/// Returns the same dict shape as [`uq_sample_mvn`]; `mean_log`/`cov_log`
6215/// are log-space MVN parameters (never the moments of `y`). Thin wrapper
6216/// over `nucleide-linalg` `sample`.
6217#[pyfunction]
6218fn uq_sample_lognormal(
6219    py: Python<'_>,
6220    mean_log: Vec<f64>,
6221    cov: Vec<Vec<f64>>,
6222    n: usize,
6223    seed: u64,
6224) -> PyResult<Py<PyAny>> {
6225    use pyo3::types::PyDict;
6226    let set = nucleide_linalg::sample::sample_lognormal(&mean_log, &cov, n, seed)
6227        .map_err(uq_sample_err)?;
6228    let d = PyDict::new(py);
6229    d.set_item("samples", set.samples)?;
6230    d.set_item("method", set.method.name())?;
6231    match &set.method {
6232        nucleide_linalg::sample::FactorMethod::Cholesky => {
6233            d.set_item("min_eigen", py.None())?;
6234            d.set_item("max_eigen", py.None())?;
6235        }
6236        nucleide_linalg::sample::FactorMethod::EigenClip {
6237            min_eigen,
6238            max_eigen,
6239        } => {
6240            d.set_item("min_eigen", *min_eigen)?;
6241            d.set_item("max_eigen", *max_eigen)?;
6242        }
6243    }
6244    Ok(d.into_any().unbind())
6245}
6246
6247/// Seeded Latin-hypercube draws over a caller-supplied covariance block.
6248///
6249/// Stratified `U(0,1)` draws (one jittered draw per stratum per dimension)
6250/// through the hand-rolled inverse-normal CDF, then the shared MVN factor
6251/// path and `x = μ + Bz` application. Returns the same dict shape as
6252/// [`uq_sample_mvn`]. Thin wrapper over `nucleide-linalg` `sample`.
6253#[pyfunction]
6254fn uq_sample_lhs(
6255    py: Python<'_>,
6256    mean: Vec<f64>,
6257    cov: Vec<Vec<f64>>,
6258    n: usize,
6259    seed: u64,
6260) -> PyResult<Py<PyAny>> {
6261    use pyo3::types::PyDict;
6262    let set = nucleide_linalg::sample::sample_lhs(&mean, &cov, n, seed).map_err(uq_sample_err)?;
6263    let d = PyDict::new(py);
6264    d.set_item("samples", set.samples)?;
6265    d.set_item("method", set.method.name())?;
6266    match &set.method {
6267        nucleide_linalg::sample::FactorMethod::Cholesky => {
6268            d.set_item("min_eigen", py.None())?;
6269            d.set_item("max_eigen", py.None())?;
6270        }
6271        nucleide_linalg::sample::FactorMethod::EigenClip {
6272            min_eigen,
6273            max_eigen,
6274        } => {
6275            d.set_item("min_eigen", *min_eigen)?;
6276            d.set_item("max_eigen", *max_eigen)?;
6277        }
6278    }
6279    Ok(d.into_any().unbind())
6280}
6281
6282/// Closed-form log-normal mean `E[y_i] = exp(mu_i + C_ii/2)` over the
6283/// log-space `(mean_log, cov)` parameters.
6284#[pyfunction]
6285fn uq_lognormal_mean(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
6286    nucleide_linalg::sample::lognormal_mean(&mean_log, &cov).map_err(uq_sample_err)
6287}
6288
6289/// Closed-form log-normal covariance
6290/// `Cov(y_i, y_j) = exp(mu_i + mu_j + (C_ii + C_jj)/2) (exp(C_ij) - 1)`.
6291#[pyfunction]
6292fn uq_lognormal_cov(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
6293    nucleide_linalg::sample::lognormal_cov(&mean_log, &cov).map_err(uq_sample_err)
6294}
6295
6296/// Passthrough copy of a perturbation vector (finiteness-checked).
6297#[pyfunction]
6298fn uq_passthrough(delta: Vec<f64>) -> PyResult<Vec<f64>> {
6299    nucleide_linalg::decay::passthrough(&delta).map_err(uq_decay_err)
6300}
6301
6302/// Fission-yield perturbation — named-open hook (waits on ENDF
6303/// fission-yield tapes); always raises.
6304#[pyfunction]
6305fn uq_perturb_fission_yields(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
6306    nucleide_linalg::decay::perturb_fission_yields(&base, &rel).map_err(uq_decay_err)
6307}
6308
6309// ---------------------------------------------------------------------------
6310// Thin reader facade bundle over existing Rust (no new math/data)
6311// ---------------------------------------------------------------------------
6312
6313fn parse_projectile(flag: &str) -> PyResult<nucleide_nuclei::rxname::Projectile> {
6314    flag.parse::<nucleide_nuclei::rxname::Projectile>()
6315        .map_err(|e| PyValueError::new_err(e.to_string()))
6316}
6317
6318fn resolve_rx_id(spec: &Bound<'_, PyAny>) -> PyResult<u32> {
6319    if let Ok(id) = spec.extract::<u32>() {
6320        return Ok(id);
6321    }
6322    if let Ok(s) = spec.extract::<&str>() {
6323        return nucleide_nuclei::rxname::name_to_id(s)
6324            .map_err(|e| PyValueError::new_err(e.to_string()));
6325    }
6326    Err(PyTypeError::new_err(
6327        "expected reaction id (int) or name (str)",
6328    ))
6329}
6330
6331/// Short `"(z,a)"`-style label for a reaction id ("" when unknown).
6332#[pyfunction]
6333fn rxname_label(id: u32) -> &'static str {
6334    nucleide_nuclei::rxname::label(id)
6335}
6336
6337/// Long documentation string for a reaction id ("" when unknown).
6338#[pyfunction]
6339fn rxname_doc(id: u32) -> &'static str {
6340    nucleide_nuclei::rxname::doc(id)
6341}
6342
6343/// Registry row for a reaction id as {id, name, mt, label, doc}, or None.
6344#[pyfunction]
6345fn rxname_reaction(py: Python<'_>, id: u32) -> PyResult<Option<Py<PyAny>>> {
6346    use pyo3::types::PyDict;
6347    Ok(nucleide_nuclei::rxname::reaction(id).map(|r| {
6348        let d = PyDict::new(py);
6349        d.set_item("id", r.id).ok();
6350        d.set_item("name", r.name).ok();
6351        d.set_item("mt", r.mt).ok();
6352        d.set_item("label", r.label).ok();
6353        d.set_item("doc", r.doc).ok();
6354        d.into_any().unbind()
6355    }))
6356}
6357
6358/// Reaction channel connecting `from_nucid` to `to_nucid` under `projectile`.
6359#[pyfunction]
6360#[pyo3(signature = (from_nucid, to_nucid, projectile="n"))]
6361fn rxname_id_from_nucdelta(from_nucid: u32, to_nucid: u32, projectile: &str) -> PyResult<u32> {
6362    let p = parse_projectile(projectile)?;
6363    nucleide_nuclei::rxname::id_from_nucdelta(from_nucid, to_nucid, p)
6364        .map_err(|e| PyValueError::new_err(e.to_string()))
6365}
6366
6367/// Daughter nuclide (GNDS name) when `parent` undergoes `rx` under `projectile`.
6368#[pyfunction]
6369#[pyo3(signature = (parent, rx, projectile="n"))]
6370fn rxname_child(parent: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
6371    let p = parse_projectile(projectile)?;
6372    let rx = resolve_rx_id(rx)?;
6373    let parent_id = NuclideId::from_name(parent)
6374        .map_err(|e| PyValueError::new_err(format!("`{parent}`: {e}")))?;
6375    nucleide_nuclei::rxname::child(parent_id, rx, p)
6376        .map(|id| id.to_name())
6377        .map_err(|e| PyValueError::new_err(e.to_string()))
6378}
6379
6380/// Parent nuclide (GNDS name) whose `rx` under `projectile` yields `child`.
6381#[pyfunction]
6382#[pyo3(signature = (child, rx, projectile="n"))]
6383fn rxname_parent(child: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
6384    let p = parse_projectile(projectile)?;
6385    let rx = resolve_rx_id(rx)?;
6386    let child_id = NuclideId::from_name(child)
6387        .map_err(|e| PyValueError::new_err(format!("`{child}`: {e}")))?;
6388    nucleide_nuclei::rxname::parent(child_id, rx, p)
6389        .map(|id| id.to_name())
6390        .map_err(|e| PyValueError::new_err(e.to_string()))
6391}
6392
6393/// True when `spec` names a particle or a nuclide (hydrogen or heavy ion).
6394#[pyfunction]
6395fn particle_is_valid(spec: &str) -> bool {
6396    nucleide_nuclei::particles::is_valid(spec)
6397}
6398
6399/// True when `n` is a registered PDC number.
6400#[pyfunction]
6401fn particle_is_valid_pdc(n: i32) -> bool {
6402    nucleide_nuclei::particles::is_valid_pdc(n)
6403}
6404
6405/// True when `spec` is ground-state hydrogen.
6406#[pyfunction]
6407fn particle_is_hydrogen(spec: &str) -> bool {
6408    nucleide_nuclei::particles::is_hydrogen(spec)
6409}
6410
6411/// True when `spec` is a nuclide heavier than ground-state hydrogen.
6412#[pyfunction]
6413fn particle_is_heavy_ion(spec: &str) -> bool {
6414    nucleide_nuclei::particles::is_heavy_ion(spec)
6415}
6416
6417/// Gut-uptake fraction `f1` for ingestion rows, or None (source default EPA).
6418#[pyfunction]
6419#[pyo3(signature = (name, source="EPA"))]
6420fn dose_f1(name: &str, source: &str) -> PyResult<Option<f64>> {
6421    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
6422    let s = parse_dose_source(source)?;
6423    Ok(nucleide_nuclei::data::dose_f1_by_name(name, s))
6424}
6425
6426/// Lung-clearance class for inhalation rows, or None (source default EPA).
6427#[pyfunction]
6428#[pyo3(signature = (name, source="EPA"))]
6429fn dose_lung_model(name: &str, source: &str) -> PyResult<Option<char>> {
6430    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
6431    let s = parse_dose_source(source)?;
6432    Ok(nucleide_nuclei::data::dose_lung_model_by_name(name, s))
6433}
6434
6435/// Canonical element symbol for a bare-symbol comp key, or None.
6436fn bare_element_z(name: &str) -> Option<u32> {
6437    let t = name.trim();
6438    if t.is_empty() {
6439        return None;
6440    }
6441    let mut chars = t.chars();
6442    let first = chars.next()?.to_uppercase().next()?;
6443    let rest: String = chars.collect::<String>().to_lowercase();
6444    let canon = format!("{first}{rest}");
6445    nucleide_nuclei::element_z(&canon)
6446}
6447
6448fn mat_from_comp_elements(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
6449    let mut mat = nucleide_material::Material::new();
6450    for (name, grams) in &comp {
6451        let id = match NuclideId::from_name(name) {
6452            Ok(id) => id,
6453            Err(_) => match bare_element_z(name) {
6454                Some(z) => NuclideId::from_nucid(z * 10_000_000),
6455                None => {
6456                    return Err(PyValueError::new_err(format!(
6457                        "`{name}`: unknown nuclide or element"
6458                    )));
6459                }
6460            },
6461        };
6462        mat.add_nuclide(id, *grams);
6463    }
6464    Ok(mat)
6465}
6466
6467fn mat_to_comp_elements(mat: &nucleide_material::Material) -> BTreeMap<String, f64> {
6468    let mut out = BTreeMap::new();
6469    for (&id, &grams) in &mat.comp {
6470        let key = if id.a() == 0 && id.state() == 0 {
6471            nucleide_nuclei::element_symbol(id.z())
6472                .unwrap_or("X")
6473                .to_string()
6474        } else {
6475            id.to_name()
6476        };
6477        *out.entry(key).or_insert(0.0) += grams;
6478    }
6479    out
6480}
6481
6482/// Mix streams weighted by relative mass amounts (thin wrapper over
6483/// `Material::mix_by_mass`). Bare element symbols map to natural-element
6484/// placeholders; collapsed/elemental keys round-trip as symbols.
6485#[pyfunction]
6486fn mix_by_mass(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
6487    let mats: Vec<nucleide_material::Material> = parts
6488        .iter()
6489        .map(|(comp, _)| mat_from_comp_elements(comp.clone()))
6490        .collect::<PyResult<_>>()?;
6491    let refs: Vec<(&nucleide_material::Material, f64)> =
6492        mats.iter().zip(parts.iter().map(|(_, w)| *w)).collect();
6493    let out = nucleide_material::Material::mix_by_mass(&refs)
6494        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6495    Ok(mat_to_comp_elements(&out))
6496}
6497
6498/// Mix streams weighted by relative volumes, converting through each
6499/// stream's density (thin wrapper over `Material::mix_by_volume`).
6500/// `parts` holds `(comp, volume, density)` triples.
6501#[pyfunction]
6502fn mix_by_volume(parts: Vec<(BTreeMap<String, f64>, f64, f64)>) -> PyResult<BTreeMap<String, f64>> {
6503    let mut mats: Vec<nucleide_material::Material> = Vec::with_capacity(parts.len());
6504    for (comp, _, density) in &parts {
6505        let mut m = mat_from_comp_elements(comp.clone())?;
6506        m.set_density(Some(*density));
6507        mats.push(m);
6508    }
6509    let refs: Vec<(&nucleide_material::Material, f64)> =
6510        mats.iter().zip(parts.iter().map(|(_, v, _)| *v)).collect();
6511    let out = nucleide_material::Material::mix_by_volume(&refs)
6512        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6513    Ok(mat_to_comp_elements(&out))
6514}
6515
6516/// Specific activity of a composition in Bq/g (AME2020 + chain decays).
6517#[pyfunction]
6518fn specific_activity(comp: BTreeMap<String, f64>) -> PyResult<f64> {
6519    let mat = mat_from_comp_elements(comp)?;
6520    let analytics = nucleide_material::Analytics {
6521        masses: &nucleide_material::Ame2020,
6522        decays: &nucleide_material::ChainDecays,
6523    };
6524    mat.specific_activity(&analytics)
6525        .map_err(|e| PyValueError::new_err(e.to_string()))
6526}
6527
6528/// Serialize a `<materials>` document bundling named materials.
6529/// `entries` holds `(name, comp, density)` triples; `cross_sections`
6530/// sets the root attribute when given.
6531#[pyfunction]
6532#[pyo3(signature = (entries, cross_sections=None))]
6533fn materials_doc_to_xml(
6534    entries: Vec<(String, BTreeMap<String, f64>, f64)>,
6535    cross_sections: Option<String>,
6536) -> PyResult<String> {
6537    let mut doc = nucleide_material::MaterialsDoc::new();
6538    if let Some(path) = cross_sections {
6539        doc = doc.cross_sections(path);
6540    }
6541    for (name, comp, density) in entries {
6542        let mut mat = mat_from_comp_elements(comp)?;
6543        mat.set_density(Some(density));
6544        doc = doc.push(name, mat);
6545    }
6546    doc.to_xml()
6547        .map_err(|e| PyValueError::new_err(e.to_string()))
6548}
6549
6550/// Replace natural-element placeholders with isotopic breakdowns (AME2020 +
6551/// natural abundances). Bare element symbols are placeholders; nuclide
6552/// names pass through untouched.
6553#[pyfunction]
6554fn expand_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
6555    let mut mat = mat_from_comp_elements(comp)?;
6556    mat.expand_elements(
6557        &nucleide_material::Ame2020,
6558        &nucleide_material::NaturalAbundances,
6559    )
6560    .map_err(|e| PyValueError::new_err(e.to_string()))?;
6561    Ok(mat_to_comp_elements(&mat))
6562}
6563
6564/// Fold every nuclide into its element placeholder (bare-symbol keys).
6565#[pyfunction]
6566fn collapse_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
6567    let mat = mat_from_comp_elements(comp)?;
6568    Ok(mat_to_comp_elements(&mat.collapse_elements()))
6569}
6570
6571fn parse_fluka_nuc(spec: &str) -> PyResult<nucleide_fluka_io::material::FlukaNuc> {
6572    use nucleide_fluka_io::material::FlukaNuc;
6573    if let Ok(id) = NuclideId::from_name(spec) {
6574        return Ok(FlukaNuc::Nuclide(id));
6575    }
6576    if let Some(z) = bare_element_z(spec) {
6577        return Ok(FlukaNuc::Element(z));
6578    }
6579    if let Ok(z) = spec.trim().parse::<u32>() {
6580        if nucleide_nuclei::element_symbol(z).is_some() {
6581            return Ok(FlukaNuc::Element(z));
6582        }
6583    }
6584    Err(PyValueError::new_err(format!(
6585        "`{spec}`: unknown nuclide or element"
6586    )))
6587}
6588
6589/// Render the MATERIAL record for an elemental nuclide ("", when builtin).
6590#[pyfunction]
6591fn fluka_material_str(fid: u32, nuc: &str, density: f64) -> PyResult<String> {
6592    let parsed = parse_fluka_nuc(nuc)?;
6593    nucleide_fluka_io::material::material_str(fid, parsed, density)
6594        .map_err(|e| PyValueError::new_err(e.to_string()))
6595}
6596
6597/// Render MATERIAL + COMPOUND records for a compound.
6598/// `frac_type` is "mass" (default) or "atom"; `components` holds
6599/// `(nuclide-or-element, fraction)` pairs.
6600#[pyfunction]
6601#[pyo3(signature = (fid, compound_name, density, frac_type="mass", components=None))]
6602fn fluka_compound_str(
6603    fid: u32,
6604    compound_name: &str,
6605    density: f64,
6606    frac_type: &str,
6607    components: Option<Vec<(String, f64)>>,
6608) -> PyResult<String> {
6609    use nucleide_fluka_io::material::{Component, FracType};
6610    let frac = match frac_type.trim().to_ascii_lowercase().as_str() {
6611        "mass" => FracType::Mass,
6612        "atom" => FracType::Atom,
6613        other => {
6614            return Err(PyValueError::new_err(format!(
6615                "frac_type must be mass|atom, got `{other}`"
6616            )));
6617        }
6618    };
6619    let pairs = components.unwrap_or_default();
6620    let comps: Vec<Component> = pairs
6621        .iter()
6622        .map(|(nuc, frac)| parse_fluka_nuc(nuc).map(|n| Component::new(n, *frac)))
6623        .collect::<PyResult<_>>()?;
6624    nucleide_fluka_io::material::compound_str(fid, compound_name, density, frac, &comps)
6625        .map_err(|e| PyValueError::new_err(e.to_string()))
6626}
6627
6628/// Sorted built-in FLUKA material names.
6629#[pyfunction]
6630fn fluka_builtin_set() -> Vec<String> {
6631    let mut out: Vec<String> = nucleide_fluka_io::material::builtin_set()
6632        .into_iter()
6633        .map(str::to_string)
6634        .collect();
6635    out.sort();
6636    out
6637}
6638
6639/// Validate an ALARA deck's cross-references (parse + `validate`).
6640#[pyfunction]
6641fn alara_validate_deck(text: &str) -> PyResult<()> {
6642    let deck = nucleide_alara_io::AlaraDeck::parse(text)
6643        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6644    deck.validate()
6645        .map_err(|e| PyValueError::new_err(e.to_string()))
6646}
6647
6648/// Reject an unknown ALARA block keyword (`line` is 1-based).
6649#[pyfunction]
6650fn alara_check_block(block: &str, line: usize) -> PyResult<()> {
6651    nucleide_alara_io::AlaraDeck::check_block(block, line)
6652        .map_err(|e| PyValueError::new_err(e.to_string()))
6653}
6654
6655/// Sum of an ALARA group-flux spectrum over its groups.
6656#[pyfunction]
6657fn alara_flux_total(name: &str, text: &str) -> PyResult<f64> {
6658    nucleide_alara_io::FluxSpec::parse(name, text)
6659        .map(|f| f.total())
6660        .map_err(|e| PyValueError::new_err(e.to_string()))
6661}
6662
6663/// Number of groups in an ALARA group-flux spectrum.
6664#[pyfunction]
6665fn alara_flux_len(name: &str, text: &str) -> PyResult<usize> {
6666    nucleide_alara_io::FluxSpec::parse(name, text)
6667        .map(|f| f.len())
6668        .map_err(|e| PyValueError::new_err(e.to_string()))
6669}
6670
6671/// Keep only the `total` aggregate rows of an ALARA/FISPACT response frame.
6672#[pyfunction]
6673fn alara_output_totals(
6674    py: Python<'_>,
6675    text: &str,
6676    run_lbl: &str,
6677) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6678    let owned_text = text.to_owned();
6679    let owned_lbl = run_lbl.to_owned();
6680    let frame = py
6681        .detach(move || {
6682            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
6683                .map(|f| f.totals())
6684        })
6685        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6686    Ok(frame
6687        .rows
6688        .iter()
6689        .map(|r| fispact_row_to_map(py, r))
6690        .collect())
6691}
6692
6693/// Sum of `value` over SpecificActivity rows of a response frame.
6694#[pyfunction]
6695fn alara_output_total_activity(text: &str, run_lbl: &str) -> PyResult<f64> {
6696    nucleide_alara_io::output::ResponseFrame::parse(text, run_lbl)
6697        .map(|f| f.total_activity())
6698        .map_err(|e| PyValueError::new_err(e.to_string()))
6699}
6700
6701/// Sum over every group and strength of a `.photonSrc` listing.
6702#[pyfunction]
6703fn alara_photon_total_strength(text: &str) -> PyResult<f64> {
6704    nucleide_alara_io::PhotonSource::from_str(text)
6705        .map(|p| p.total_strength())
6706        .map_err(|e| PyValueError::new_err(e.to_string()))
6707}
6708
6709/// Total schedule time in seconds over a deck's expanded flat steps.
6710#[pyfunction]
6711#[pyo3(signature = (deck_text, top=None))]
6712fn alara_schedule_total_time(deck_text: &str, top: Option<&str>) -> PyResult<f64> {
6713    let owned = deck_text.to_owned();
6714    let owned_top = top.map(str::to_owned);
6715    let steps =
6716        expand_deck_schedules(&owned, owned_top.as_deref()).map_err(PyValueError::new_err)?;
6717    Ok(nucleide_alara_io::schedule::total_time(&steps))
6718}
6719
6720/// Find a TAPE6 record by nuclide name, or None.
6721#[pyfunction]
6722fn origen_tape6_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
6723    use pyo3::types::PyDict;
6724    let owned = text.to_owned();
6725    let query = nuclide.to_owned();
6726    let found = py
6727        .detach(move || nucleide_origen_io::Tape6::parse(&owned).map(|t| t.find(&query).cloned()))
6728        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6729    Ok(found.map(|r| {
6730        let d = PyDict::new(py);
6731        d.set_item("nuclide", &r.nuclide).ok();
6732        d.set_item("grams", r.grams).ok();
6733        d.set_item("activity_bq", r.activity_bq).ok();
6734        d.into_any().unbind()
6735    }))
6736}
6737
6738/// Total TAPE6 inventory activity in becquerel.
6739#[pyfunction]
6740fn origen_tape6_total_activity(text: &str) -> PyResult<f64> {
6741    nucleide_origen_io::Tape6::parse(text)
6742        .map(|t| t.total_activity())
6743        .map_err(|e| PyValueError::new_err(e.to_string()))
6744}
6745
6746/// Find a TAPE9 decay entry by nuclide name, or None.
6747#[pyfunction]
6748fn origen_tape9_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
6749    use pyo3::types::PyDict;
6750    let owned = text.to_owned();
6751    let query = nuclide.to_owned();
6752    let found = py
6753        .detach(move || {
6754            nucleide_origen_io::Tape9Entry::parse(&owned)
6755                .map(|entries| nucleide_origen_io::Tape9Entry::find(&entries, &query).cloned())
6756        })
6757        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6758    Ok(found.map(|e| {
6759        let d = PyDict::new(py);
6760        d.set_item("nuclide", &e.nuclide).ok();
6761        d.set_item("decay_const", e.decay_const).ok();
6762        d.into_any().unbind()
6763    }))
6764}
6765
6766/// Number of spatial points in an RTFLUX/ATFLUX/RZFLUX file.
6767#[pyfunction]
6768#[pyo3(signature = (text, kind="rtflux"))]
6769fn cccc_rtflux_npoints(text: &str, kind: &str) -> PyResult<usize> {
6770    let flux_kind = parse_flux_kind(kind)?;
6771    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
6772        .map(|f| f.npoints())
6773        .map_err(|e| PyValueError::new_err(e.to_string()))
6774}
6775
6776/// Flux vector for point `i`, or None when out of range.
6777#[pyfunction]
6778#[pyo3(signature = (text, kind="rtflux", index=0))]
6779fn cccc_rtflux_point(text: &str, kind: &str, index: usize) -> PyResult<Option<Vec<f64>>> {
6780    let flux_kind = parse_flux_kind(kind)?;
6781    let flux = nucleide_cccc_io::FluxFile::parse(flux_kind, text)
6782        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6783    Ok(flux.point(index).map(<[f64]>::to_vec))
6784}
6785
6786/// Sum of all flux values in an RTFLUX/ATFLUX/RZFLUX file.
6787#[pyfunction]
6788#[pyo3(signature = (text, kind="rtflux"))]
6789fn cccc_rtflux_total(text: &str, kind: &str) -> PyResult<f64> {
6790    let flux_kind = parse_flux_kind(kind)?;
6791    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
6792        .map(|f| f.total())
6793        .map_err(|e| PyValueError::new_err(e.to_string()))
6794}
6795
6796fn parse_flux_kind(kind: &str) -> PyResult<nucleide_cccc_io::rtflux::FluxKind> {
6797    match kind.to_ascii_lowercase().as_str() {
6798        "rtflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rtflux),
6799        "atflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Atflux),
6800        "rzflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rzflux),
6801        other => Err(PyValueError::new_err(format!(
6802            "kind must be rtflux|atflux|rzflux, got `{other}`"
6803        ))),
6804    }
6805}
6806
6807/// Find an ISOTXS nuclide by label, or None.
6808#[pyfunction]
6809fn cccc_isotxs_find(py: Python<'_>, text: &str, label: &str) -> PyResult<Option<Py<PyAny>>> {
6810    use pyo3::types::PyDict;
6811    let owned = text.to_owned();
6812    let query = label.to_owned();
6813    let found = py
6814        .detach(move || {
6815            nucleide_cccc_io::IsotxsLib::parse(&owned).map(|lib| lib.find(&query).cloned())
6816        })
6817        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6818    Ok(found.map(|n| {
6819        let d = PyDict::new(py);
6820        d.set_item("label", &n.label).ok();
6821        d.set_item("zaid", &n.zaid).ok();
6822        d.set_item("groups", n.groups).ok();
6823        d.set_item("total_xs", n.total_xs.clone()).ok();
6824        d.into_any().unbind()
6825    }))
6826}
6827
6828/// Number of nuclides in an ISOTXS library.
6829#[pyfunction]
6830fn cccc_isotxs_len(text: &str) -> PyResult<usize> {
6831    nucleide_cccc_io::IsotxsLib::parse(text)
6832        .map(|lib| lib.len())
6833        .map_err(|e| PyValueError::new_err(e.to_string()))
6834}
6835
6836/// Identify a FISPACT-II output by its `.fis` suffix convention.
6837#[pyfunction]
6838fn fispact_is_output(path: &str) -> bool {
6839    nucleide_fispact_io::is_fispact_output(path)
6840}
6841
6842/// Product-per-feed mass ratio for assays `x_feed`, `x_prod`, `x_tail`.
6843#[pyfunction]
6844fn enrichment_prod_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6845    nucleide_enrichment::prod_per_feed(x_feed, x_prod, x_tail)
6846}
6847
6848/// Tails-per-feed mass ratio.
6849#[pyfunction]
6850fn enrichment_tail_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6851    nucleide_enrichment::tail_per_feed(x_feed, x_prod, x_tail)
6852}
6853
6854/// Tails-per-product mass ratio.
6855#[pyfunction]
6856fn enrichment_tail_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6857    nucleide_enrichment::tail_per_prod(x_feed, x_prod, x_tail)
6858}
6859
6860/// Feed-per-product mass ratio.
6861#[pyfunction]
6862fn enrichment_feed_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6863    nucleide_enrichment::feed_per_prod(x_feed, x_prod, x_tail)
6864}
6865
6866/// Feed-per-tails mass ratio.
6867#[pyfunction]
6868fn enrichment_feed_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6869    nucleide_enrichment::feed_per_tail(x_feed, x_prod, x_tail)
6870}
6871
6872/// Product-per-tails mass ratio.
6873#[pyfunction]
6874fn enrichment_prod_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
6875    nucleide_enrichment::prod_per_tail(x_feed, x_prod, x_tail)
6876}
6877
6878/// Stage separation factor for a component of mass `m_i`.
6879#[pyfunction]
6880#[allow(non_snake_case)]
6881fn enrichment_alphastar_i(alpha: f64, Mstar: f64, M_i: f64) -> f64 {
6882    nucleide_enrichment::alphastar_i(alpha, Mstar, M_i)
6883}
6884
6885/// Validated delayed-neutron data from OpenMC IFP kinetics data.
6886///
6887/// OpenMC's IFP estimator reports effective delayed fractions (`betas`)
6888/// and the generation time (`lambda_gen`) but no precursor decay
6889/// constants: the caller supplies `lambdas` from the same data library.
6890/// Returns {betas, lambdas, lambda_gen, beta_total, groups}.
6891#[pyfunction]
6892fn kinetics_from_ifp(
6893    py: Python<'_>,
6894    betas: Vec<f64>,
6895    lambda_gen: f64,
6896    lambdas: Vec<f64>,
6897) -> PyResult<Py<PyAny>> {
6898    use pyo3::types::PyDict;
6899    let params = nucleide_kinetics::KineticParams::from_ifp(betas, lambda_gen, lambdas)
6900        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6901    let d = PyDict::new(py);
6902    d.set_item("betas", params.betas()).ok();
6903    d.set_item("lambdas", params.lambdas()).ok();
6904    d.set_item("lambda_gen", params.lambda_gen()).ok();
6905    d.set_item("beta_total", params.beta_total()).ok();
6906    d.set_item("groups", params.groups()).ok();
6907    Ok(d.into_any().unbind())
6908}
6909
6910/// Run MAGIC with explicit array selection and parameters.
6911/// `selection` is "total" (default) or "per_group".
6912#[pyfunction]
6913#[pyo3(signature = (tally, selection="total", tolerance=0.5, null_value=0.0))]
6914fn magic_with(
6915    tally: &PyMeshTally,
6916    selection: &str,
6917    tolerance: f64,
6918    null_value: f64,
6919) -> PyResult<PyMagicOutput> {
6920    let sel = match selection.trim().to_ascii_lowercase().as_str() {
6921        "total" => nucleide_vr_tools::magic::MagicSelection::Total,
6922        "per_group" | "pergroup" | "per-group" => {
6923            nucleide_vr_tools::magic::MagicSelection::PerGroup
6924        }
6925        other => {
6926            return Err(PyValueError::new_err(format!(
6927                "selection must be total|per_group, got `{other}`"
6928            )));
6929        }
6930    };
6931    let params = nucleide_vr_tools::magic::MagicParams {
6932        tolerance,
6933        null_value,
6934    };
6935    nucleide_vr_tools::magic::magic_with(&tally.inner, sel, params)
6936        .map(|inner| PyMagicOutput { inner })
6937        .map_err(|e| PyValueError::new_err(e.to_string()))
6938}
6939
6940/// Check one `stat:sum:<key>:<24-char value>` MCPL header comment.
6941#[pyfunction]
6942fn mcpl_statsum_validate(comment: &str) -> PyResult<String> {
6943    nucleide_mcpl_io::statsum_validate(comment)
6944        .map(str::to_string)
6945        .map_err(PyValueError::new_err)
6946}
6947
6948/// Build a well-formed `stat:sum:` MCPL header comment.
6949#[pyfunction]
6950fn mcpl_statsum_comment(key: &str, value: f64) -> PyResult<String> {
6951    nucleide_mcpl_io::statsum_comment(key, value).map_err(|e| PyValueError::new_err(e.to_string()))
6952}
6953
6954/// Python module entry point.
6955#[pymodule]
6956fn _internal(m: &Bound<'_, PyModule>) -> PyResult<()> {
6957    m.add_function(wrap_pyfunction!(version, m)?)?;
6958    m.add_function(wrap_pyfunction!(from_zaid, m)?)?;
6959    m.add_function(wrap_pyfunction!(atomic_mass, m)?)?;
6960    m.add_function(wrap_pyfunction!(natural_abundance, m)?)?;
6961    m.add_function(wrap_pyfunction!(rxname_id, m)?)?;
6962    m.add_function(wrap_pyfunction!(rxname_name, m)?)?;
6963    m.add_function(wrap_pyfunction!(rxname_mt, m)?)?;
6964    m.add_function(wrap_pyfunction!(rxname_label, m)?)?;
6965    m.add_function(wrap_pyfunction!(rxname_doc, m)?)?;
6966    m.add_function(wrap_pyfunction!(rxname_reaction, m)?)?;
6967    m.add_function(wrap_pyfunction!(rxname_id_from_nucdelta, m)?)?;
6968    m.add_function(wrap_pyfunction!(rxname_child, m)?)?;
6969    m.add_function(wrap_pyfunction!(rxname_parent, m)?)?;
6970    m.add_function(wrap_pyfunction!(particle_is_valid, m)?)?;
6971    m.add_function(wrap_pyfunction!(particle_is_valid_pdc, m)?)?;
6972    m.add_function(wrap_pyfunction!(particle_is_hydrogen, m)?)?;
6973    m.add_function(wrap_pyfunction!(particle_is_heavy_ion, m)?)?;
6974    m.add_function(wrap_pyfunction!(dose_f1, m)?)?;
6975    m.add_function(wrap_pyfunction!(dose_lung_model, m)?)?;
6976    m.add_function(wrap_pyfunction!(read_xsdir, m)?)?;
6977    m.add_function(wrap_pyfunction!(read_meshtal, m)?)?;
6978    m.add_function(wrap_pyfunction!(read_wwinp, m)?)?;
6979    m.add_function(wrap_pyfunction!(read_mctal, m)?)?;
6980    m.add_function(wrap_pyfunction!(read_ssw, m)?)?;
6981    m.add_function(wrap_pyfunction!(read_ptrac, m)?)?;
6982    m.add_function(wrap_pyfunction!(read_mcpl, m)?)?;
6983    m.add_function(wrap_pyfunction!(write_mcpl, m)?)?;
6984    m.add_function(wrap_pyfunction!(ssw2mcpl, m)?)?;
6985    m.add_function(wrap_pyfunction!(mcpl2ssw, m)?)?;
6986    m.add_function(wrap_pyfunction!(read_endl, m)?)?;
6987    m.add_function(wrap_pyfunction!(endl_endftod, m)?)?;
6988    m.add_function(wrap_pyfunction!(combine_ssw_files, m)?)?;
6989    m.add_function(wrap_pyfunction!(read_chain, m)?)?;
6990    m.add_function(wrap_pyfunction!(build_depletion_system, m)?)?;
6991    m.add_function(wrap_pyfunction!(deplete, m)?)?;
6992    m.add_function(wrap_pyfunction!(deplete_series, m)?)?;
6993    m.add_function(wrap_pyfunction!(simple_xs, m)?)?;
6994    m.add_function(wrap_pyfunction!(scattering_length, m)?)?;
6995    m.add_function(wrap_pyfunction!(decay_energy, m)?)?;
6996    m.add_function(wrap_pyfunction!(decay_branches, m)?)?;
6997    m.add_function(wrap_pyfunction!(decay_branch_fraction, m)?)?;
6998    m.add_function(wrap_pyfunction!(fission_yields, m)?)?;
6999    m.add_function(wrap_pyfunction!(fission_yield, m)?)?;
7000    m.add_function(wrap_pyfunction!(normalize_nuclide, m)?)?;
7001    m.add_function(wrap_pyfunction!(decay_heat, m)?)?;
7002    m.add_function(wrap_pyfunction!(dose_factor, m)?)?;
7003    m.add_function(wrap_pyfunction!(dose_per_g, m)?)?;
7004    m.add_function(wrap_pyfunction!(mix_by_mass, m)?)?;
7005    m.add_function(wrap_pyfunction!(mix_by_volume, m)?)?;
7006    m.add_function(wrap_pyfunction!(specific_activity, m)?)?;
7007    m.add_function(wrap_pyfunction!(materials_doc_to_xml, m)?)?;
7008    m.add_function(wrap_pyfunction!(expand_elements, m)?)?;
7009    m.add_function(wrap_pyfunction!(collapse_elements, m)?)?;
7010    m.add_function(wrap_pyfunction!(read_serpent, m)?)?;
7011    m.add_function(wrap_pyfunction!(read_usrbin, m)?)?;
7012    m.add_function(wrap_pyfunction!(fluka_material_str, m)?)?;
7013    m.add_function(wrap_pyfunction!(fluka_compound_str, m)?)?;
7014    m.add_function(wrap_pyfunction!(fluka_builtin_set, m)?)?;
7015    m.add_function(wrap_pyfunction!(magic, m)?)?;
7016    m.add_function(wrap_pyfunction!(magic_with, m)?)?;
7017    m.add_function(wrap_pyfunction!(write_ssw, m)?)?;
7018    m.add_function(wrap_pyfunction!(mesh_to_geom, m)?)?;
7019    m.add_function(wrap_pyfunction!(half_life, m)?)?;
7020    m.add_function(wrap_pyfunction!(decay_constant, m)?)?;
7021    m.add_function(wrap_pyfunction!(q_value_capture, m)?)?;
7022    m.add_function(wrap_pyfunction!(q_value_alpha, m)?)?;
7023    m.add_function(wrap_pyfunction!(read_inp, m)?)?;
7024    m.add_function(wrap_pyfunction!(from_formula, m)?)?;
7025    m.add_function(wrap_pyfunction!(activity, m)?)?;
7026    m.add_function(wrap_pyfunction!(to_xml, m)?)?;
7027    m.add_function(wrap_pyfunction!(alara_parse_deck, m)?)?;
7028    m.add_function(wrap_pyfunction!(alara_parse_flux, m)?)?;
7029    m.add_function(wrap_pyfunction!(alara_parse_output, m)?)?;
7030    m.add_function(wrap_pyfunction!(alara_expand_schedule, m)?)?;
7031    m.add_function(wrap_pyfunction!(alara_validate_deck, m)?)?;
7032    m.add_function(wrap_pyfunction!(alara_check_block, m)?)?;
7033    m.add_function(wrap_pyfunction!(alara_flux_total, m)?)?;
7034    m.add_function(wrap_pyfunction!(alara_flux_len, m)?)?;
7035    m.add_function(wrap_pyfunction!(alara_output_totals, m)?)?;
7036    m.add_function(wrap_pyfunction!(alara_output_total_activity, m)?)?;
7037    m.add_function(wrap_pyfunction!(alara_photon_total_strength, m)?)?;
7038    m.add_function(wrap_pyfunction!(alara_schedule_total_time, m)?)?;
7039    m.add_function(wrap_pyfunction!(isotxs_parse, m)?)?;
7040    m.add_function(wrap_pyfunction!(rtflux_parse, m)?)?;
7041    m.add_function(wrap_pyfunction!(cccc_rtflux_npoints, m)?)?;
7042    m.add_function(wrap_pyfunction!(cccc_rtflux_point, m)?)?;
7043    m.add_function(wrap_pyfunction!(cccc_rtflux_total, m)?)?;
7044    m.add_function(wrap_pyfunction!(cccc_isotxs_find, m)?)?;
7045    m.add_function(wrap_pyfunction!(cccc_isotxs_len, m)?)?;
7046    m.add_function(wrap_pyfunction!(partisn_render, m)?)?;
7047    m.add_function(wrap_pyfunction!(partisn_validate, m)?)?;
7048    m.add_function(wrap_pyfunction!(fispact_parse_output, m)?)?;
7049    m.add_function(wrap_pyfunction!(fispact_is_output, m)?)?;
7050    m.add_function(wrap_pyfunction!(origen_parse_tape5, m)?)?;
7051    m.add_function(wrap_pyfunction!(origen_parse_tape6, m)?)?;
7052    m.add_function(wrap_pyfunction!(origen_parse_tape9, m)?)?;
7053    m.add_function(wrap_pyfunction!(origen_tape6_find, m)?)?;
7054    m.add_function(wrap_pyfunction!(origen_tape6_total_activity, m)?)?;
7055    m.add_function(wrap_pyfunction!(origen_tape9_find, m)?)?;
7056    m.add_function(wrap_pyfunction!(r2s_from_deck, m)?)?;
7057    m.add_function(wrap_pyfunction!(r2s_from_snapshot, m)?)?;
7058    m.add_function(wrap_pyfunction!(r2s_validate, m)?)?;
7059    m.add_function(wrap_pyfunction!(r2s_expand, m)?)?;
7060    m.add_function(wrap_pyfunction!(r2s_assemble, m)?)?;
7061    m.add_function(wrap_pyfunction!(r2s_tag_zone_strength, m)?)?;
7062    m.add_function(wrap_pyfunction!(r2s_photon_group_sums, m)?)?;
7063    m.add_function(wrap_pyfunction!(kinetics_solve, m)?)?;
7064    m.add_function(wrap_pyfunction!(kinetics_equilibrium, m)?)?;
7065    m.add_function(wrap_pyfunction!(kinetics_initial_rate, m)?)?;
7066    m.add_function(wrap_pyfunction!(kinetics_inhour_rho, m)?)?;
7067    m.add_function(wrap_pyfunction!(kinetics_stable_period, m)?)?;
7068    m.add_function(wrap_pyfunction!(kinetics_prompt_jump, m)?)?;
7069    m.add_function(wrap_pyfunction!(kinetics_from_ifp, m)?)?;
7070    m.add_function(wrap_pyfunction!(spectroscopy_rect_smooth, m)?)?;
7071    m.add_function(wrap_pyfunction!(spectroscopy_five_point_smooth, m)?)?;
7072    m.add_function(wrap_pyfunction!(spectroscopy_calc_bg, m)?)?;
7073    m.add_function(wrap_pyfunction!(spectroscopy_gross_count, m)?)?;
7074    m.add_function(wrap_pyfunction!(spectroscopy_net_counts, m)?)?;
7075    m.add_function(wrap_pyfunction!(spectroscopy_energy_bins, m)?)?;
7076    m.add_function(wrap_pyfunction!(spectroscopy_detector_efficiency, m)?)?;
7077    m.add_function(wrap_pyfunction!(spectroscopy_fit_efficiency, m)?)?;
7078    m.add_function(wrap_pyfunction!(spectroscopy_xray_lines, m)?)?;
7079    m.add_function(wrap_pyfunction!(spectroscopy_sdef_decay_source, m)?)?;
7080    m.add_function(wrap_pyfunction!(spectroscopy_parse_dollar_spe, m)?)?;
7081    m.add_function(wrap_pyfunction!(spectroscopy_parse_spe, m)?)?;
7082    m.add_function(wrap_pyfunction!(spectroscopy_read_dollar_spe, m)?)?;
7083    m.add_function(wrap_pyfunction!(spectroscopy_read_spe, m)?)?;
7084    m.add_function(wrap_pyfunction!(spectroscopy_parse_lines_tsv, m)?)?;
7085    m.add_function(wrap_pyfunction!(spectroscopy_read_decay_lines, m)?)?;
7086    m.add_function(wrap_pyfunction!(uq_sample_mvn, m)?)?;
7087    m.add_function(wrap_pyfunction!(uq_sample_lhs, m)?)?;
7088    m.add_function(wrap_pyfunction!(uq_sample_lognormal, m)?)?;
7089    m.add_function(wrap_pyfunction!(uq_lognormal_mean, m)?)?;
7090    m.add_function(wrap_pyfunction!(uq_lognormal_cov, m)?)?;
7091    m.add_function(wrap_pyfunction!(uq_sample_mean, m)?)?;
7092    m.add_function(wrap_pyfunction!(uq_sample_cov, m)?)?;
7093    m.add_function(wrap_pyfunction!(uq_check_convergence, m)?)?;
7094    m.add_function(wrap_pyfunction!(uq_perturb_branches, m)?)?;
7095    m.add_function(wrap_pyfunction!(uq_perturb_energies, m)?)?;
7096    m.add_function(wrap_pyfunction!(uq_passthrough, m)?)?;
7097    m.add_function(wrap_pyfunction!(uq_perturb_fission_yields, m)?)?;
7098    m.add_function(wrap_pyfunction!(parse_deck, m)?)?;
7099    m.add_function(wrap_pyfunction!(read_deck, m)?)?;
7100    m.add_function(wrap_pyfunction!(cumulative_decays, m)?)?;
7101    m.add_function(wrap_pyfunction!(progeny, m)?)?;
7102    m.add_function(wrap_pyfunction!(branching_fraction, m)?)?;
7103    m.add_function(wrap_pyfunction!(decay_mode, m)?)?;
7104    m.add_function(wrap_pyfunction!(chain_edges, m)?)?;
7105    m.add_function(wrap_pyfunction!(armi_to_nucid, m)?)?;
7106    m.add_function(wrap_pyfunction!(nucid_to_armi, m)?)?;
7107    m.add_function(wrap_pyfunction!(mcc3_to_nucid, m)?)?;
7108    m.add_function(wrap_pyfunction!(check_labels, m)?)?;
7109    m.add_function(wrap_pyfunction!(audit_material, m)?)?;
7110    m.add_function(wrap_pyfunction!(separate_material, m)?)?;
7111    m.add_function(wrap_pyfunction!(blend_material, m)?)?;
7112    m.add_function(wrap_pyfunction!(enrichment_value_func, m)?)?;
7113    m.add_function(wrap_pyfunction!(enrichment_swu_per_feed, m)?)?;
7114    m.add_function(wrap_pyfunction!(enrichment_swu_per_prod, m)?)?;
7115    m.add_function(wrap_pyfunction!(enrichment_swu_per_tail, m)?)?;
7116    m.add_function(wrap_pyfunction!(enrichment_prod_per_feed, m)?)?;
7117    m.add_function(wrap_pyfunction!(enrichment_tail_per_feed, m)?)?;
7118    m.add_function(wrap_pyfunction!(enrichment_tail_per_prod, m)?)?;
7119    m.add_function(wrap_pyfunction!(enrichment_feed_per_prod, m)?)?;
7120    m.add_function(wrap_pyfunction!(enrichment_feed_per_tail, m)?)?;
7121    m.add_function(wrap_pyfunction!(enrichment_prod_per_tail, m)?)?;
7122    m.add_function(wrap_pyfunction!(enrichment_alphastar_i, m)?)?;
7123    m.add_function(wrap_pyfunction!(mcpl_statsum_validate, m)?)?;
7124    m.add_function(wrap_pyfunction!(mcpl_statsum_comment, m)?)?;
7125    m.add_class::<PyCusum>()?;
7126    m.add_function(wrap_pyfunction!(emit_cards, m)?)?;
7127    m.add_function(wrap_pyfunction!(emit_drift_table, m)?)?;
7128    m.add_function(wrap_pyfunction!(emit_armi_cards, m)?)?;
7129    m.add_function(wrap_pyfunction!(emit_armi_drift_table, m)?)?;
7130    m.add_class::<PyNuclide>()?;
7131    m.add_class::<PyParticle>()?;
7132    m.add_class::<PyXsdir>()?;
7133    m.add_class::<PyXsdirTable>()?;
7134    m.add_class::<PyMeshtal>()?;
7135    m.add_class::<PyMeshTally>()?;
7136    m.add_class::<PyWwinp>()?;
7137    m.add_class::<PyMctal>()?;
7138    m.add_class::<PySurfSrc>()?;
7139    m.add_class::<PyPtracFile>()?;
7140    m.add_class::<PyMcplFile>()?;
7141    m.add_class::<PyEndlLibrary>()?;
7142    m.add_class::<PyChain>()?;
7143    m.add_class::<PyDepletionSystem>()?;
7144    m.add_class::<PyUsrbinTally>()?;
7145    m.add_class::<PyMagicOutput>()?;
7146    m.add_class::<PyAliasTable>()?;
7147    m.add_class::<PyMeshSourceSampler>()?;
7148    m.add_class::<PyCascade>()?;
7149    m.add_class::<PyMaterialsCompendium>()?;
7150    m.add_class::<PyDeckProblem>()?;
7151    m.add_class::<PyInventory>()?;
7152    Ok(())
7153}