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#[pyclass(name = "Mctal")]
729struct PyMctal {
730    inner: nucleide_mcnp_io::mctal::Mctal,
731}
732
733#[pymethods]
734impl PyMctal {
735    #[getter]
736    fn code_name(&self) -> &str {
737        &self.inner.code_name
738    }
739    #[getter]
740    fn comment(&self) -> &str {
741        &self.inner.comment
742    }
743    #[getter]
744    fn n_histories(&self) -> u64 {
745        self.inner.n_histories
746    }
747    #[getter]
748    fn n_cycles(&self) -> usize {
749        self.inner.n_cycles
750    }
751    #[getter]
752    fn n_inactive(&self) -> usize {
753        self.inner.n_inactive
754    }
755    #[getter]
756    fn vars_per_cycle(&self) -> usize {
757        self.inner.vars_per_cycle
758    }
759    #[getter]
760    fn k_col(&self) -> Vec<f64> {
761        self.inner.k_col.clone()
762    }
763    #[getter]
764    fn k_abs(&self) -> Vec<f64> {
765        self.inner.k_abs.clone()
766    }
767    #[getter]
768    fn k_path(&self) -> Vec<f64> {
769        self.inner.k_path.clone()
770    }
771    #[getter]
772    fn prompt_life_col(&self) -> Vec<f64> {
773        self.inner.prompt_life_col.clone()
774    }
775    #[getter]
776    fn prompt_life_path(&self) -> Vec<f64> {
777        self.inner.prompt_life_path.clone()
778    }
779    /// Running averages (empty unless vars_per_cycle == 19); each entry is a
780    /// dict of the averaged pairs plus cycle_histories/fom.
781    #[getter]
782    fn averages(&self) -> Vec<BTreeMap<String, f64>> {
783        self.inner
784            .averages
785            .iter()
786            .map(|a| {
787                let mut m = BTreeMap::new();
788                m.insert("avg_k_col".into(), a.avg_k_col.0);
789                m.insert("avg_k_col_stdev".into(), a.avg_k_col.1);
790                m.insert("avg_k_abs".into(), a.avg_k_abs.0);
791                m.insert("avg_k_abs_stdev".into(), a.avg_k_abs.1);
792                m.insert("avg_k_path".into(), a.avg_k_path.0);
793                m.insert("avg_k_path_stdev".into(), a.avg_k_path.1);
794                m.insert("avg_k_combined".into(), a.avg_k_combined.0);
795                m.insert("avg_k_combined_stdev".into(), a.avg_k_combined.1);
796                m.insert("avg_k_combined_active".into(), a.avg_k_combined_active.0);
797                m.insert(
798                    "avg_k_combined_active_stdev".into(),
799                    a.avg_k_combined_active.1,
800                );
801                m.insert("prompt_life_combined".into(), a.prompt_life_combined.0);
802                m.insert(
803                    "prompt_life_combined_stdev".into(),
804                    a.prompt_life_combined.1,
805                );
806                m.insert("cycle_histories".into(), a.cycle_histories);
807                m.insert("fom".into(), a.fom);
808                m
809            })
810            .collect()
811    }
812    /// Per-cycle kcode series as 1-D float64 NumPy arrays.
813    ///
814    /// Returns `(k_col, k_abs, k_path, prompt_life_col, prompt_life_path)`,
815    /// each of shape `(n_cycles,)` in cycle order, C-order float64. Each
816    /// array is owned, writable, and decoupled from the file data (the
817    /// `Vec` is cloned then moved into the array; later mutation is not
818    /// reflected). Requires NumPy installed at runtime. See the `k_col`,
819    /// `k_abs`, `k_path`, `prompt_life_col`, `prompt_life_path` getters for
820    /// the plain-copy lists over the same data; use those when NumPy is
821    /// unavailable.
822    #[allow(clippy::type_complexity)]
823    fn k_arrays<'py>(
824        &self,
825        py: Python<'py>,
826    ) -> PyResult<(
827        Bound<'py, PyArray1<f64>>,
828        Bound<'py, PyArray1<f64>>,
829        Bound<'py, PyArray1<f64>>,
830        Bound<'py, PyArray1<f64>>,
831        Bound<'py, PyArray1<f64>>,
832    )> {
833        Ok((
834            self.inner.k_col.clone().into_pyarray(py),
835            self.inner.k_abs.clone().into_pyarray(py),
836            self.inner.k_path.clone().into_pyarray(py),
837            self.inner.prompt_life_col.clone().into_pyarray(py),
838            self.inner.prompt_life_path.clone().into_pyarray(py),
839        ))
840    }
841    /// Running averages as a 2-D float64 NumPy array.
842    ///
843    /// Shape is `(n_cycles, 14)` (empty `averages` yields `(0, 14)`) with
844    /// one row per cycle in cycle order, C-order float64. Columns are
845    /// `avg_k_col`, `avg_k_col_stdev`, `avg_k_abs`, `avg_k_abs_stdev`,
846    /// `avg_k_path`, `avg_k_path_stdev`, `avg_k_combined`,
847    /// `avg_k_combined_stdev`, `avg_k_combined_active`,
848    /// `avg_k_combined_active_stdev`, `prompt_life_combined`,
849    /// `prompt_life_combined_stdev`, `cycle_histories`, `fom` — the same
850    /// values as the `averages` dicts, in a fixed column order. The array
851    /// is owned, writable, and decoupled. Requires NumPy at runtime.
852    fn averages_array<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyArray2<f64>>> {
853        let n = self.inner.averages.len();
854        let mut flat = Vec::with_capacity(n * 14);
855        for a in &self.inner.averages {
856            flat.extend_from_slice(&[
857                a.avg_k_col.0,
858                a.avg_k_col.1,
859                a.avg_k_abs.0,
860                a.avg_k_abs.1,
861                a.avg_k_path.0,
862                a.avg_k_path.1,
863                a.avg_k_combined.0,
864                a.avg_k_combined.1,
865                a.avg_k_combined_active.0,
866                a.avg_k_combined_active.1,
867                a.prompt_life_combined.0,
868                a.prompt_life_combined.1,
869                a.cycle_histories,
870                a.fom,
871            ]);
872        }
873        m_err(
874            flat.into_pyarray(py)
875                .reshape((n, 14))
876                .map_err(|e| e.to_string()),
877        )
878    }
879    /// Optional third token of the `tally` line (perturbation count when
880    /// present; stored verbatim — perturbation bodies are named-open).
881    #[getter]
882    fn npert(&self) -> Option<String> {
883        self.inner.npert.clone()
884    }
885    /// Declared tally numbers from the header.
886    #[getter]
887    fn tally_nums(&self) -> Vec<u32> {
888        self.inner.tally_nums.clone()
889    }
890    /// Parsed standard-tally bodies in file order (empty for legacy
891    /// kcode-only files). Each entry is a dict with `number`,
892    /// `particle_type`, `detector_type` (or None), `particle_list`,
893    /// `comment` (FC lines), one `{count, values}` dict per bin card
894    /// (`f`, `d`, `u`, `s`, `m`, `c`, `e`, `t`), `vals` (list of
895    /// `(value, rel_error)` pairs in file order), and `total` (sum of values).
896    #[getter]
897    fn tallies(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
898        use pyo3::types::PyDict;
899        let mut out = Vec::with_capacity(self.inner.tallies.len());
900        for t in &self.inner.tallies {
901            let d = PyDict::new(py);
902            d.set_item("number", t.number)?;
903            d.set_item("particle_type", t.particle_type)?;
904            d.set_item("detector_type", t.detector_type)?;
905            d.set_item("particle_list", t.particle_list.clone())?;
906            d.set_item("comment", t.comment.clone())?;
907            for (key, card) in [
908                ("f", &t.f),
909                ("d", &t.d),
910                ("u", &t.u),
911                ("s", &t.s),
912                ("m", &t.m),
913                ("c", &t.c),
914                ("e", &t.e),
915                ("t", &t.t),
916            ] {
917                let c = PyDict::new(py);
918                c.set_item("count", card.count)?;
919                c.set_item("values", card.values.clone())?;
920                d.set_item(key, c)?;
921            }
922            let vals: Vec<(f64, f64)> = t.vals.clone();
923            d.set_item("vals", vals)?;
924            d.set_item("total", t.total_val())?;
925            out.push(d.into_any().unbind());
926        }
927        Ok(out)
928    }
929    /// Tally `vals` as a 2-D float64 NumPy array.
930    ///
931    /// Shape is `(n_pairs, 2)` with one `(value, rel_error)` row per pair
932    /// in file order, C-order float64. Tallies without bodies yield
933    /// `(0, 2)`. The array is owned, writable, and decoupled. Requires
934    /// NumPy at runtime. See the `tallies` dicts for the plain-copy lists
935    /// over the same data; use those when NumPy is unavailable.
936    fn tally_vals_array<'py>(
937        &self,
938        py: Python<'py>,
939        number: u32,
940    ) -> PyResult<Bound<'py, PyArray2<f64>>> {
941        let tally = self
942            .inner
943            .tallies
944            .iter()
945            .find(|t| t.number == number)
946            .ok_or_else(|| {
947                PyValueError::new_err(format!("mctal has no parsed body for tally {number}"))
948            })?;
949        let mut flat = Vec::with_capacity(tally.vals.len() * 2);
950        for (v, e) in &tally.vals {
951            flat.push(*v);
952            flat.push(*e);
953        }
954        let n = tally.vals.len();
955        m_err(
956            flat.into_pyarray(py)
957                .reshape((n, 2))
958                .map_err(|e| e.to_string()),
959        )
960    }
961}
962
963/// Parse an MCNP MCTAL file (headers, standard tally bodies, and kcode).
964#[pyfunction]
965fn read_mctal(path: &str) -> PyResult<PyMctal> {
966    m_err(nucleide_mcnp_io::mctal::Mctal::from_file(path).map(|inner| PyMctal { inner }))
967}
968
969/// Parsed SSW surface-source file.
970#[pyclass(name = "SurfSrc")]
971struct PySurfSrc {
972    inner: nucleide_mcnp_io::surfsrc::SurfSrc,
973}
974
975#[pymethods]
976impl PySurfSrc {
977    #[getter]
978    fn kod(&self) -> String {
979        self.inner.header.kod.trim_end().to_string()
980    }
981    #[getter]
982    fn ver(&self) -> String {
983        self.inner.header.ver.trim_end().to_string()
984    }
985    #[getter]
986    fn np1(&self) -> i64 {
987        self.inner.header.np1
988    }
989    /// Signed stored `np1` (negative ⇒ the file carries table 2).
990    #[getter]
991    fn orignp1(&self) -> i64 {
992        self.inner.header.orignp1
993    }
994    #[getter]
995    fn nrss(&self) -> i64 {
996        self.inner.header.nrss
997    }
998    #[getter]
999    fn ncrd(&self) -> i32 {
1000        self.inner.header.ncrd
1001    }
1002    #[getter]
1003    fn njsw(&self) -> i32 {
1004        self.inner.header.njsw
1005    }
1006    #[getter]
1007    fn niss(&self) -> i64 {
1008        self.inner.header.niss
1009    }
1010    /// Formatted header block.
1011    fn print_header(&self) -> String {
1012        self.inner.header.print_header()
1013    }
1014    /// Track records as dicts of named fields.
1015    fn tracks(&self) -> PyResult<Vec<BTreeMap<String, f64>>> {
1016        let tracks = self
1017            .inner
1018            .read_tracklist()
1019            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1020        Ok(tracks
1021            .iter()
1022            .map(|t| {
1023                let mut d = BTreeMap::new();
1024                d.insert("nps".into(), t.nps);
1025                d.insert("bitarray".into(), t.bitarray);
1026                d.insert("wgt".into(), t.wgt);
1027                d.insert("erg".into(), t.erg);
1028                d.insert("tme".into(), t.tme);
1029                d.insert("x".into(), t.x);
1030                d.insert("y".into(), t.y);
1031                d.insert("z".into(), t.z);
1032                d.insert("u".into(), t.u);
1033                d.insert("v".into(), t.v);
1034                d.insert("cs".into(), t.cs);
1035                d.insert("w".into(), t.w);
1036                d
1037            })
1038            .collect())
1039    }
1040}
1041
1042/// Read an MCNP SSW surface-source file (header eagerly; tracks on demand).
1043#[pyfunction]
1044fn read_ssw(path: &str) -> PyResult<PySurfSrc> {
1045    nucleide_mcnp_io::surfsrc::SurfSrc::open(path)
1046        .map(|inner| PySurfSrc { inner })
1047        .map_err(|e| PyValueError::new_err(e.to_string()))
1048}
1049
1050/// Detected PTRAC layout: 0 = i4 little-endian, 1 = i8 little-endian.
1051#[pyclass(name = "PtracFile")]
1052struct PyPtracFile {
1053    inner: nucleide_mcnp_io::ptrac::PtracFile,
1054}
1055
1056#[pymethods]
1057impl PyPtracFile {
1058    #[getter]
1059    fn problem_title(&self) -> &str {
1060        &self.inner.problem_title
1061    }
1062    /// 0 for i4, 1 for i8.
1063    #[getter]
1064    fn width_code(&self) -> u8 {
1065        match self.inner.format {
1066            nucleide_mcnp_io::ptrac::Format::I4LittleEndian => 0,
1067            nucleide_mcnp_io::ptrac::Format::I8LittleEndian => 1,
1068        }
1069    }
1070    /// Variable counts per event type as {nps,src,bnk,sur,col,ter}.
1071    #[getter]
1072    fn variable_nums(&self) -> BTreeMap<String, usize> {
1073        let v = &self.inner.variable_nums;
1074        let mut m = BTreeMap::new();
1075        m.insert("nps".into(), v.nps);
1076        m.insert("src".into(), v.src);
1077        m.insert("bnk".into(), v.bnk);
1078        m.insert("sur".into(), v.sur);
1079        m.insert("col".into(), v.col);
1080        m.insert("ter".into(), v.ter);
1081        m
1082    }
1083    /// All events as dicts: {'event_type': int, '<var>': float, ...}.
1084    fn events(&self) -> PyResult<Vec<BTreeMap<String, f64>>> {
1085        let events = self
1086            .inner
1087            .events()
1088            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1089        Ok(events
1090            .iter()
1091            .map(|ev| {
1092                let mut d = BTreeMap::new();
1093                d.insert("event_type".to_string(), ev.event_type as f64);
1094                for (n, v) in ev.iter() {
1095                    d.insert(n.to_string(), v);
1096                }
1097                d
1098            })
1099            .collect())
1100    }
1101    /// All events as a 2-D float64 NumPy array.
1102    ///
1103    /// Shape is `(n_events, 19)` in file order, C-order float64. Columns
1104    /// follow `nucleide.mcnp.ptrac_event_columns()` (`event_type` plus the
1105    /// 18 `PtracEvent`-order data columns `node` … `tme`); variables absent
1106    /// from the file's variable list read as 0.0, matching
1107    /// `ptrac_event_rows`. The array is owned, writable, and decoupled from
1108    /// the file data. Requires NumPy installed at runtime. See `events`
1109    /// for the plain-copy dicts over the same data; use those when NumPy
1110    /// is unavailable.
1111    fn events_array<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyArray2<f64>>> {
1112        let events = self
1113            .inner
1114            .events()
1115            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1116        let n = events.len();
1117        let mut flat = Vec::with_capacity(n * PTRAC_EVENT_COLUMNS.len());
1118        for ev in &events {
1119            flat.push(ev.event_type as f64);
1120            for col in &PTRAC_EVENT_COLUMNS[1..] {
1121                flat.push(ev.get(col).unwrap_or(0.0));
1122            }
1123        }
1124        m_err(
1125            flat.into_pyarray(py)
1126                .reshape((n, PTRAC_EVENT_COLUMNS.len()))
1127                .map_err(|e| e.to_string()),
1128        )
1129    }
1130    /// One event-table column as a 1-D float64 NumPy array.
1131    ///
1132    /// `field` is one of `ptrac_event_columns()` (`event_type` or any of
1133    /// the 18 data columns). Shape is `(n_events,)` in file order; absent
1134    /// variables read as 0.0, matching `ptrac_event_rows`. Owned, writable,
1135    /// decoupled; requires NumPy at runtime. Raises `ValueError` for an
1136    /// unknown field name.
1137    fn event_field_array<'py>(
1138        &self,
1139        py: Python<'py>,
1140        field: &str,
1141    ) -> PyResult<Bound<'py, PyArray1<f64>>> {
1142        if !PTRAC_EVENT_COLUMNS.contains(&field) {
1143            return Err(PyValueError::new_err(format!(
1144                "unknown PTRAC field `{field}` (expected one of {})",
1145                PTRAC_EVENT_COLUMNS.join(", ")
1146            )));
1147        }
1148        let events = self
1149            .inner
1150            .events()
1151            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1152        let col: Vec<f64> = events
1153            .iter()
1154            .map(|ev| {
1155                if field == "event_type" {
1156                    ev.event_type as f64
1157                } else {
1158                    ev.get(field).unwrap_or(0.0)
1159                }
1160            })
1161            .collect();
1162        Ok(col.into_pyarray(py))
1163    }
1164}
1165
1166/// PTRAC event-table columns in `pyne.mcnp.PtracEvent` order: `event_type`
1167/// plus the 18 mapped data columns (`node` … `tme`).
1168///
1169/// Mirrors `nucleide.mcnp.ptrac_event_columns()`; `events_array` columns
1170/// follow this order.
1171const PTRAC_EVENT_COLUMNS: [&str; 19] = [
1172    "event_type",
1173    "node",
1174    "nsr",
1175    "nsf",
1176    "nxs",
1177    "ntyn",
1178    "ipt",
1179    "ncl",
1180    "mat",
1181    "ncp",
1182    "xxx",
1183    "yyy",
1184    "zzz",
1185    "uuu",
1186    "vvv",
1187    "www",
1188    "erg",
1189    "wgt",
1190    "tme",
1191];
1192
1193/// Read an MCNP PTRAC event file.
1194#[pyfunction]
1195fn read_ptrac(path: &str) -> PyResult<PyPtracFile> {
1196    nucleide_mcnp_io::ptrac::PtracFile::open(path)
1197        .map(|inner| PyPtracFile { inner })
1198        .map_err(|e| PyValueError::new_err(e.to_string()))
1199}
1200
1201/// One MCPL particle record (kinetic energy in MeV, position in cm, time
1202/// in ms; see `nucleide-mcpl-io`).
1203fn mcpl_particle_to_dict(py: Python<'_>, p: &nucleide_mcpl_io::Particle) -> PyResult<Py<PyAny>> {
1204    use pyo3::types::PyDict;
1205    let d = PyDict::new(py);
1206    d.set_item("ekin", p.ekin)?;
1207    d.set_item("polarisation", p.polarisation.to_vec())?;
1208    d.set_item("position", p.position.to_vec())?;
1209    d.set_item("direction", p.direction.to_vec())?;
1210    d.set_item("time", p.time)?;
1211    d.set_item("weight", p.weight)?;
1212    d.set_item("pdgcode", p.pdgcode)?;
1213    d.set_item("userflags", p.userflags)?;
1214    Ok(d.into_any().unbind())
1215}
1216
1217fn mcpl_particle_from_dict(d: &Bound<'_, PyAny>) -> PyResult<nucleide_mcpl_io::Particle> {
1218    let get_f64 = |key: &str| -> PyResult<f64> {
1219        d.get_item(key)
1220            .map_err(|e| PyValueError::new_err(format!("particle missing `{key}`: {e}")))?
1221            .extract()
1222            .map_err(|_| PyValueError::new_err(format!("particle `{key}` must be a float")))
1223    };
1224    let get_vec3 = |key: &str| -> PyResult<[f64; 3]> {
1225        let v: Vec<f64> = d
1226            .get_item(key)
1227            .map_err(|e| PyValueError::new_err(format!("particle missing `{key}`: {e}")))?
1228            .extract()
1229            .map_err(|_| PyValueError::new_err(format!("particle `{key}` must be a 3-list")))?;
1230        if v.len() != 3 {
1231            return Err(PyValueError::new_err(format!(
1232                "particle `{key}` must have exactly 3 entries"
1233            )));
1234        }
1235        Ok([v[0], v[1], v[2]])
1236    };
1237    let pdgcode: i32 = d
1238        .get_item("pdgcode")
1239        .map_err(|e| PyValueError::new_err(format!("particle missing `pdgcode`: {e}")))?
1240        .extract()
1241        .map_err(|_| PyValueError::new_err("particle `pdgcode` must be an int"))?;
1242    let userflags: u32 = d
1243        .get_item("userflags")
1244        .map_err(|e| PyValueError::new_err(format!("particle missing `userflags`: {e}")))?
1245        .extract()
1246        .map_err(|_| PyValueError::new_err("particle `userflags` must be an int"))?;
1247    Ok(nucleide_mcpl_io::Particle {
1248        ekin: get_f64("ekin")?,
1249        polarisation: get_vec3("polarisation")?,
1250        position: get_vec3("position")?,
1251        direction: get_vec3("direction")?,
1252        time: get_f64("time")?,
1253        weight: get_f64("weight")?,
1254        pdgcode,
1255        userflags,
1256    })
1257}
1258
1259/// Parsed MCPL particle-list file (header eagerly; particles on demand).
1260#[pyclass(name = "McplFile")]
1261struct PyMcplFile {
1262    inner: nucleide_mcpl_io::McplFile,
1263}
1264
1265#[pymethods]
1266impl PyMcplFile {
1267    /// Format version (2 or 3 on read; writers always emit 3).
1268    #[getter]
1269    fn version(&self) -> u16 {
1270        self.inner.header.version
1271    }
1272    /// Stored particle count.
1273    #[getter]
1274    fn nparticles(&self) -> u64 {
1275        self.inner.header.nparticles
1276    }
1277    /// Source name from the header.
1278    #[getter]
1279    fn srcname(&self) -> &str {
1280        &self.inner.header.srcname
1281    }
1282    /// Header comment strings (round-tripped verbatim, never interpreted).
1283    #[getter]
1284    fn comments(&self) -> Vec<String> {
1285        self.inner.header.comments.clone()
1286    }
1287    /// Whether per-particle user flags are stored.
1288    #[getter]
1289    fn has_userflags(&self) -> bool {
1290        self.inner.header.has_userflags
1291    }
1292    /// Whether per-particle polarisation vectors are stored.
1293    #[getter]
1294    fn has_polarisation(&self) -> bool {
1295        self.inner.header.has_polarisation
1296    }
1297    /// `true` = double precision, `false` = single precision.
1298    #[getter]
1299    fn double_prec(&self) -> bool {
1300        self.inner.header.double_prec
1301    }
1302    /// File-wide PDG code when set (`None` = per-particle codes).
1303    #[getter]
1304    fn universal_pdgcode(&self) -> Option<i32> {
1305        self.inner.header.universal_pdgcode
1306    }
1307    /// File-wide weight when set (`None` = per-particle weights).
1308    #[getter]
1309    fn universal_weight(&self) -> Option<f64> {
1310        self.inner.header.universal_weight
1311    }
1312    /// Header blobs as `(key, bytes)` pairs.
1313    #[getter]
1314    fn blobs(&self) -> Vec<(String, Vec<u8>)> {
1315        self.inner
1316            .header
1317            .blobs
1318            .iter()
1319            .map(|b| (b.key.clone(), b.data.clone()))
1320            .collect()
1321    }
1322    /// All particle records as dicts (`ekin`, `polarisation`, `position`,
1323    /// `direction`, `time`, `weight`, `pdgcode`, `userflags`).
1324    fn particles(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
1325        let ps = self
1326            .inner
1327            .particles()
1328            .map_err(|e| PyValueError::new_err(e.to_string()))?;
1329        ps.iter().map(|p| mcpl_particle_to_dict(py, p)).collect()
1330    }
1331}
1332
1333/// Read an MCPL particle-list file (`.gz` reads through gzip transparently).
1334#[pyfunction]
1335fn read_mcpl(path: &str) -> PyResult<PyMcplFile> {
1336    nucleide_mcpl_io::McplFile::open(path)
1337        .map(|inner| PyMcplFile { inner })
1338        .map_err(|e| PyValueError::new_err(e.to_string()))
1339}
1340
1341/// Write an MCPL particle-list file from a header dict and particle dicts.
1342///
1343/// `header` keys: `srcname` (str), `comments` (list of str),
1344/// `has_userflags`/`has_polarisation`/`double_prec` (bool),
1345/// `universal_pdgcode` (int or None), `universal_weight` (float or None),
1346/// `blobs` (list of `(key, bytes)` pairs). `particles` holds one dict per
1347/// record with the same keys as `McplFile.particles()`. A `.gz` suffix
1348/// compresses through gzip transparently. Thin wrapper over
1349/// `nucleide-mcpl-io`.
1350#[pyfunction]
1351fn write_mcpl(
1352    path: &str,
1353    header: &Bound<'_, PyAny>,
1354    particles: Vec<Bound<'_, PyAny>>,
1355) -> PyResult<()> {
1356    use nucleide_mcpl_io::{Blob, Header};
1357    let get = |key: &str| header.get_item(key);
1358    let srcname: String = get("srcname")
1359        .map_err(|_| PyValueError::new_err("header missing `srcname`"))?
1360        .extract()
1361        .map_err(|_| PyValueError::new_err("header `srcname` must be a str"))?;
1362    let comments: Vec<String> = get("comments")
1363        .map_err(|_| PyValueError::new_err("header missing `comments`"))?
1364        .extract()
1365        .map_err(|_| PyValueError::new_err("header `comments` must be a list of str"))?;
1366    let flag = |key: &str| -> PyResult<bool> {
1367        get(key)
1368            .map_err(|_| PyValueError::new_err(format!("header missing `{key}`")))?
1369            .extract()
1370            .map_err(|_| PyValueError::new_err(format!("header `{key}` must be a bool")))
1371    };
1372    let universal_pdgcode: Option<i32> = get("universal_pdgcode")
1373        .map_err(|_| PyValueError::new_err("header missing `universal_pdgcode`"))?
1374        .extract()
1375        .map_err(|_| PyValueError::new_err("header `universal_pdgcode` must be an int or None"))?;
1376    let universal_weight: Option<f64> = get("universal_weight")
1377        .map_err(|_| PyValueError::new_err("header missing `universal_weight`"))?
1378        .extract()
1379        .map_err(|_| PyValueError::new_err("header `universal_weight` must be a float or None"))?;
1380    let blob_pairs: Vec<(String, Vec<u8>)> = get("blobs")
1381        .map_err(|_| PyValueError::new_err("header missing `blobs`"))?
1382        .extract()
1383        .map_err(|_| {
1384            PyValueError::new_err("header `blobs` must be a list of (key, bytes) pairs")
1385        })?;
1386    let h = Header {
1387        has_userflags: flag("has_userflags")?,
1388        has_polarisation: flag("has_polarisation")?,
1389        double_prec: flag("double_prec")?,
1390        universal_pdgcode,
1391        universal_weight,
1392        srcname,
1393        comments,
1394        blobs: blob_pairs
1395            .into_iter()
1396            .map(|(key, data)| Blob { key, data })
1397            .collect(),
1398        ..Header::default()
1399    };
1400    let ps: Vec<nucleide_mcpl_io::Particle> = particles
1401        .iter()
1402        .map(mcpl_particle_from_dict)
1403        .collect::<PyResult<_>>()?;
1404    nucleide_mcpl_io::write_to_path(path, &h, &ps).map_err(|e| PyValueError::new_err(e.to_string()))
1405}
1406
1407/// Convert an SSW surface-source file to an MCPL particle-list file
1408/// (neutron/gamma-only v1; see `nucleide-mcpl-io` `ssw`).
1409///
1410/// The SSW format stores no per-track surface id or particle kind, so every
1411/// track needs an explicit caller parameter: `surfs[i]`/`kinds[i]` pair with
1412/// track `i` (`kinds` holds `"neutron"`/`"gamma"` only). `options` (dict or
1413/// None) holds `double_prec`/`surf_to_userflags`/`gzip` (bool),
1414/// `srcname` (str), `comments` (list of str), and `deck_blob`
1415/// (`(key, bytes)` pair or None); absent keys take the crate defaults.
1416/// Output is gzip-compressed when `options["gzip"]` is set or `mcpl_path`
1417/// ends in `.gz`. Returns the particle count. Thin wrapper over
1418/// `nucleide-mcpl-io`.
1419#[pyfunction]
1420#[pyo3(signature = (ssw_path, mcpl_path, surfs, kinds, options=None))]
1421fn ssw2mcpl(
1422    ssw_path: &str,
1423    mcpl_path: &str,
1424    surfs: Vec<u32>,
1425    kinds: Vec<String>,
1426    options: Option<Bound<'_, PyAny>>,
1427) -> PyResult<u64> {
1428    use nucleide_mcnp_io::surfsrc::SurfSrc;
1429    use nucleide_mcpl_io::ssw::{SswParticleKind, SswTrack};
1430    let ssw = SurfSrc::open(ssw_path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1431    let raw = ssw
1432        .read_tracklist()
1433        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1434    if raw.len() != surfs.len() || raw.len() != kinds.len() {
1435        return Err(PyValueError::new_err(format!(
1436            "ssw2mcpl: SSW holds {} tracks but got {} surfs and {} kinds \
1437             (one surf+kind per track required)",
1438            raw.len(),
1439            surfs.len(),
1440            kinds.len()
1441        )));
1442    }
1443    let mut tracks = Vec::with_capacity(raw.len());
1444    for (i, (t, surf, kind)) in raw
1445        .iter()
1446        .zip(surfs)
1447        .zip(kinds.iter())
1448        .map(|((t, s), k)| (t, s, k))
1449        .enumerate()
1450    {
1451        let kind = SswParticleKind::parse(kind).ok_or_else(|| {
1452            PyValueError::new_err(format!(
1453                "track {i} kind `{kind}` unknown (expected \"neutron\" or \"gamma\")"
1454            ))
1455        })?;
1456        tracks.push(SswTrack {
1457            ekin: t.erg,
1458            time_shakes: t.tme,
1459            position: [t.x, t.y, t.z],
1460            direction: [t.u, t.v, t.cs],
1461            weight: t.wgt,
1462            surf,
1463            kind,
1464        });
1465    }
1466    let mut opts = parse_ssw2mcpl_options(options.as_ref())?;
1467    if mcpl_path.ends_with(".gz") {
1468        opts.gzip = true;
1469    }
1470    let bytes = nucleide_mcpl_io::ssw::ssw2mcpl_bytes(&tracks, &opts)
1471        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1472    std::fs::write(mcpl_path, bytes).map_err(|e| PyValueError::new_err(e.to_string()))?;
1473    Ok(tracks.len() as u64)
1474}
1475
1476/// Parse the `ssw2mcpl` options dict (None = crate defaults).
1477fn parse_ssw2mcpl_options(
1478    options: Option<&Bound<'_, PyAny>>,
1479) -> PyResult<nucleide_mcpl_io::ssw::Ssw2McplOptions> {
1480    use nucleide_mcpl_io::ssw::{DeckBlob, Ssw2McplOptions};
1481    let mut opts = Ssw2McplOptions::default();
1482    let Some(d) = options else {
1483        return Ok(opts);
1484    };
1485    if !d.is_instance_of::<pyo3::types::PyDict>() {
1486        return Err(PyValueError::new_err("options must be a dict or None"));
1487    }
1488    let flag = |key: &str| -> PyResult<Option<bool>> {
1489        match d.get_item(key) {
1490            Ok(v) => v
1491                .extract()
1492                .map(Some)
1493                .map_err(|_| PyValueError::new_err(format!("options `{key}` must be a bool"))),
1494            Err(_) => Ok(None),
1495        }
1496    };
1497    if let Some(v) = flag("double_prec")? {
1498        opts.double_prec = v;
1499    }
1500    if let Some(v) = flag("surf_to_userflags")? {
1501        opts.surf_to_userflags = v;
1502    }
1503    if let Some(v) = flag("gzip")? {
1504        opts.gzip = v;
1505    }
1506    if let Ok(v) = d.get_item("srcname") {
1507        opts.srcname = v
1508            .extract()
1509            .map_err(|_| PyValueError::new_err("options `srcname` must be a str"))?;
1510    }
1511    if let Ok(v) = d.get_item("comments") {
1512        opts.comments = v
1513            .extract()
1514            .map_err(|_| PyValueError::new_err("options `comments` must be a list of str"))?;
1515    }
1516    if let Ok(v) = d.get_item("deck_blob") {
1517        if !v.is_none() {
1518            let (key, data): (String, Vec<u8>) = v.extract().map_err(|_| {
1519                PyValueError::new_err("options `deck_blob` must be a (key, bytes) pair or None")
1520            })?;
1521            opts.deck_blob = Some(DeckBlob { key, data });
1522        }
1523    }
1524    Ok(opts)
1525}
1526
1527/// Convert an MCPL particle-list file back to an SSW surface-source file
1528/// (neutron/gamma-only v1; see `nucleide-mcpl-io` `ssw`).
1529///
1530/// The output header clones `reference_ssw_path` (code/version/deck
1531/// passthrough) with `nrss` patched to the particle count. Surface ids come
1532/// from each particle's `userflags`; pass `surface` to stamp one id on every
1533/// track instead (either way `[1, 999999]` is enforced). PDG codes outside
1534/// 2112/22 are errors. Returns the track count. Thin wrapper over
1535/// `nucleide-mcpl-io`.
1536#[pyfunction]
1537#[pyo3(signature = (mcpl_path, reference_ssw_path, ssw_out_path, surface=None))]
1538fn mcpl2ssw(
1539    mcpl_path: &str,
1540    reference_ssw_path: &str,
1541    ssw_out_path: &str,
1542    surface: Option<u32>,
1543) -> PyResult<u64> {
1544    use nucleide_mcnp_io::surfsrc::SurfSrc;
1545    use nucleide_mcpl_io::ssw::Mcpl2SswOptions;
1546    let mcpl = nucleide_mcpl_io::McplFile::open(mcpl_path)
1547        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1548    let particles = mcpl
1549        .particles()
1550        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1551    let reference =
1552        SurfSrc::open(reference_ssw_path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1553    let (header, tracks) = nucleide_mcpl_io::ssw::mcpl2ssw(
1554        &particles,
1555        &reference.header,
1556        &Mcpl2SswOptions { surface },
1557    )
1558    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1559    nucleide_mcnp_io::surfsrc::write_to_path(ssw_out_path, &header, &tracks)
1560        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1561    Ok(tracks.len() as u64)
1562}
1563
1564/// Parsed ENDL evaluation file (EEDL/EPDL scope).
1565#[pyclass(name = "EndlLibrary")]
1566struct PyEndlLibrary {
1567    inner: nucleide_mcnp_io::endl::Library,
1568}
1569
1570#[pymethods]
1571impl PyEndlLibrary {
1572    /// Distinct nucleus ids in file order.
1573    fn nuclides(&self) -> Vec<i64> {
1574        self.inner.nuclides()
1575    }
1576    /// Reaction data for one selector set.
1577    ///
1578    /// `nuc` is an integer nucleus id (e.g. `820000000` for natural Pb) or a
1579    /// fully-specified isotope name (`"Pb208"`); bare element names do not
1580    /// resolve. `x1`/`p_out` filter by subshell/outgoing particle when given.
1581    /// Returns rows of `fields_for_rprop(rprop)` floats.
1582    #[pyo3(signature = (nuc, p_in, rdesc, rprop, x1=None, p_out=None))]
1583    fn get_rx(
1584        &self,
1585        nuc: &Bound<'_, PyAny>,
1586        p_in: i32,
1587        rdesc: i32,
1588        rprop: i32,
1589        x1: Option<i32>,
1590        p_out: Option<i32>,
1591    ) -> PyResult<Vec<Vec<f64>>> {
1592        let id = if let Ok(n) = nuc.extract::<i64>() {
1593            n
1594        } else if let Ok(name) = nuc.extract::<&str>() {
1595            NuclideId::from_name(name).map_err(wrap_nucid_err)?.nucid() as i64
1596        } else {
1597            return Err(PyTypeError::new_err("expected int nucleus id or str name"));
1598        };
1599        self.inner
1600            .get_rx(id, p_in, rdesc, rprop, x1, p_out)
1601            .map(|rows| rows.to_vec())
1602            .map_err(|e| PyValueError::new_err(e.to_string()))
1603    }
1604}
1605
1606/// Read an ENDL evaluation file (EEDL/EPDL scope).
1607#[pyfunction]
1608fn read_endl(path: &str) -> PyResult<PyEndlLibrary> {
1609    nucleide_mcnp_io::endl::Library::open(path)
1610        .map(|inner| PyEndlLibrary { inner })
1611        .map_err(|e| PyValueError::new_err(e.to_string()))
1612}
1613
1614/// Convert one 11-character ENDL number field to float.
1615#[pyfunction]
1616fn endl_endftod(field: &str) -> f64 {
1617    nucleide_mcnp_io::endl::endftod(field)
1618}
1619
1620/// Combine several SSW surface-source files into one (`ssw_combine.py` port).
1621///
1622/// Headers must agree on kod/ver/loddat, particle type, surface counts and
1623/// per-surface records; the output header carries the signed `orignp1` sum
1624/// and the plain `nrss` sum, with later files' track `nps` shifted
1625/// sign-preservingly. Raises `ValueError` on incompatible inputs (upstream
1626/// returns `False`).
1627#[pyfunction]
1628fn combine_ssw_files(output: &str, inputs: Vec<String>) -> PyResult<()> {
1629    nucleide_mcnp_io::surfsrc::combine_files(&inputs, output)
1630        .map_err(|e| PyValueError::new_err(e.to_string()))
1631}
1632
1633// ---------------------------------------------------------------------------
1634// Depletion / CRAM
1635// ---------------------------------------------------------------------------
1636
1637/// A parsed depletion chain (XML format).
1638#[pyclass(name = "Chain")]
1639struct PyChain {
1640    inner: std::sync::Arc<nucleide_depletion::Chain>,
1641}
1642
1643#[pymethods]
1644impl PyChain {
1645    /// Nuclide names in chain order.
1646    #[getter]
1647    fn nuclides(&self) -> Vec<String> {
1648        self.inner.nuclides.iter().map(|n| n.name.clone()).collect()
1649    }
1650
1651    fn index_of(&self, name: &str) -> Option<usize> {
1652        self.inner.index_of(name)
1653    }
1654}
1655
1656/// Parse a depletion-chain XML file.
1657#[pyfunction]
1658fn read_chain(path: &str) -> PyResult<PyChain> {
1659    nucleide_depletion::Chain::from_file(path)
1660        .map(|inner| PyChain {
1661            inner: std::sync::Arc::new(inner),
1662        })
1663        .map_err(|e| PyValueError::new_err(e.to_string()))
1664}
1665
1666/// One-group reaction rates keyed by "NuclideName:reaction".
1667type RateMap = BTreeMap<String, f64>;
1668
1669/// Pre-built depletion system for repeated CRAM solves.
1670#[pyclass(name = "DepletionSystem")]
1671struct PyDepletionSystem {
1672    inner: std::sync::Arc<nucleide_depletion::DepletionSystem>,
1673}
1674
1675#[pymethods]
1676impl PyDepletionSystem {
1677    /// Solve one depletion step with the pre-built system.
1678    ///
1679    /// `order` selects the CRAM order (16 or 48); `method` selects the
1680    /// solver kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
1681    /// default `"cram48"`). An explicitly non-default `method` overrides
1682    /// `order`; the default `method` defers to `order` for backwards
1683    /// compatibility. `Bateman` arms fall back to CRAM-48 on non-decay
1684    /// systems.
1685    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
1686    fn solve(
1687        &self,
1688        n0: BTreeMap<String, f64>,
1689        dt: f64,
1690        order: u8,
1691        method: &str,
1692    ) -> PyResult<BTreeMap<String, f64>> {
1693        let method = resolve_method(order, method)?;
1694        nucleide_depletion::deplete_with_method(&self.inner, method, &n0, dt)
1695            .map(|r| r.atoms)
1696            .map_err(|e| PyValueError::new_err(e.to_string()))
1697    }
1698
1699    /// Solve one depletion step using pre-built index vectors.
1700    ///
1701    /// `n0` and the returned vector are in chain index order; this avoids the
1702    /// name-to-index mapping overhead of `solve()` for tight timing loops.
1703    /// `method` behaves as in [`PyDepletionSystem::solve`].
1704    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
1705    fn solve_vec(&self, n0: Vec<f64>, dt: f64, order: u8, method: &str) -> PyResult<Vec<f64>> {
1706        let method = resolve_method(order, method)?;
1707        nucleide_depletion::solve_with_method(&self.inner, method, &n0, dt)
1708            .map_err(|e| PyValueError::new_err(e.to_string()))
1709    }
1710}
1711
1712/// Build a reusable depletion system from a chain and reaction rates.
1713#[pyfunction]
1714fn build_depletion_system(chain: &PyChain, rates: RateMap) -> PyResult<PyDepletionSystem> {
1715    let rs = split_rates(&rates, &chain.inner)?;
1716    nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rs)
1717        .map(|sys| PyDepletionSystem {
1718            inner: std::sync::Arc::new(sys),
1719        })
1720        .map_err(|e| PyValueError::new_err(e.to_string()))
1721}
1722
1723fn parse_order(order: u8) -> PyResult<nucleide_depletion::Order> {
1724    match order {
1725        16 => Ok(nucleide_depletion::Order::Order16),
1726        48 => Ok(nucleide_depletion::Order::Order48),
1727        other => Err(PyValueError::new_err(format!(
1728            "unsupported CRAM order {other} (supported: 16, 48)"
1729        ))),
1730    }
1731}
1732
1733/// Parse a solver `method=` spelling (`"cram16"`, `"cram48"`, `"bateman"`,
1734/// `"bateman_hp"`; case-insensitive, `-`/`_` interchangeable).
1735fn parse_method(name: &str) -> PyResult<nucleide_depletion::Method> {
1736    name.parse().map_err(|e: String| PyValueError::new_err(e))
1737}
1738
1739/// Resolve the legacy `order` (16|48) plus `method=` into a core [`Method`].
1740///
1741/// An explicitly non-default `method` wins; the default `"cram48"` defers to
1742/// `order` so existing `order=16` calls keep working unchanged.
1743fn resolve_method(order: u8, method: &str) -> PyResult<nucleide_depletion::Method> {
1744    let parsed = parse_method(method)?;
1745    if parsed == nucleide_depletion::Method::default_cram() {
1746        parse_order(order).map(nucleide_depletion::Method::Cram)
1747    } else {
1748        Ok(parsed)
1749    }
1750}
1751
1752fn split_rates(
1753    rates: &RateMap,
1754    chain: &nucleide_depletion::Chain,
1755) -> PyResult<nucleide_depletion::ReactionRates> {
1756    let mut out = nucleide_depletion::ReactionRates::new();
1757    for (key, v) in rates {
1758        let (nuc, rx) = key.split_once(':').ok_or_else(|| {
1759            PyValueError::new_err(format!("rate key `{key}` must be `Name:reaction`"))
1760        })?;
1761        let idx = chain
1762            .index_of(nuc)
1763            .ok_or_else(|| PyValueError::new_err(format!("rate for unknown nuclide `{nuc}`")))?;
1764        out.entry(idx).or_default().insert(rx.to_string(), *v);
1765    }
1766    Ok(out)
1767}
1768
1769/// Solve one depletion step with IPF CRAM or the analytic Bateman fast path.
1770///
1771/// `n0` maps nuclide names to initial atom counts; `rates` maps
1772/// `"Name:(n,gamma)"`-style keys to one-group rates [1/s]; `dt` is the step
1773/// length in seconds; `order` is 16 or 48; `method` selects the solver
1774/// kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`, default
1775/// `"cram48"` — an explicitly non-default `method` overrides `order`).
1776/// `Bateman` arms fall back to CRAM-48 on non-decay systems (rates on,
1777/// cyclic topology, near-degenerate half-lives).
1778#[pyfunction]
1779#[pyo3(signature = (chain, n0, dt, rates=None, order=48, method="cram48"))]
1780fn deplete(
1781    chain: &PyChain,
1782    n0: BTreeMap<String, f64>,
1783    dt: f64,
1784    rates: Option<RateMap>,
1785    order: u8,
1786    method: &str,
1787) -> PyResult<BTreeMap<String, f64>> {
1788    let method = resolve_method(order, method)?;
1789    let rates = split_rates(rates.as_ref().unwrap_or(&BTreeMap::new()), &chain.inner)?;
1790    let sys = nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rates)
1791        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1792    nucleide_depletion::deplete_with_method(&sys, method, &n0, dt)
1793        .map(|r| r.atoms)
1794        .map_err(|e| PyValueError::new_err(e.to_string()))
1795}
1796
1797// ---------------------------------------------------------------------------
1798// Serpent / FLUKA / variance reduction + writers
1799// ---------------------------------------------------------------------------
1800
1801/// Parse a Serpent .m output file ("res", "dep", or "det") into a plain
1802/// Python dict keyed by variable name. Scalars become floats/strings, vectors
1803/// become 1-D lists, and matrices become 2-D lists of row lists (one row per
1804/// Serpent block). A matrix holding non-numeric values raises `ValueError`.
1805#[pyfunction]
1806fn read_serpent(path: &str, kind: &str) -> PyResult<Py<PyAny>> {
1807    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1808    let table = match kind {
1809        "res" => nucleide_serpent_io::parse_res(&text),
1810        "dep" => nucleide_serpent_io::parse_dep(&text),
1811        "det" => nucleide_serpent_io::parse_det(&text),
1812        other => {
1813            return Err(PyValueError::new_err(format!(
1814                "kind must be res|dep|det, got `{other}`"
1815            )))
1816        }
1817    }
1818    .map_err(|e| PyValueError::new_err(e.to_string()))?;
1819    fn entry_to_py(py: Python<'_>, e: &nucleide_serpent_io::Entry) -> PyResult<Py<PyAny>> {
1820        use nucleide_serpent_io::Entry as E;
1821        let value = match e {
1822            E::Scalar(nucleide_serpent_io::Value::Num(n)) => {
1823                n.into_pyobject(py).unwrap().unbind().into_any()
1824            }
1825            E::Scalar(nucleide_serpent_io::Value::Str(s)) => {
1826                s.into_pyobject(py).unwrap().unbind().into_any()
1827            }
1828            E::Vector(vs) => vs
1829                .iter()
1830                .map(|v| match v {
1831                    nucleide_serpent_io::Value::Num(n) => {
1832                        n.into_pyobject(py).unwrap().unbind().into_any()
1833                    }
1834                    nucleide_serpent_io::Value::Str(s) => {
1835                        s.into_pyobject(py).unwrap().unbind().into_any()
1836                    }
1837                })
1838                .collect::<Vec<_>>()
1839                .into_pyobject(py)
1840                .unwrap()
1841                .unbind()
1842                .into_any(),
1843            E::Matrix(m) => m
1844                .to_rows_f64()
1845                .map_err(|err| PyValueError::new_err(err.to_string()))?
1846                .into_pyobject(py)
1847                .unwrap()
1848                .unbind()
1849                .into_any(),
1850        };
1851        Ok(value)
1852    }
1853    Python::attach(|py| {
1854        let dict = pyo3::types::PyDict::new(py);
1855        for (k, e) in table.iter() {
1856            dict.set_item(k, entry_to_py(py, e)?)?;
1857        }
1858        Ok(dict.into_any().unbind())
1859    })
1860}
1861
1862/// One FLUKA USRBIN detector.
1863#[pyclass(name = "UsrbinTally")]
1864struct PyUsrbinTally {
1865    inner: nucleide_fluka_io::usrbin::UsrbinTally,
1866}
1867
1868#[pymethods]
1869impl PyUsrbinTally {
1870    #[getter]
1871    fn name(&self) -> &str {
1872        &self.inner.name
1873    }
1874    #[getter]
1875    fn particle(&self) -> &str {
1876        &self.inner.particle
1877    }
1878    #[getter]
1879    fn nx(&self) -> usize {
1880        self.inner.x_info.bins
1881    }
1882    #[getter]
1883    fn ny(&self) -> usize {
1884        self.inner.y_info.bins
1885    }
1886    #[getter]
1887    fn nz(&self) -> usize {
1888        self.inner.z_info.bins
1889    }
1890    #[getter]
1891    fn x_bounds(&self) -> Vec<f64> {
1892        self.inner.x_bounds.clone()
1893    }
1894    #[getter]
1895    fn y_bounds(&self) -> Vec<f64> {
1896        self.inner.y_bounds.clone()
1897    }
1898    #[getter]
1899    fn z_bounds(&self) -> Vec<f64> {
1900        self.inner.z_bounds.clone()
1901    }
1902    /// Scored values, x slowest -> z fastest.
1903    #[getter]
1904    fn data(&self) -> Vec<f64> {
1905        self.inner.part_data.clone()
1906    }
1907    /// Statistical errors, same layout as `data`.
1908    #[getter]
1909    fn error(&self) -> Vec<f64> {
1910        self.inner.error_data.clone()
1911    }
1912    fn dims(&self) -> [usize; 3] {
1913        [self.nx(), self.ny(), self.nz()]
1914    }
1915}
1916
1917/// Parse all USRBIN tallies from a FLUKA .lis file.
1918#[pyfunction]
1919fn read_usrbin(path: &str) -> PyResult<Vec<PyUsrbinTally>> {
1920    let tallies = nucleide_fluka_io::usrbin::read_usrbin_file(path)
1921        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1922    Ok(tallies
1923        .into_iter()
1924        .map(|inner| PyUsrbinTally { inner })
1925        .collect())
1926}
1927
1928/// MAGIC weight-window output.
1929#[pyclass(name = "MagicOutput")]
1930struct PyMagicOutput {
1931    inner: nucleide_vr_tools::magic::MagicOutput,
1932}
1933
1934#[pymethods]
1935impl PyMagicOutput {
1936    /// Flat lower bounds ([ve] in total mode, [ve*g+g] per-group).
1937    #[getter]
1938    fn lower_bounds_ww(&self) -> Vec<f64> {
1939        self.inner.lower_bounds_ww.clone()
1940    }
1941    #[getter]
1942    fn groups_per_ve(&self) -> usize {
1943        self.inner.groups_per_ve
1944    }
1945    #[getter]
1946    fn scale_factors(&self) -> Vec<f64> {
1947        self.inner.scale_factors.clone()
1948    }
1949    #[getter]
1950    fn e_upper_bounds(&self) -> Vec<f64> {
1951        self.inner.e_upper_bounds.clone()
1952    }
1953    #[getter]
1954    fn ww_tag_name(&self) -> &str {
1955        &self.inner.ww_tag_name
1956    }
1957}
1958
1959/// Generate MAGIC weight-window lower bounds from a meshtal tally.
1960#[pyfunction]
1961#[pyo3(signature = (tally, per_group=false, tolerance=0.5))]
1962fn magic(tally: &PyMeshTally, per_group: bool, tolerance: f64) -> PyResult<PyMagicOutput> {
1963    let selection = if per_group {
1964        nucleide_vr_tools::magic::MagicSelection::PerGroup
1965    } else {
1966        nucleide_vr_tools::magic::MagicSelection::Total
1967    };
1968    let params = nucleide_vr_tools::magic::MagicParams {
1969        tolerance,
1970        ..Default::default()
1971    };
1972    nucleide_vr_tools::magic::magic_with(&tally.inner, selection, params)
1973        .map(|inner| PyMagicOutput { inner })
1974        .map_err(|e| PyValueError::new_err(e.to_string()))
1975}
1976
1977/// Walker alias table for discrete sampling.
1978#[pyclass(name = "AliasTable")]
1979struct PyAliasTable {
1980    inner: nucleide_vr_tools::sampling::AliasTable,
1981}
1982
1983#[pymethods]
1984impl PyAliasTable {
1985    /// Build from a probability density (normalized internally).
1986    #[new]
1987    fn new(pdf: Vec<f64>) -> PyResult<Self> {
1988        nucleide_vr_tools::sampling::AliasTable::new(&pdf)
1989            .map(|inner| PyAliasTable { inner })
1990            .map_err(|e| PyValueError::new_err(e.to_string()))
1991    }
1992    /// Sample an index from two uniform random numbers.
1993    fn sample(&self, r1: f64, r2: f64) -> usize {
1994        self.inner.sample(r1, r2)
1995    }
1996    #[getter]
1997    fn pdf(&self) -> Vec<f64> {
1998        self.inner.pdf().to_vec()
1999    }
2000    fn __len__(&self) -> usize {
2001        self.inner.len()
2002    }
2003}
2004
2005/// Mesh source sampler over a meshtal tally (ANALOG/UNIFORM/USER modes).
2006#[pyclass(name = "MeshSourceSampler")]
2007struct PyMeshSourceSampler {
2008    inner: nucleide_vr_tools::sampling::MeshSourceSampler,
2009}
2010
2011#[pymethods]
2012impl PyMeshSourceSampler {
2013    /// mode: "analog" | "uniform" | "user" (user requires user_pdf).
2014    #[new]
2015    #[pyo3(signature = (tally, mode, user_pdf=None))]
2016    fn new(tally: &PyMeshTally, mode: &str, user_pdf: Option<Vec<f64>>) -> PyResult<Self> {
2017        let user = if matches!(mode, "user") {
2018            Some(user_pdf.ok_or_else(|| PyValueError::new_err("user mode needs user_pdf"))?)
2019        } else {
2020            None
2021        };
2022        let m = match mode {
2023            "analog" => nucleide_vr_tools::sampling::Mode::Analog,
2024            "uniform" => nucleide_vr_tools::sampling::Mode::Uniform,
2025            "user" => nucleide_vr_tools::sampling::Mode::User,
2026            other => {
2027                return Err(PyValueError::new_err(format!(
2028                    "mode must be analog|uniform|user, got `{other}`"
2029                )))
2030            }
2031        };
2032        nucleide_vr_tools::sampling::MeshSourceSampler::new(&tally.inner, m, user.as_deref())
2033            .map(|inner| PyMeshSourceSampler { inner })
2034            .map_err(|e| PyValueError::new_err(e.to_string()))
2035    }
2036    /// Sample a voxel; returns dict(index, i, j, k, weight).
2037    fn sample(&self, r1: f64, r2: f64) -> BTreeMap<String, f64> {
2038        let s = self.inner.sample(r1, r2);
2039        let mut d = BTreeMap::new();
2040        d.insert("index".into(), s.index as f64);
2041        d.insert("i".into(), s.i as f64);
2042        d.insert("j".into(), s.j as f64);
2043        d.insert("k".into(), s.k as f64);
2044        d.insert("weight".into(), s.weight);
2045        d
2046    }
2047}
2048
2049/// Write a SurfSrc file back to disk. `tracks` defaults to re-reading the
2050/// original file's tracks.
2051#[pyfunction]
2052#[pyo3(signature = (ssw, path, tracks=None))]
2053fn write_ssw(
2054    ssw: &PySurfSrc,
2055    path: &str,
2056    tracks: Option<Vec<BTreeMap<String, f64>>>,
2057) -> PyResult<()> {
2058    let header = ssw.inner.header.clone();
2059    let track_data: Vec<nucleide_mcnp_io::surfsrc::TrackData> = match tracks {
2060        Some(dict_tracks) => dict_tracks
2061            .iter()
2062            .map(|d| {
2063                let g = |k: &str| d.get(k).copied().unwrap_or(0.0);
2064                let mut record = vec![0.0f64; nucleide_mcnp_io::surfsrc::TrackData::RECORD_WIDTH];
2065                record[0] = g("nps");
2066                record[1] = g("bitarray");
2067                record[2] = g("wgt");
2068                record[3] = g("erg");
2069                record[4] = g("tme");
2070                record[5] = g("x");
2071                record[6] = g("y");
2072                record[7] = g("z");
2073                record[8] = g("u");
2074                record[9] = g("v");
2075                record[10] = g("cs");
2076                nucleide_mcnp_io::surfsrc::TrackData::from_record(record)
2077            })
2078            .collect(),
2079        None => ssw
2080            .inner
2081            .read_tracklist()
2082            .map_err(|e| PyValueError::new_err(e.to_string()))?,
2083    };
2084    let mut f = std::fs::File::create(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
2085    nucleide_mcnp_io::surfsrc::write_to(&mut f, &header, &track_data)
2086        .map_err(|e| PyValueError::new_err(e.to_string()))
2087}
2088
2089/// Generate MCNP input-deck text from a structured mesh.
2090#[pyfunction]
2091fn mesh_to_geom(
2092    x_bounds: Vec<f64>,
2093    y_bounds: Vec<f64>,
2094    z_bounds: Vec<f64>,
2095    cell_materials: Vec<Option<(String, f64)>>,
2096    title_card: &str,
2097) -> String {
2098    let opts = nucleide_mcnp_io::deck::DeckOptions {
2099        title_card: title_card.to_string(),
2100        frac_type: nucleide_mcnp_io::deck::FracType::Mass,
2101    };
2102    nucleide_mcnp_io::deck::mesh_to_geom(&x_bounds, &y_bounds, &z_bounds, &cell_materials, &opts)
2103}
2104
2105// ---------------------------------------------------------------------------
2106// ALARA I/O (thin glue over `alara-io`; solver stays out of scope)
2107// ---------------------------------------------------------------------------
2108
2109/// Parse an ALARA input deck into plain Python containers.
2110///
2111/// Returns a dict with `block_kinds` (list[str] in file order), `geometry`
2112/// (str | None), `mixtures` (list of {name, entries}), `fluxes` (list of
2113/// {name, file, scale, skip, format}), `cooling_times_s` (list[float]),
2114/// `schedules`, `pulse_histories`, `outputs`, and `truncation`.
2115#[pyfunction]
2116fn alara_parse_deck(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
2117    let owned = text.to_owned();
2118    let deck = py
2119        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
2120        .map_err(ala_err)?;
2121    Ok(deck_to_py(py, &deck))
2122}
2123
2124fn ala_err(e: nucleide_alara_io::Error) -> PyErr {
2125    PyValueError::new_err(e.to_string())
2126}
2127
2128fn deck_to_py(py: Python<'_>, deck: &nucleide_alara_io::AlaraDeck) -> Py<PyAny> {
2129    use pyo3::types::PyDict;
2130    let out = PyDict::new(py);
2131    let block_kinds: Vec<&str> = deck.block_kinds();
2132    out.set_item("block_kinds", block_kinds).ok();
2133    out.set_item("geometry", deck.geometry.as_ref().map(|g| g.kind.clone()))
2134        .ok();
2135    let mixtures: Vec<Py<PyAny>> = deck.mixtures.iter().map(|m| mixture_to_py(py, m)).collect();
2136    out.set_item("mixtures", mixtures).ok();
2137    let fluxes: Vec<Py<PyAny>> = deck.fluxes.iter().map(|f| fluxdef_to_py(py, f)).collect();
2138    out.set_item("fluxes", fluxes).ok();
2139    out.set_item(
2140        "cooling_times_s",
2141        deck.cooling
2142            .as_ref()
2143            .map(|c| c.times_s.clone())
2144            .unwrap_or_default(),
2145    )
2146    .ok();
2147    let schedules: Vec<Py<PyAny>> = deck
2148        .schedules
2149        .iter()
2150        .map(|s| {
2151            let d = PyDict::new(py);
2152            let items: Vec<Vec<String>> = s.items.iter().map(|it| it.tokens.clone()).collect();
2153            d.set_item("name", &s.name).ok();
2154            d.set_item("items", items).ok();
2155            d.into_any().unbind()
2156        })
2157        .collect();
2158    out.set_item("schedules", schedules).ok();
2159    let histories: Vec<Py<PyAny>> = deck
2160        .pulse_histories
2161        .iter()
2162        .map(|h| {
2163            let d = PyDict::new(py);
2164            let levels: Vec<Py<PyAny>> = h
2165                .levels
2166                .iter()
2167                .map(|l| {
2168                    let e = PyDict::new(py);
2169                    e.set_item("pulses", l.pulses).ok();
2170                    e.set_item("delay_s", l.delay_s).ok();
2171                    e.into_any().unbind()
2172                })
2173                .collect();
2174            d.set_item("name", &h.name).ok();
2175            d.set_item("levels", levels).ok();
2176            d.into_any().unbind()
2177        })
2178        .collect();
2179    out.set_item("pulse_histories", histories).ok();
2180    let outputs: Vec<Py<PyAny>> = deck
2181        .outputs
2182        .iter()
2183        .map(|o| {
2184            let d = PyDict::new(py);
2185            d.set_item("resolution", &o.resolution).ok();
2186            d.set_item("entries", o.entries.clone()).ok();
2187            d.into_any().unbind()
2188        })
2189        .collect();
2190    out.set_item("outputs", outputs).ok();
2191    out.set_item("truncation", deck.truncation.as_ref().map(|t| t.tolerance))
2192        .ok();
2193    out.into_any().unbind()
2194}
2195
2196fn mixture_to_py(py: Python<'_>, mix: &nucleide_alara_io::deck::Mixture) -> Py<PyAny> {
2197    use pyo3::types::PyDict;
2198    let entries: Vec<Py<PyAny>> = mix
2199        .entries
2200        .iter()
2201        .map(|e| mixture_entry_to_py(py, e))
2202        .collect();
2203    let d = PyDict::new(py);
2204    d.set_item("name", &mix.name).ok();
2205    d.set_item("entries", entries).ok();
2206    d.into_any().unbind()
2207}
2208
2209fn mixture_entry_to_py(py: Python<'_>, entry: &nucleide_alara_io::deck::MixtureEntry) -> Py<PyAny> {
2210    use nucleide_alara_io::deck::MixtureEntry as E;
2211    use pyo3::types::PyDict;
2212    let d = PyDict::new(py);
2213    match entry {
2214        E::Material {
2215            name,
2216            rel_density,
2217            vol_fraction,
2218        } => {
2219            d.set_item("kind", "material").ok();
2220            d.set_item("name", name).ok();
2221            d.set_item("rel_density", *rel_density).ok();
2222            d.set_item("vol_fraction", *vol_fraction).ok();
2223        }
2224        E::Element {
2225            symbol,
2226            rel_density,
2227            vol_fraction,
2228        } => {
2229            d.set_item("kind", "element").ok();
2230            d.set_item("symbol", symbol).ok();
2231            d.set_item("rel_density", *rel_density).ok();
2232            d.set_item("vol_fraction", *vol_fraction).ok();
2233        }
2234        E::Like {
2235            mixture,
2236            rel_density,
2237        } => {
2238            d.set_item("kind", "like").ok();
2239            d.set_item("mixture", mixture).ok();
2240            d.set_item("rel_density", *rel_density).ok();
2241        }
2242        E::Target { target_kind, name } => {
2243            d.set_item("kind", "target").ok();
2244            d.set_item("target_kind", target_kind).ok();
2245            d.set_item("name", name).ok();
2246        }
2247    }
2248    d.into_any().unbind()
2249}
2250
2251fn fluxdef_to_py(py: Python<'_>, flux: &nucleide_alara_io::deck::FluxDef) -> Py<PyAny> {
2252    use pyo3::types::PyDict;
2253    let d = PyDict::new(py);
2254    d.set_item("name", &flux.name).ok();
2255    d.set_item("file", &flux.file).ok();
2256    d.set_item("scale", flux.scale).ok();
2257    d.set_item("skip", flux.skip).ok();
2258    d.set_item("format", &flux.format).ok();
2259    d.into_any().unbind()
2260}
2261
2262/// Parse an ALARA default-format group-flux file into plain containers.
2263///
2264/// Returns a dict with `name`, `groups_per_interval`, `num_intervals`,
2265/// `totals` (per-interval sums), `total` (grand sum), and `intervals`.
2266#[pyfunction]
2267fn alara_parse_flux(py: Python<'_>, text: &str, name: &str) -> PyResult<Py<PyAny>> {
2268    let owned_text = text.to_owned();
2269    let owned_name = name.to_owned();
2270    let spectra = py
2271        .detach(move || nucleide_alara_io::FluxSpectra::parse(&owned_name, &owned_text))
2272        .map_err(ala_err)?;
2273    use pyo3::types::PyDict;
2274    let d = PyDict::new(py);
2275    d.set_item("name", spectra.name.clone()).ok();
2276    d.set_item("groups_per_interval", spectra.groups_per_interval)
2277        .ok();
2278    d.set_item("num_intervals", spectra.num_intervals()).ok();
2279    let totals: Vec<f64> = spectra.intervals.iter().map(|iv| iv.iter().sum()).collect();
2280    d.set_item("totals", totals).ok();
2281    d.set_item("total", spectra.total()).ok();
2282    d.set_item("intervals", spectra.intervals.clone()).ok();
2283    Ok(d.into_any().unbind())
2284}
2285
2286/// Parse an ALARA activation-output listing into a list of row dicts.
2287///
2288/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
2289/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
2290/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
2291#[pyfunction]
2292fn alara_parse_output(
2293    py: Python<'_>,
2294    text: &str,
2295    run_lbl: &str,
2296) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2297    let owned_text = text.to_owned();
2298    let owned_lbl = run_lbl.to_owned();
2299    let rows = py
2300        .detach(move || {
2301            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl).map(|f| f.rows)
2302        })
2303        .map_err(ala_err)?;
2304    Ok(rows
2305        .iter()
2306        .map(|r| {
2307            let mut d = BTreeMap::new();
2308            d.insert(
2309                "time_s".to_string(),
2310                r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
2311            );
2312            d.insert(
2313                "time_label".to_string(),
2314                r.time_label
2315                    .clone()
2316                    .into_pyobject(py)
2317                    .unwrap()
2318                    .unbind()
2319                    .into_any(),
2320            );
2321            d.insert(
2322                "nuclide".to_string(),
2323                r.nuclide
2324                    .clone()
2325                    .into_pyobject(py)
2326                    .unwrap()
2327                    .unbind()
2328                    .into_any(),
2329            );
2330            d.insert(
2331                "half_life_s".to_string(),
2332                r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
2333            );
2334            d.insert(
2335                "run_lbl".to_string(),
2336                r.run_lbl
2337                    .clone()
2338                    .into_pyobject(py)
2339                    .unwrap()
2340                    .unbind()
2341                    .into_any(),
2342            );
2343            d.insert(
2344                "block".to_string(),
2345                r.block
2346                    .as_str()
2347                    .into_pyobject(py)
2348                    .unwrap()
2349                    .unbind()
2350                    .into_any(),
2351            );
2352            d.insert(
2353                "block_name".to_string(),
2354                r.block_name
2355                    .clone()
2356                    .into_pyobject(py)
2357                    .unwrap()
2358                    .unbind()
2359                    .into_any(),
2360            );
2361            d.insert(
2362                "block_num".to_string(),
2363                r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
2364            );
2365            d.insert(
2366                "variable".to_string(),
2367                r.variable
2368                    .as_str()
2369                    .into_pyobject(py)
2370                    .unwrap()
2371                    .unbind()
2372                    .into_any(),
2373            );
2374            d.insert(
2375                "var_unit".to_string(),
2376                r.var_unit
2377                    .clone()
2378                    .into_pyobject(py)
2379                    .unwrap()
2380                    .unbind()
2381                    .into_any(),
2382            );
2383            d.insert(
2384                "value".to_string(),
2385                r.value.into_pyobject(py).unwrap().unbind().into_any(),
2386            );
2387            d
2388        })
2389        .collect())
2390}
2391
2392/// Expand a deck's schedule hierarchy into flat irradiation/cooling steps.
2393///
2394/// Choice: takes deck text (plus optional top schedule name) instead of JSON
2395/// schedule/history blobs, so callers reuse the already-parsed deck blocks
2396/// without a parallel JSON schema. Returns a list of
2397/// {duration_s, flux, is_cooling} dicts.
2398#[pyfunction]
2399#[pyo3(signature = (deck_text, top=None))]
2400fn alara_expand_schedule(
2401    py: Python<'_>,
2402    deck_text: &str,
2403    top: Option<&str>,
2404) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2405    let owned_text = deck_text.to_owned();
2406    let owned_top = top.map(str::to_owned);
2407    let steps = py
2408        .detach(move || expand_deck_schedules(&owned_text, owned_top.as_deref()))
2409        .map_err(PyValueError::new_err)?;
2410    Ok(steps
2411        .into_iter()
2412        .map(|s| {
2413            let mut d = BTreeMap::new();
2414            let cooling = s.is_cooling();
2415            d.insert(
2416                "duration_s".to_string(),
2417                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
2418            );
2419            d.insert(
2420                "flux".to_string(),
2421                s.flux
2422                    .clone()
2423                    .into_pyobject(py)
2424                    .unwrap()
2425                    .unbind()
2426                    .into_any(),
2427            );
2428            d.insert(
2429                "is_cooling".to_string(),
2430                pyo3::types::PyBool::new(py, cooling)
2431                    .to_owned()
2432                    .into_any()
2433                    .unbind(),
2434            );
2435            d
2436        })
2437        .collect())
2438}
2439
2440fn expand_deck_schedules(
2441    deck_text: &str,
2442    top: Option<&str>,
2443) -> Result<Vec<nucleide_alara_io::FlatStep>, String> {
2444    let deck = nucleide_alara_io::AlaraDeck::parse(deck_text).map_err(|e| e.to_string())?;
2445    let mut scheds = Vec::with_capacity(deck.schedules.len());
2446    for raw in &deck.schedules {
2447        let mut items = Vec::with_capacity(raw.items.len());
2448        for entry in &raw.items {
2449            items.push(
2450                parse_deck_sched_item(&entry.tokens)
2451                    .map_err(|m| format!("schedule `{}` line {}: {m}", raw.name, entry.line))?,
2452            );
2453        }
2454        scheds.push(nucleide_alara_io::schedule::ScheduleDef {
2455            name: raw.name.clone(),
2456            items,
2457        });
2458    }
2459    let histories: Vec<nucleide_alara_io::schedule::PulseHistory> = deck
2460        .pulse_histories
2461        .iter()
2462        .map(|h| nucleide_alara_io::schedule::PulseHistory {
2463            name: h.name.clone(),
2464            levels: h
2465                .levels
2466                .iter()
2467                .map(|l| nucleide_alara_io::schedule::PulseLevel {
2468                    count: l.pulses,
2469                    delay_s: l.delay_s,
2470                })
2471                .collect(),
2472        })
2473        .collect();
2474    match top {
2475        Some(name) => {
2476            nucleide_alara_io::expand_from(name, &scheds, &histories).map_err(|e| e.to_string())
2477        }
2478        None => nucleide_alara_io::expand(&scheds, &histories).map_err(|e| e.to_string()),
2479    }
2480}
2481
2482fn parse_deck_sched_item(tokens: &[String]) -> Result<nucleide_alara_io::SchedItem, String> {
2483    match tokens {
2484        [op_text, op_unit, flux, history, delay_text, delay_unit] => {
2485            let op: f64 = op_text
2486                .parse()
2487                .map_err(|_| format!("expected operating time, found `{op_text}`"))?;
2488            let delay: f64 = delay_text
2489                .parse()
2490                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2491            let op_time_s =
2492                nucleide_alara_io::parse_time_to_seconds(op, op_unit).map_err(|e| e.to_string())?;
2493            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2494                .map_err(|e| e.to_string())?;
2495            Ok(nucleide_alara_io::SchedItem::Pulse {
2496                op_time_s,
2497                flux: flux.clone(),
2498                history: history.clone(),
2499                delay_s,
2500            })
2501        }
2502        [name, history, delay_text, delay_unit] => {
2503            let delay: f64 = delay_text
2504                .parse()
2505                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2506            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2507                .map_err(|e| e.to_string())?;
2508            Ok(nucleide_alara_io::SchedItem::SubSchedule {
2509                name: name.clone(),
2510                history: history.clone(),
2511                delay_s,
2512            })
2513        }
2514        _ => Err(format!(
2515            "expected 4- or 6-token schedule item, found {}",
2516            tokens.join(" ")
2517        )),
2518    }
2519}
2520
2521// ---------------------------------------------------------------------------
2522// Data accessors, input parsing, enrichment, materials
2523// ---------------------------------------------------------------------------
2524
2525/// Half-life [s] for a nucid integer or name string.
2526#[pyfunction]
2527fn half_life(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2528    lookup(key, nucleide_nuclei::data::half_life)
2529}
2530
2531/// Decay constant lambda = ln2 / t_half [1/s].
2532#[pyfunction]
2533fn decay_constant(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2534    lookup(key, nucleide_nuclei::data::decay_constant)
2535}
2536
2537/// Neutron-capture Q value computed from AME2020 masses [MeV].
2538#[pyfunction]
2539fn q_value_capture(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2540    lookup(key, nucleide_nuclei::data::q_value_neutron_capture)
2541}
2542
2543/// Alpha-decay Q value from AME2020 masses [MeV].
2544#[pyfunction]
2545fn q_value_alpha(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2546    lookup(key, nucleide_nuclei::data::q_value_alpha)
2547}
2548
2549/// Parse MCNP material cards from an input deck.
2550/// Returns a list of dicts: {number, fractions: {NuclideName: frac},
2551/// fraction_type: "atom"|"mass", density, comments}.
2552#[pyfunction]
2553fn read_inp(path: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2554    let mats = nucleide_mcnp_io::inp::materials_from_file(path)
2555        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2556    Python::attach(|py| {
2557        Ok(mats
2558            .into_iter()
2559            .map(|m| {
2560                let mut d = BTreeMap::new();
2561                d.insert(
2562                    "number".to_string(),
2563                    m.number.into_pyobject(py).unwrap().unbind().into_any(),
2564                );
2565                let fr: BTreeMap<String, f64> = m
2566                    .fractions
2567                    .iter()
2568                    .map(|(id, f)| (id.to_name(), *f))
2569                    .collect();
2570                d.insert(
2571                    "fractions".to_string(),
2572                    fr.into_pyobject(py).unwrap().unbind().into_any(),
2573                );
2574                d.insert(
2575                    "fraction_type".to_string(),
2576                    match m.fraction_type {
2577                        nucleide_mcnp_io::inp::FracKind::Atom => "atom",
2578                        nucleide_mcnp_io::inp::FracKind::Mass => "mass",
2579                    }
2580                    .into_pyobject(py)
2581                    .unwrap()
2582                    .unbind()
2583                    .into_any(),
2584                );
2585                d.insert(
2586                    "density".to_string(),
2587                    m.density.into_pyobject(py).unwrap().unbind().into_any(),
2588                );
2589                d.insert(
2590                    "comments".to_string(),
2591                    m.comments
2592                        .join(" ")
2593                        .into_pyobject(py)
2594                        .unwrap()
2595                        .unbind()
2596                        .into_any(),
2597                );
2598                d
2599            })
2600            .collect())
2601    })
2602}
2603
2604fn comp_to_material(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
2605    let mut mat = nucleide_material::Material::new();
2606    for (name, grams) in &comp {
2607        let id = nucleide_nuclei::NuclideId::from_name(name)
2608            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
2609        mat.add_nuclide(id, *grams);
2610    }
2611    Ok(mat)
2612}
2613
2614/// Expand a chemical formula into a natural-isotope composition dict
2615/// ({nuclide_name: atom_fraction}) using AME2020 masses + abundances.
2616#[pyfunction]
2617fn from_formula(formula: &str) -> PyResult<BTreeMap<String, f64>> {
2618    use nucleide_material::AbundanceProvider;
2619    let parsed = nucleide_material::parse_formula(formula)
2620        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2621    // Build a temporary element-count material then expand via abundances:
2622    let mut nat = Vec::new();
2623    for (z, count) in &parsed {
2624        if let Some(isotopes) = nucleide_material::NaturalAbundances.natural_isotopes(*z) {
2625            for (id, frac) in isotopes {
2626                nat.push((id, frac * count));
2627            }
2628        }
2629    }
2630    let total: f64 = nat.iter().map(|(_, c)| c).sum();
2631    if total <= 0.0 {
2632        return Err(PyValueError::new_err("empty formula expansion"));
2633    }
2634    let mut out: BTreeMap<String, f64> = BTreeMap::new();
2635    for (id, atoms) in nat {
2636        *out.entry(id.to_name()).or_insert(0.0) += atoms / total;
2637    }
2638    Ok(out)
2639}
2640
2641/// Activity [Bq] per nuclide plus whole-material specific activity.
2642/// Returns {name: Bq} entries and "specific" = Bq/g of the composition.
2643#[pyfunction]
2644fn activity(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
2645    let mat = comp_to_material(comp)?;
2646    let analytics = nucleide_material::Analytics {
2647        masses: &nucleide_material::Ame2020,
2648        decays: &nucleide_material::ChainDecays,
2649    };
2650    let per_nuc = mat
2651        .activity(&analytics)
2652        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2653    let specific = mat
2654        .specific_activity(&analytics)
2655        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2656    let mut out: BTreeMap<String, f64> = per_nuc
2657        .into_iter()
2658        .map(|(id, v)| (id.to_name(), v))
2659        .collect();
2660    out.insert("specific".to_string(), specific);
2661    Ok(out)
2662}
2663
2664/// Serialize a composition dictionary to a `<material>` XML fragment.
2665#[pyfunction]
2666fn to_xml(comp: BTreeMap<String, f64>, name: &str, density: f64, units: &str) -> PyResult<String> {
2667    let mat = comp_to_material(comp)?;
2668    mat.to_xml(name, density, units)
2669        .map_err(|e| PyValueError::new_err(e.to_string()))
2670}
2671
2672/// Enrichment cascade with numeric multicomponent solving.
2673#[pyclass(name = "Cascade")]
2674struct PyCascade {
2675    inner: std::sync::Mutex<nucleide_enrichment::Cascade>,
2676}
2677
2678#[pymethods]
2679impl PyCascade {
2680    /// Natural-uranium default cascade (alpha=1.05, Mstar=236, j=U235, k=U238).
2681    #[staticmethod]
2682    fn default_uranium() -> Self {
2683        Self {
2684            inner: std::sync::Mutex::new(nucleide_enrichment::default_uranium_cascade()),
2685        }
2686    }
2687
2688    /// Build a cascade from full parameters. `mat_feed` is a dict of
2689    /// nuclide-name strings to mass fractions.
2690    #[new]
2691    #[allow(non_snake_case)]
2692    #[allow(clippy::too_many_arguments)]
2693    fn new(
2694        alpha: f64,
2695        Mstar: f64,
2696        j: u32,
2697        k: u32,
2698        N: f64,
2699        M: f64,
2700        x_feed_j: f64,
2701        x_prod_j: f64,
2702        x_tail_j: f64,
2703        mat_feed: BTreeMap<String, f64>,
2704    ) -> PyResult<Self> {
2705        let mut feed = BTreeMap::new();
2706        for (name, frac) in mat_feed {
2707            let id = NuclideId::from_name(&name).map_err(wrap_nucid_err)?;
2708            feed.insert(id, frac);
2709        }
2710        let casc = nucleide_enrichment::Cascade {
2711            alpha,
2712            Mstar,
2713            j: NuclideId::from_nucid(j),
2714            k: NuclideId::from_nucid(k),
2715            N,
2716            M,
2717            x_feed_j,
2718            x_prod_j,
2719            x_tail_j,
2720            mat_feed: nucleide_enrichment::Stream::with_total_mass(feed, 1.0),
2721            mat_prod: nucleide_enrichment::Stream::new(),
2722            mat_tail: nucleide_enrichment::Stream::new(),
2723            l_t_per_feed: 0.0,
2724            swu_per_feed: 0.0,
2725            swu_per_prod: 0.0,
2726        };
2727        Ok(Self {
2728            inner: std::sync::Mutex::new(casc),
2729        })
2730    }
2731
2732    /// Solve via the numeric fixed-point + secant scheme in place.
2733    #[pyo3(signature = (tolerance=None, max_iterations=None))]
2734    fn solve(&self, tolerance: Option<f64>, max_iterations: Option<u32>) -> PyResult<()> {
2735        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
2736        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
2737        let mut c = self
2738            .inner
2739            .lock()
2740            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2741        *c = nucleide_enrichment::solve_numeric(&c, tol, iters)
2742            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2743        Ok(())
2744    }
2745
2746    /// Solve and optimize `M*` for a multicomponent feed in place.
2747    #[pyo3(signature = (tolerance=None, max_iterations=None))]
2748    fn solve_multicomponent(
2749        &self,
2750        tolerance: Option<f64>,
2751        max_iterations: Option<u32>,
2752    ) -> PyResult<()> {
2753        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
2754        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
2755        let mut c = self
2756            .inner
2757            .lock()
2758            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2759        *c = nucleide_enrichment::multicomponent(&c, tol, iters)
2760            .map_err(|e| PyValueError::new_err(e.to_string()))?;
2761        Ok(())
2762    }
2763
2764    #[getter]
2765    fn alpha(&self) -> PyResult<f64> {
2766        Ok(self
2767            .inner
2768            .lock()
2769            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2770            .alpha)
2771    }
2772    #[getter]
2773    #[allow(non_snake_case)]
2774    fn Mstar(&self) -> PyResult<f64> {
2775        Ok(self
2776            .inner
2777            .lock()
2778            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2779            .Mstar)
2780    }
2781    #[getter]
2782    #[allow(non_snake_case)]
2783    fn N(&self) -> PyResult<f64> {
2784        Ok(self
2785            .inner
2786            .lock()
2787            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2788            .N)
2789    }
2790    #[getter]
2791    #[allow(non_snake_case)]
2792    fn M(&self) -> PyResult<f64> {
2793        Ok(self
2794            .inner
2795            .lock()
2796            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2797            .M)
2798    }
2799    #[getter]
2800    fn x_feed_j(&self) -> PyResult<f64> {
2801        Ok(self
2802            .inner
2803            .lock()
2804            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2805            .x_feed_j)
2806    }
2807    #[getter]
2808    fn x_prod_j(&self) -> PyResult<f64> {
2809        Ok(self
2810            .inner
2811            .lock()
2812            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2813            .x_prod_j)
2814    }
2815    #[getter]
2816    fn x_tail_j(&self) -> PyResult<f64> {
2817        Ok(self
2818            .inner
2819            .lock()
2820            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2821            .x_tail_j)
2822    }
2823    #[getter]
2824    fn l_t_per_feed(&self) -> PyResult<f64> {
2825        Ok(self
2826            .inner
2827            .lock()
2828            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2829            .l_t_per_feed)
2830    }
2831    #[getter]
2832    fn swu_per_feed(&self) -> PyResult<f64> {
2833        Ok(self
2834            .inner
2835            .lock()
2836            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2837            .swu_per_feed)
2838    }
2839    #[getter]
2840    fn swu_per_prod(&self) -> PyResult<f64> {
2841        Ok(self
2842            .inner
2843            .lock()
2844            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2845            .swu_per_prod)
2846    }
2847    /// Feed composition as {nuclide_name: mass_fraction}.
2848    #[getter]
2849    fn mat_feed(&self) -> PyResult<BTreeMap<String, f64>> {
2850        Ok(self
2851            .inner
2852            .lock()
2853            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2854            .mat_feed
2855            .comp
2856            .iter()
2857            .map(|(id, frac)| (id.to_name(), *frac))
2858            .collect())
2859    }
2860    /// Product composition as {nuclide_name: mass_fraction}.
2861    #[getter]
2862    fn mat_prod(&self) -> PyResult<BTreeMap<String, f64>> {
2863        Ok(self
2864            .inner
2865            .lock()
2866            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2867            .mat_prod
2868            .comp
2869            .iter()
2870            .map(|(id, frac)| (id.to_name(), *frac))
2871            .collect())
2872    }
2873    /// Tails composition as {nuclide_name: mass_fraction}.
2874    #[getter]
2875    fn mat_tail(&self) -> PyResult<BTreeMap<String, f64>> {
2876        Ok(self
2877            .inner
2878            .lock()
2879            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
2880            .mat_tail
2881            .comp
2882            .iter()
2883            .map(|(id, frac)| (id.to_name(), *frac))
2884            .collect())
2885    }
2886    /// Separative work per product [kg SWU/kg] from the key assays.
2887    fn separative_work_per_product(&self) -> PyResult<f64> {
2888        let c = self
2889            .inner
2890            .lock()
2891            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2892        Ok(nucleide_enrichment::swu_per_prod(
2893            c.x_feed_j, c.x_prod_j, c.x_tail_j,
2894        ))
2895    }
2896
2897    fn __repr__(&self) -> PyResult<String> {
2898        let c = self
2899            .inner
2900            .lock()
2901            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
2902        Ok(format!(
2903            "Cascade(alpha={}, Mstar={}, x_prod_j={:.5})",
2904            c.alpha, c.Mstar, c.x_prod_j
2905        ))
2906    }
2907}
2908
2909/// Dirac separation potential `V(x) = (2x - 1) ln(x / (1 - x))`.
2910///
2911/// Thin wrapper over `nucleide_enrichment::value_func`.
2912#[pyfunction]
2913fn enrichment_value_func(x: f64) -> f64 {
2914    nucleide_enrichment::value_func(x)
2915}
2916
2917/// SWU per unit mass of feed for assays `x_feed`, `x_prod`, `x_tail`.
2918///
2919/// Thin wrapper over `nucleide_enrichment::swu_per_feed`.
2920#[pyfunction]
2921fn enrichment_swu_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
2922    nucleide_enrichment::swu_per_feed(x_feed, x_prod, x_tail)
2923}
2924
2925/// SWU per unit mass of product for assays `x_feed`, `x_prod`, `x_tail`.
2926///
2927/// Thin wrapper over `nucleide_enrichment::swu_per_prod`.
2928#[pyfunction]
2929fn enrichment_swu_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
2930    nucleide_enrichment::swu_per_prod(x_feed, x_prod, x_tail)
2931}
2932
2933/// SWU per unit mass of tails for assays `x_feed`, `x_prod`, `x_tail`.
2934///
2935/// Thin wrapper over `nucleide_enrichment::swu_per_tail`.
2936#[pyfunction]
2937fn enrichment_swu_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
2938    nucleide_enrichment::swu_per_tail(x_feed, x_prod, x_tail)
2939}
2940
2941/// PNNL/DOE Materials Compendium library (411 named materials).
2942#[pyclass(name = "MaterialsCompendium")]
2943struct PyMaterialsCompendium {
2944    inner: nucleide_material::MaterialsLibrary,
2945}
2946
2947#[pymethods]
2948impl PyMaterialsCompendium {
2949    /// Load from the official MaterialsCompendium.json.
2950    #[staticmethod]
2951    fn load(path: &str) -> PyResult<Self> {
2952        nucleide_material::MaterialsLibrary::from_file(path)
2953            .map(|inner| PyMaterialsCompendium { inner })
2954            .map_err(|e| PyValueError::new_err(e.to_string()))
2955    }
2956
2957    fn __len__(&self) -> usize {
2958        self.inner.len()
2959    }
2960
2961    /// All display names in file order.
2962    fn names(&self) -> Vec<String> {
2963        self.inner.names().into_iter().map(String::from).collect()
2964    }
2965
2966    /// Case-insensitive lookup by name; returns
2967    /// {name, mat_num, density, fractions: {ZAID: weight_fraction}} or None.
2968    /// With as_material=True fractions are keyed by nuclide name instead.
2969    #[pyo3(signature = (name, as_material=false))]
2970    #[allow(clippy::type_complexity)]
2971    fn get(&self, name: &str, as_material: bool) -> PyResult<Option<BTreeMap<String, Py<PyAny>>>> {
2972        let entry = match self.inner.get(name) {
2973            Some(e) => e,
2974            None => return Ok(None),
2975        };
2976        // Material conversion needs no GIL; do it before attaching.
2977        let named_fractions = if as_material {
2978            Some(
2979                entry
2980                    .to_material()
2981                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
2982            )
2983        } else {
2984            None
2985        };
2986
2987        Ok(Python::attach(|py| {
2988            let mut d: BTreeMap<String, Py<PyAny>> = BTreeMap::new();
2989            d.insert(
2990                "name".into(),
2991                entry
2992                    .name
2993                    .as_str()
2994                    .into_pyobject(py)
2995                    .unwrap()
2996                    .unbind()
2997                    .into_any(),
2998            );
2999            d.insert(
3000                "mat_num".into(),
3001                entry.mat_num.into_pyobject(py).unwrap().unbind().into_any(),
3002            );
3003            d.insert(
3004                "density".into(),
3005                entry.density.into_pyobject(py).unwrap().unbind().into_any(),
3006            );
3007            match &named_fractions {
3008                Some(mat) => {
3009                    let fr: BTreeMap<String, f64> =
3010                        mat.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
3011                    d.insert(
3012                        "fractions".into(),
3013                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3014                    );
3015                }
3016                None => {
3017                    let fr = entry.weight_fractions();
3018                    d.insert(
3019                        "fractions".into(),
3020                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3021                    );
3022                }
3023            }
3024            Some(d)
3025        }))
3026    }
3027}
3028
3029// ---------------------------------------------------------------------------
3030// CCCC I/O (thin glue over `cccc-io`; no solver)
3031// ---------------------------------------------------------------------------
3032
3033/// Parse ISOTXS text into plain Python containers.
3034///
3035/// Returns a dict with `nuclides` (list of {label, zaid, groups, total_xs}
3036/// in file order).
3037#[pyfunction]
3038fn isotxs_parse(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3039    let owned = text.to_owned();
3040    let lib = py
3041        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3042        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3043    Ok(isotxs_to_py(py, &lib))
3044}
3045
3046fn isotxs_to_py(py: Python<'_>, lib: &nucleide_cccc_io::IsotxsLib) -> Py<PyAny> {
3047    use pyo3::types::PyDict;
3048    let out = PyDict::new(py);
3049    let nuclides: Vec<Py<PyAny>> = lib
3050        .nuclides
3051        .iter()
3052        .map(|n| {
3053            let d = PyDict::new(py);
3054            d.set_item("label", &n.label).ok();
3055            d.set_item("zaid", &n.zaid).ok();
3056            d.set_item("groups", n.groups).ok();
3057            d.set_item("total_xs", n.total_xs.clone()).ok();
3058            d.into_any().unbind()
3059        })
3060        .collect();
3061    out.set_item("nuclides", nuclides).ok();
3062    out.into_any().unbind()
3063}
3064
3065/// Parse an RTFLUX/ATFLUX/RZFLUX flux file into plain containers.
3066///
3067/// `kind` selects the expected header keyword (`rtflux`|`atflux`|`rzflux`,
3068/// case-insensitive). Returns a dict with `kind`, `groups`, `per_point`,
3069/// `npoints`, `values`, and `total`.
3070#[pyfunction]
3071#[pyo3(signature = (text, kind="rtflux"))]
3072fn rtflux_parse(py: Python<'_>, text: &str, kind: &str) -> PyResult<Py<PyAny>> {
3073    let flux_kind = match kind.to_ascii_lowercase().as_str() {
3074        "rtflux" => nucleide_cccc_io::rtflux::FluxKind::Rtflux,
3075        "atflux" => nucleide_cccc_io::rtflux::FluxKind::Atflux,
3076        "rzflux" => nucleide_cccc_io::rtflux::FluxKind::Rzflux,
3077        other => {
3078            return Err(PyValueError::new_err(format!(
3079                "kind must be rtflux|atflux|rzflux, got `{other}`"
3080            )))
3081        }
3082    };
3083    let owned = text.to_owned();
3084    let flux = py
3085        .detach(move || nucleide_cccc_io::FluxFile::parse(flux_kind, &owned))
3086        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3087    use pyo3::types::PyDict;
3088    let d = PyDict::new(py);
3089    d.set_item("kind", flux.kind.keyword()).ok();
3090    d.set_item("groups", flux.groups).ok();
3091    d.set_item("per_point", flux.per_point).ok();
3092    d.set_item("npoints", flux.npoints()).ok();
3093    d.set_item("values", flux.values.clone()).ok();
3094    d.set_item("total", flux.total()).ok();
3095    Ok(d.into_any().unbind())
3096}
3097
3098fn partisn_deck_from_dict(
3099    deck: &Bound<'_, pyo3::types::PyDict>,
3100) -> PyResult<nucleide_cccc_io::PartisnDeck> {
3101    let title: String = match deck.get_item("title")? {
3102        Some(v) => v
3103            .extract()
3104            .map_err(|_| PyValueError::new_err("partisn deck `title` must be str"))?,
3105        None => return Err(PyValueError::new_err("partisn deck missing `title`")),
3106    };
3107    let dim: u8 = match deck.get_item("dim")? {
3108        Some(v) => v
3109            .extract()
3110            .map_err(|_| PyValueError::new_err("partisn deck `dim` must be 1, 2, or 3"))?,
3111        None => return Err(PyValueError::new_err("partisn deck missing `dim`")),
3112    };
3113    let zones_value = match deck.get_item("zones")? {
3114        Some(v) => v,
3115        None => return Err(PyValueError::new_err("partisn deck missing `zones`")),
3116    };
3117    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = zones_value
3118        .extract()
3119        .map_err(|_| PyValueError::new_err("partisn deck `zones` must be a list of dicts"))?;
3120    let mut zones = Vec::with_capacity(zone_dicts.len());
3121    for z in &zone_dicts {
3122        let id: u32 = match z.get_item("id")? {
3123            Some(v) => v
3124                .extract()
3125                .map_err(|_| PyValueError::new_err("partisn zone `id` must be int"))?,
3126            None => return Err(PyValueError::new_err("partisn zone missing `id`")),
3127        };
3128        let material: String = match z.get_item("material")? {
3129            Some(v) => v
3130                .extract()
3131                .map_err(|_| PyValueError::new_err("partisn zone `material` must be str"))?,
3132            None => return Err(PyValueError::new_err("partisn zone missing `material`")),
3133        };
3134        let isotxs_labels: Vec<String> = match z.get_item("isotxs_labels")? {
3135            Some(v) => v.extract().map_err(|_| {
3136                PyValueError::new_err("partisn zone `isotxs_labels` must be a list of str")
3137            })?,
3138            None => {
3139                return Err(PyValueError::new_err(
3140                    "partisn zone missing `isotxs_labels`",
3141                ))
3142            }
3143        };
3144        let density: f64 = match z.get_item("density")? {
3145            Some(v) => v
3146                .extract()
3147                .map_err(|_| PyValueError::new_err("partisn zone `density` must be float"))?,
3148            None => return Err(PyValueError::new_err("partisn zone missing `density`")),
3149        };
3150        zones.push(nucleide_cccc_io::partisn::PartisnZone {
3151            id,
3152            material,
3153            isotxs_labels,
3154            density,
3155        });
3156    }
3157    let source: Option<String> = match deck.get_item("source")? {
3158        Some(v) if v.is_none() => None,
3159        Some(v) => Some(
3160            v.extract()
3161                .map_err(|_| PyValueError::new_err("partisn deck `source` must be str or None"))?,
3162        ),
3163        None => None,
3164    };
3165    Ok(nucleide_cccc_io::PartisnDeck {
3166        title,
3167        dim,
3168        zones,
3169        source,
3170    })
3171}
3172
3173/// Render a PARTISN deck dict to PARTISN input text.
3174///
3175/// Deck shape: {title: str, dim: 1|2|3, zones: [{id, material,
3176/// isotxs_labels, density}], source: str | None}.
3177#[pyfunction]
3178fn partisn_render(py: Python<'_>, deck: &Bound<'_, pyo3::types::PyDict>) -> PyResult<String> {
3179    let rust_deck = partisn_deck_from_dict(deck)?;
3180    Ok(py.detach(move || rust_deck.render()))
3181}
3182
3183/// Validate a PARTISN deck dict against ISOTXS text.
3184///
3185/// Raises `ValueError` when `dim` is not 1/2/3 or a zone names an ISOTXS
3186/// label absent from the library.
3187#[pyfunction]
3188fn partisn_validate(
3189    py: Python<'_>,
3190    deck: &Bound<'_, pyo3::types::PyDict>,
3191    isotxs_text: &str,
3192) -> PyResult<()> {
3193    let rust_deck = partisn_deck_from_dict(deck)?;
3194    let owned = isotxs_text.to_owned();
3195    let lib = py
3196        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3197        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3198    rust_deck
3199        .validate(&lib)
3200        .map_err(|e| PyValueError::new_err(e.to_string()))
3201}
3202
3203// ---------------------------------------------------------------------------
3204// FISPACT-II output (thin glue over `fispact-io`; reuses ResponseFrame)
3205// ---------------------------------------------------------------------------
3206
3207fn fispact_row_to_map(
3208    py: Python<'_>,
3209    r: &nucleide_alara_io::output::ResponseRow,
3210) -> BTreeMap<String, Py<PyAny>> {
3211    let mut d = BTreeMap::new();
3212    d.insert(
3213        "time_s".to_string(),
3214        r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
3215    );
3216    d.insert(
3217        "time_label".to_string(),
3218        r.time_label
3219            .clone()
3220            .into_pyobject(py)
3221            .unwrap()
3222            .unbind()
3223            .into_any(),
3224    );
3225    d.insert(
3226        "nuclide".to_string(),
3227        r.nuclide
3228            .clone()
3229            .into_pyobject(py)
3230            .unwrap()
3231            .unbind()
3232            .into_any(),
3233    );
3234    d.insert(
3235        "half_life_s".to_string(),
3236        r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
3237    );
3238    d.insert(
3239        "run_lbl".to_string(),
3240        r.run_lbl
3241            .clone()
3242            .into_pyobject(py)
3243            .unwrap()
3244            .unbind()
3245            .into_any(),
3246    );
3247    d.insert(
3248        "block".to_string(),
3249        r.block
3250            .as_str()
3251            .into_pyobject(py)
3252            .unwrap()
3253            .unbind()
3254            .into_any(),
3255    );
3256    d.insert(
3257        "block_name".to_string(),
3258        r.block_name
3259            .clone()
3260            .into_pyobject(py)
3261            .unwrap()
3262            .unbind()
3263            .into_any(),
3264    );
3265    d.insert(
3266        "block_num".to_string(),
3267        r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
3268    );
3269    d.insert(
3270        "variable".to_string(),
3271        r.variable
3272            .as_str()
3273            .into_pyobject(py)
3274            .unwrap()
3275            .unbind()
3276            .into_any(),
3277    );
3278    d.insert(
3279        "var_unit".to_string(),
3280        r.var_unit
3281            .clone()
3282            .into_pyobject(py)
3283            .unwrap()
3284            .unbind()
3285            .into_any(),
3286    );
3287    d.insert(
3288        "value".to_string(),
3289        r.value.into_pyobject(py).unwrap().unbind().into_any(),
3290    );
3291    d
3292}
3293
3294/// Parse a FISPACT-II inventory listing into a list of row dicts.
3295///
3296/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
3297/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
3298/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
3299#[pyfunction]
3300fn fispact_parse_output(
3301    py: Python<'_>,
3302    text: &str,
3303    run_lbl: &str,
3304) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3305    let owned_text = text.to_owned();
3306    let owned_lbl = run_lbl.to_owned();
3307    let rows = py
3308        .detach(move || {
3309            nucleide_fispact_io::parse_to_frame(&owned_text, &owned_lbl).map(|f| f.rows)
3310        })
3311        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3312    Ok(rows.iter().map(|r| fispact_row_to_map(py, r)).collect())
3313}
3314
3315// ---------------------------------------------------------------------------
3316// ORIGEN TAPE readers (thin glue over `origen-io`; scoped TAPE5/6/9)
3317// ---------------------------------------------------------------------------
3318
3319/// Parse ORIGEN TAPE5 input-echo text into plain containers.
3320///
3321/// Returns a dict with `titles` (list[str]), `irradiation_steps`
3322/// (list of {flux, days}), and `materials` (list of {name, entries:
3323/// [{nuclide, grams}]}).
3324#[pyfunction]
3325fn origen_parse_tape5(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3326    let owned = text.to_owned();
3327    let tape = py
3328        .detach(move || nucleide_origen_io::Tape5::parse(&owned))
3329        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3330    use pyo3::types::PyDict;
3331    let out = PyDict::new(py);
3332    out.set_item("titles", tape.titles.clone()).ok();
3333    let steps: Vec<Py<PyAny>> = tape
3334        .irradiation_steps
3335        .iter()
3336        .map(|s| {
3337            let d = PyDict::new(py);
3338            d.set_item("flux", s.flux).ok();
3339            d.set_item("days", s.days).ok();
3340            d.into_any().unbind()
3341        })
3342        .collect();
3343    out.set_item("irradiation_steps", steps).ok();
3344    let materials: Vec<Py<PyAny>> = tape
3345        .materials
3346        .iter()
3347        .map(|m| {
3348            let d = PyDict::new(py);
3349            d.set_item("name", &m.name).ok();
3350            let entries: Vec<Py<PyAny>> = m
3351                .grams
3352                .iter()
3353                .map(|(nuclide, grams)| {
3354                    let e = PyDict::new(py);
3355                    e.set_item("nuclide", nuclide).ok();
3356                    e.set_item("grams", *grams).ok();
3357                    e.into_any().unbind()
3358                })
3359                .collect();
3360            d.set_item("entries", entries).ok();
3361            d.into_any().unbind()
3362        })
3363        .collect();
3364    out.set_item("materials", materials).ok();
3365    Ok(out.into_any().unbind())
3366}
3367
3368/// Parse ORIGEN TAPE6 output-inventory text into plain containers.
3369///
3370/// Returns a dict with `records` (list of {nuclide, grams, activity_bq} in
3371/// file order) and `total_activity` (sum over records).
3372#[pyfunction]
3373fn origen_parse_tape6(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3374    let owned = text.to_owned();
3375    let tape = py
3376        .detach(move || nucleide_origen_io::Tape6::parse(&owned))
3377        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3378    use pyo3::types::PyDict;
3379    let out = PyDict::new(py);
3380    let records: Vec<Py<PyAny>> = tape
3381        .records
3382        .iter()
3383        .map(|r| {
3384            let d = PyDict::new(py);
3385            d.set_item("nuclide", &r.nuclide).ok();
3386            d.set_item("grams", r.grams).ok();
3387            d.set_item("activity_bq", r.activity_bq).ok();
3388            d.into_any().unbind()
3389        })
3390        .collect();
3391    out.set_item("records", records).ok();
3392    out.set_item("total_activity", tape.total_activity()).ok();
3393    Ok(out.into_any().unbind())
3394}
3395
3396/// Parse ORIGEN TAPE9 decay-constant text into a list of row dicts.
3397///
3398/// Each entry is {nuclide, decay_const} in file order.
3399#[pyfunction]
3400fn origen_parse_tape9(py: Python<'_>, text: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3401    let owned = text.to_owned();
3402    let entries = py
3403        .detach(move || nucleide_origen_io::Tape9Entry::parse(&owned))
3404        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3405    Ok(entries
3406        .iter()
3407        .map(|e| {
3408            let mut d = BTreeMap::new();
3409            d.insert(
3410                "nuclide".to_string(),
3411                e.nuclide
3412                    .clone()
3413                    .into_pyobject(py)
3414                    .unwrap()
3415                    .unbind()
3416                    .into_any(),
3417            );
3418            d.insert(
3419                "decay_const".to_string(),
3420                e.decay_const.into_pyobject(py).unwrap().unbind().into_any(),
3421            );
3422            d
3423        })
3424        .collect())
3425}
3426
3427// ---------------------------------------------------------------------------
3428// R2S workflow builder (thin glue over `r2s`; no transport/activation solve)
3429// ---------------------------------------------------------------------------
3430
3431fn r2s_workflow_to_py(py: Python<'_>, workflow: &nucleide_r2s::R2sWorkflow) -> Py<PyAny> {
3432    use pyo3::types::PyDict;
3433    let out = PyDict::new(py);
3434    let steps: Vec<Py<PyAny>> = workflow
3435        .steps
3436        .iter()
3437        .map(|s| {
3438            let d = PyDict::new(py);
3439            d.set_item("zone", &s.zone).ok();
3440            d.set_item("flux", &s.flux).ok();
3441            d.into_any().unbind()
3442        })
3443        .collect();
3444    out.set_item("steps", steps).ok();
3445    out.set_item("cooling_s", workflow.cooling_s.clone()).ok();
3446    out.set_item("top_schedule", &workflow.top_schedule).ok();
3447    out.into_any().unbind()
3448}
3449
3450fn r2s_workflow_from_dict(
3451    workflow: &Bound<'_, pyo3::types::PyDict>,
3452) -> PyResult<nucleide_r2s::R2sWorkflow> {
3453    let steps_value = match workflow.get_item("steps")? {
3454        Some(v) => v,
3455        None => return Err(PyValueError::new_err("r2s workflow missing `steps`")),
3456    };
3457    let step_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = steps_value
3458        .extract()
3459        .map_err(|_| PyValueError::new_err("r2s workflow `steps` must be a list of dicts"))?;
3460    let mut steps = Vec::with_capacity(step_dicts.len());
3461    for s in &step_dicts {
3462        let zone: String = match s.get_item("zone")? {
3463            Some(v) => v
3464                .extract()
3465                .map_err(|_| PyValueError::new_err("r2s step `zone` must be str"))?,
3466            None => return Err(PyValueError::new_err("r2s step missing `zone`")),
3467        };
3468        let flux: String = match s.get_item("flux")? {
3469            Some(v) => v
3470                .extract()
3471                .map_err(|_| PyValueError::new_err("r2s step `flux` must be str"))?,
3472            None => return Err(PyValueError::new_err("r2s step missing `flux`")),
3473        };
3474        steps.push(nucleide_r2s::R2sStep { zone, flux });
3475    }
3476    let cooling_s: Vec<f64> = match workflow.get_item("cooling_s")? {
3477        Some(v) => v.extract().map_err(|_| {
3478            PyValueError::new_err("r2s workflow `cooling_s` must be a list of float")
3479        })?,
3480        None => return Err(PyValueError::new_err("r2s workflow missing `cooling_s`")),
3481    };
3482    let top_schedule: String = match workflow.get_item("top_schedule")? {
3483        Some(v) => v
3484            .extract()
3485            .map_err(|_| PyValueError::new_err("r2s workflow `top_schedule` must be str"))?,
3486        None => return Err(PyValueError::new_err("r2s workflow missing `top_schedule`")),
3487    };
3488    Ok(nucleide_r2s::R2sWorkflow {
3489        steps,
3490        cooling_s,
3491        top_schedule,
3492    })
3493}
3494
3495/// Derive an R2S workflow summary from an ALARA deck.
3496///
3497/// Returns a dict with `steps` (list of {zone, flux}), `cooling_s`
3498/// (list[float]), and `top_schedule` (str).
3499#[pyfunction]
3500fn r2s_from_deck(py: Python<'_>, deck_text: &str) -> PyResult<Py<PyAny>> {
3501    let owned = deck_text.to_owned();
3502    let workflow = py
3503        .detach(move || {
3504            let deck = nucleide_alara_io::AlaraDeck::parse(&owned)
3505                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3506            nucleide_r2s::R2sWorkflow::from_deck(&deck)
3507        })
3508        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3509    Ok(r2s_workflow_to_py(py, &workflow))
3510}
3511
3512/// Validate an R2S workflow dict against an ALARA deck.
3513///
3514/// Raises `ValueError` when a step zone/flux is unknown or cooling histories
3515/// are missing.
3516#[pyfunction]
3517fn r2s_validate(
3518    py: Python<'_>,
3519    workflow: &Bound<'_, pyo3::types::PyDict>,
3520    deck_text: &str,
3521) -> PyResult<()> {
3522    let rust_workflow = r2s_workflow_from_dict(workflow)?;
3523    let owned = deck_text.to_owned();
3524    let deck = py
3525        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
3526        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3527    rust_workflow
3528        .validate_against(&deck)
3529        .map_err(|e| PyValueError::new_err(e.to_string()))
3530}
3531
3532/// Expand an ALARA deck's irradiation hierarchy into flat steps via R2S.
3533///
3534/// Returns a list of {duration_s, flux, is_cooling} dicts. When `top` is
3535/// given it overrides the workflow's discovered top schedule.
3536#[pyfunction]
3537#[pyo3(signature = (deck_text, top=None))]
3538fn r2s_expand(
3539    py: Python<'_>,
3540    deck_text: &str,
3541    top: Option<&str>,
3542) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3543    let owned_text = deck_text.to_owned();
3544    let owned_top = top.map(str::to_owned);
3545    let steps = py
3546        .detach(move || {
3547            let deck = nucleide_alara_io::AlaraDeck::parse(&owned_text)
3548                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3549            let mut workflow = nucleide_r2s::R2sWorkflow::from_deck(&deck)?;
3550            if let Some(top) = owned_top {
3551                workflow.top_schedule = top;
3552            }
3553            workflow.expand(&deck, &[])
3554        })
3555        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3556    Ok(steps
3557        .into_iter()
3558        .map(|s| {
3559            let mut d = BTreeMap::new();
3560            let cooling = s.is_cooling();
3561            d.insert(
3562                "duration_s".to_string(),
3563                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
3564            );
3565            d.insert(
3566                "flux".to_string(),
3567                s.flux.into_pyobject(py).unwrap().unbind().into_any(),
3568            );
3569            d.insert(
3570                "is_cooling".to_string(),
3571                pyo3::types::PyBool::new(py, cooling)
3572                    .to_owned()
3573                    .into_any()
3574                    .unbind(),
3575            );
3576            d
3577        })
3578        .collect())
3579}
3580
3581/// Assemble a uniform-split photon source summary for `zone`.
3582///
3583/// Parses an ALARA activation-output listing, sums shutdown
3584/// `SpecificActivity` over the zone's nuclide rows (skipping `total`
3585/// aggregates), and splits the total uniformly over `groups` energy groups.
3586/// Returns a dict with `zone`, `groups` (list[float]), and `total`.
3587///
3588/// Approximation: the uniform split preserves only the total shutdown
3589/// strength; real decay photons follow the nuclide- and energy-dependent
3590/// lines in ALARA `.photonSrc` spectra.
3591#[pyfunction]
3592fn r2s_assemble(
3593    py: Python<'_>,
3594    output_text: &str,
3595    run_lbl: &str,
3596    zone: &str,
3597    groups: usize,
3598) -> PyResult<Py<PyAny>> {
3599    let owned_text = output_text.to_owned();
3600    let owned_lbl = run_lbl.to_owned();
3601    let owned_zone = zone.to_owned();
3602    let source = py
3603        .detach(move || {
3604            let frame = nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
3605                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
3606            Ok::<_, nucleide_r2s::Error>(nucleide_r2s::photon::assemble(
3607                &frame,
3608                &owned_zone,
3609                groups,
3610            ))
3611        })
3612        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3613    use pyo3::types::PyDict;
3614    let out = PyDict::new(py);
3615    out.set_item("zone", source.zone.clone()).ok();
3616    out.set_item("groups", source.groups.clone()).ok();
3617    out.set_item("total", source.total()).ok();
3618    Ok(out.into_any().unbind())
3619}
3620
3621/// Map zone totals onto voxels (`zone_of_voxel` holds zone indices).
3622///
3623/// `totals` carries one total source strength per zone; with `split=False`
3624/// every voxel copies its zone total (tag-as-attribute), with `split=True`
3625/// each zone total is divided conservatively over its voxels. Returns a
3626/// dict with `n_zones`, `zone_of_voxel`, `source_strength`,
3627/// `decay_time_s` (all shutdown `0.0`), and `total`. Thin wrapper over
3628/// `nucleide-r2s` `tag_zone_totals` / `split_zone_totals`.
3629#[pyfunction]
3630#[pyo3(signature = (totals, zone_of_voxel, split=false))]
3631fn r2s_tag_zone_strength(
3632    py: Python<'_>,
3633    totals: Vec<f64>,
3634    zone_of_voxel: Vec<usize>,
3635    split: bool,
3636) -> PyResult<Py<PyAny>> {
3637    let zones: Vec<nucleide_r2s::photon::ZonePhotonSource> = totals
3638        .into_iter()
3639        .enumerate()
3640        .map(|(i, total)| {
3641            let groups = if total == 0.0 {
3642                Vec::new()
3643            } else {
3644                vec![total]
3645            };
3646            nucleide_r2s::photon::ZonePhotonSource {
3647                zone: format!("zone{i}"),
3648                groups,
3649            }
3650        })
3651        .collect();
3652    let tags = if split {
3653        nucleide_r2s::tags::split_zone_totals(&zones, &zone_of_voxel)
3654    } else {
3655        nucleide_r2s::tags::tag_zone_totals(&zones, &zone_of_voxel)
3656    }
3657    .map_err(|e| PyValueError::new_err(e.to_string()))?;
3658    use pyo3::types::PyDict;
3659    let out = PyDict::new(py);
3660    out.set_item("n_zones", tags.n_zones).ok();
3661    out.set_item("zone_of_voxel", tags.zone_of_voxel.clone())
3662        .ok();
3663    out.set_item("source_strength", tags.source_strength.clone())
3664        .ok();
3665    out.set_item("decay_time_s", tags.decay_time_s.clone()).ok();
3666    out.set_item("total", tags.total_strength()).ok();
3667    Ok(out.into_any().unbind())
3668}
3669
3670/// Select and sum `.photonSrc` group spectra for `nuclides` at `time_s`.
3671///
3672/// Parses ALARA photon-source text, keeps rows matching the named nuclides
3673/// at exactly `time_s` seconds (shutdown `0.0`), and adds them element-wise
3674/// in ALARA group order. Returns a dict with `groups` (matching
3675/// `{nuclide, time_s, strengths}` rows), `sums`, and `total`. No rescaling:
3676/// strengths keep the file's normalization. Thin wrapper over
3677/// `nucleide-r2s` `photon_groups_at` / `sum_group_strengths`.
3678#[pyfunction]
3679fn r2s_photon_group_sums(
3680    py: Python<'_>,
3681    photon_text: &str,
3682    nuclides: Vec<String>,
3683    time_s: f64,
3684) -> PyResult<Py<PyAny>> {
3685    let source = nucleide_alara_io::photon::PhotonSource::from_str(photon_text)
3686        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3687    let names: Vec<&str> = nuclides.iter().map(String::as_str).collect();
3688    let at = nucleide_r2s::tags::photon_groups_at(&source, &names, time_s);
3689    let sums = nucleide_r2s::tags::sum_group_strengths(&at)
3690        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3691    use pyo3::types::PyDict;
3692    let out = PyDict::new(py);
3693    let rows: Vec<Py<PyAny>> = at
3694        .iter()
3695        .map(|g| {
3696            let d = PyDict::new(py);
3697            d.set_item("nuclide", g.nuclide.clone()).ok();
3698            d.set_item("time_s", g.time_s).ok();
3699            d.set_item("strengths", g.strengths.clone()).ok();
3700            d.into_any().unbind()
3701        })
3702        .collect();
3703    out.set_item("groups", rows).ok();
3704    out.set_item("sums", sums.clone()).ok();
3705    out.set_item("total", sums.iter().sum::<f64>()).ok();
3706    Ok(out.into_any().unbind())
3707}
3708
3709fn snapshot_dict_str(
3710    zone: &Bound<'_, pyo3::types::PyDict>,
3711    key: &str,
3712    what: &str,
3713) -> PyResult<String> {
3714    match zone.get_item(key)? {
3715        Some(v) => v
3716            .extract()
3717            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
3718        None => Err(PyValueError::new_err(format!(
3719            "snapshot {what} missing `{key}`"
3720        ))),
3721    }
3722}
3723
3724fn snapshot_dict_opt_str(
3725    zone: &Bound<'_, pyo3::types::PyDict>,
3726    key: &str,
3727    what: &str,
3728) -> PyResult<Option<String>> {
3729    match zone.get_item(key)? {
3730        Some(v) if v.is_none() => Ok(None),
3731        Some(v) => v
3732            .extract::<String>()
3733            .map(Some)
3734            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
3735        None => Ok(None),
3736    }
3737}
3738
3739fn snapshot_dict_f64(
3740    zone: &Bound<'_, pyo3::types::PyDict>,
3741    key: &str,
3742    what: &str,
3743) -> PyResult<f64> {
3744    match zone.get_item(key)? {
3745        Some(v) => v
3746            .extract()
3747            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
3748        None => Err(PyValueError::new_err(format!(
3749            "snapshot {what} missing `{key}`"
3750        ))),
3751    }
3752}
3753
3754fn snapshot_dict_opt_f64(
3755    zone: &Bound<'_, pyo3::types::PyDict>,
3756    key: &str,
3757    what: &str,
3758) -> PyResult<Option<f64>> {
3759    match zone.get_item(key)? {
3760        Some(v) if v.is_none() => Ok(None),
3761        Some(v) => v
3762            .extract::<f64>()
3763            .map(Some)
3764            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
3765        None => Ok(None),
3766    }
3767}
3768
3769fn snapshot_zone_from_dict(
3770    zone: &Bound<'_, pyo3::types::PyDict>,
3771) -> PyResult<nucleide_r2s::snapshot::SnapshotZone> {
3772    let id = snapshot_dict_str(zone, "id", "zone")?;
3773    let volume_cm3 = snapshot_dict_f64(zone, "volume_cm3", "zone")?;
3774    let composition: BTreeMap<String, f64> = match zone.get_item("composition")? {
3775        Some(v) => v.extract().map_err(|_| {
3776            PyValueError::new_err("snapshot zone `composition` must be a dict of str to float")
3777        })?,
3778        None => return Err(PyValueError::new_err("snapshot zone missing `composition`")),
3779    };
3780    Ok(nucleide_r2s::snapshot::SnapshotZone {
3781        zone: id,
3782        volume_cm3,
3783        zbottom_cm: snapshot_dict_opt_f64(zone, "zbottom_cm", "zone")?,
3784        ztop_cm: snapshot_dict_opt_f64(zone, "ztop_cm", "zone")?,
3785        material: snapshot_dict_opt_str(zone, "material", "zone")?,
3786        xs_type: snapshot_dict_opt_str(zone, "xs_type", "zone")?,
3787        temperature_c: snapshot_dict_opt_f64(zone, "temperature_C", "zone")?,
3788        composition: composition.into_iter().collect(),
3789        flux_name: snapshot_dict_opt_str(zone, "flux", "zone")?,
3790    })
3791}
3792
3793fn snapshot_input_from_dict(
3794    snapshot: &Bound<'_, pyo3::types::PyDict>,
3795) -> PyResult<nucleide_r2s::snapshot::SnapshotInput> {
3796    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("zones")? {
3797        Some(v) => v
3798            .extract()
3799            .map_err(|_| PyValueError::new_err("snapshot `zones` must be a list of dicts"))?,
3800        None => return Err(PyValueError::new_err("snapshot missing `zones`")),
3801    };
3802    let mut zones = Vec::with_capacity(zone_dicts.len());
3803    for z in &zone_dicts {
3804        zones.push(snapshot_zone_from_dict(z)?);
3805    }
3806    let flux_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("flux_defs")? {
3807        Some(v) => v
3808            .extract()
3809            .map_err(|_| PyValueError::new_err("snapshot `flux_defs` must be a list of dicts"))?,
3810        None => return Err(PyValueError::new_err("snapshot missing `flux_defs`")),
3811    };
3812    let mut flux_defs = Vec::with_capacity(flux_dicts.len());
3813    for f in &flux_dicts {
3814        flux_defs.push(nucleide_r2s::snapshot::SnapshotFluxDef {
3815            name: snapshot_dict_str(f, "name", "flux")?,
3816            file: snapshot_dict_str(f, "file", "flux")?,
3817            scale: snapshot_dict_f64(f, "scale", "flux")?,
3818        });
3819    }
3820    let cooling_s: Vec<f64> = match snapshot.get_item("cooling_s")? {
3821        Some(v) => v
3822            .extract()
3823            .map_err(|_| PyValueError::new_err("snapshot `cooling_s` must be a list of float"))?,
3824        None => return Err(PyValueError::new_err("snapshot missing `cooling_s`")),
3825    };
3826    Ok(nucleide_r2s::snapshot::SnapshotInput {
3827        zones,
3828        flux_defs,
3829        cooling_s,
3830        schedule_text: snapshot_dict_opt_str(snapshot, "schedule_text", "snapshot")?,
3831        output: snapshot_dict_opt_str(snapshot, "output", "snapshot")?,
3832    })
3833}
3834
3835/// Build an R2S workflow bundle from a versionless ARMI DB snapshot dict.
3836///
3837/// `snapshot` mirrors `nucleide_r2s::snapshot::SnapshotInput`: `zones` (list
3838/// of `{id, volume_cm3, composition: {ARMI-name: ndens}}` with optional
3839/// `zbottom_cm`/`ztop_cm`/`material`/`xs_type`/`temperature_C`/`flux`),
3840/// `flux_defs` (list of `{name, file, scale}`), `cooling_s` (list[float]),
3841/// plus optional `schedule_text` and `output`. Returns `{workflow, deck,
3842/// decks}`: the workflow summary (same shape as `r2s_from_deck`), the
3843/// canonical template deck text, and one canonical deck text per step.
3844///
3845/// Composition keys follow the emit ARMI-input rule (post-expansion nuclide
3846/// keys; elemental keys, bare `AM242`, and unknown names are `ValueError`s);
3847/// densities are atoms/barn-cm. Empty `cooling_s` is a `ValueError` via
3848/// workflow validation. Raises `ValueError` on any invalid input or dangling
3849/// cross-reference.
3850#[pyfunction]
3851fn r2s_from_snapshot(
3852    py: Python<'_>,
3853    snapshot: &Bound<'_, pyo3::types::PyDict>,
3854) -> PyResult<Py<PyAny>> {
3855    let input = snapshot_input_from_dict(snapshot)?;
3856    let (workflow, template, decks) = py
3857        .detach(move || nucleide_r2s::snapshot::snapshot_workflow(&input))
3858        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3859    use pyo3::types::PyDict;
3860    let out = PyDict::new(py);
3861    out.set_item("workflow", r2s_workflow_to_py(py, &workflow))
3862        .ok();
3863    out.set_item("deck", template.to_string()).ok();
3864    let deck_texts: Vec<String> = decks.iter().map(ToString::to_string).collect();
3865    out.set_item("decks", deck_texts).ok();
3866    Ok(out.into_any().unbind())
3867}
3868
3869// ---------------------------------------------------------------------------
3870// 0.3.0 series driver, data accessors, list helpers
3871// ---------------------------------------------------------------------------
3872//
3873// Thin facade only (core tables and integrators live in `nucleide-nuclei` /
3874// `nucleide-material` / `nucleide-depletion`; nothing duplicated here):
3875//
3876// - `deplete_series` wraps the core `integrate` series (`predictor`/`cecm`/
3877//   `cf4`), omitting the core `t = 0` row so there is one output per step.
3878// - `simple_xs` / `scattering_length` / `decay_energy` / `decay_heat` are
3879//   thin wrappers over the vendored TSV tables + material analytics.
3880// - `MeshTally::to_list` / `totals_list` are plain-copy helpers alongside the
3881//   landed zero-copy NumPy bridge (`result_array()` / `totals_array()`):
3882//   `numpy = "0.28"` is a bindings-only
3883//   dependency (abi3-py310 inherited from the workspace PyO3).
3884
3885/// Supported `deplete_series` integrators (core `Integrator` variants).
3886fn parse_integrator(name: &str) -> PyResult<nucleide_depletion::Integrator> {
3887    use nucleide_depletion::Integrator as I;
3888    if name.eq_ignore_ascii_case("predictor") {
3889        return Ok(I::Predictor);
3890    }
3891    if name.eq_ignore_ascii_case("cecm") {
3892        return Ok(I::Cecm);
3893    }
3894    if name.eq_ignore_ascii_case("cf4") {
3895        return Ok(I::Cf4);
3896    }
3897    Err(PyValueError::new_err(format!(
3898        "unsupported integrator `{name}` (supported: predictor, cecm, cf4)"
3899    )))
3900}
3901
3902/// Solve a multi-step depletion series with the chosen core integrator.
3903///
3904/// Thin wrapper over `nucleide_depletion::integrate`: one [`Step`] per `dt`
3905/// (per-step `rates`/`rates_list`, `None` meaning decay-only), `n0` keyed by
3906/// nuclide name. `method` selects the solver kernel (`"cram16"`,
3907/// `"cram48"`, `"bateman"`, `"bateman_hp"`, default `"cram48"` — an
3908/// explicitly non-default `method` overrides `order`; Bateman steps with
3909/// live rates fall back to CRAM-48). Returns a dict with `times`
3910/// (cumulative seconds, one entry per step — the core `t = 0` initial row is
3911/// omitted so `atoms[k]` matches a single `deplete` call over `dts[k]`),
3912/// `atoms`, `activity` ([Bq]), and `decay_heat` ([W] per nuclide via the
3913/// shared chain → ENDF/B-VII.1 → 0.0 energy resolution).
3914#[pyfunction]
3915#[pyo3(signature = (chain, n0, dts, rates=None, rates_list=None, integrator="predictor", order=48, method="cram48"))]
3916#[allow(clippy::too_many_arguments)]
3917fn deplete_series(
3918    chain: &PyChain,
3919    n0: BTreeMap<String, f64>,
3920    dts: Vec<f64>,
3921    rates: Option<RateMap>,
3922    rates_list: Option<Vec<Option<RateMap>>>,
3923    integrator: &str,
3924    order: u8,
3925    method: &str,
3926) -> PyResult<Py<PyAny>> {
3927    use nucleide_depletion::{DepletionSystem, ReactionRates, Step};
3928    let integrator = parse_integrator(integrator)?;
3929    let method = resolve_method(order, method)?;
3930    if let Some(list) = &rates_list {
3931        if list.len() != dts.len() {
3932            return Err(PyValueError::new_err(format!(
3933                "rates_list has {} entries but dts has {}",
3934                list.len(),
3935                dts.len()
3936            )));
3937        }
3938    }
3939    if dts.is_empty() {
3940        return Err(PyValueError::new_err("dts must not be empty"));
3941    }
3942    // Atom vector in chain order; unknown names fail loudly like `deplete`.
3943    let mut n0_vec = vec![0.0; chain.inner.len()];
3944    for (name, value) in &n0 {
3945        let idx = chain.inner.index_of(name).ok_or_else(|| {
3946            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
3947        })?;
3948        n0_vec[idx] = *value;
3949    }
3950    let empty = BTreeMap::new();
3951    let mut steps = Vec::with_capacity(dts.len());
3952    for (i, dt) in dts.iter().enumerate() {
3953        let step_rates = rates_list
3954            .as_ref()
3955            .and_then(|list| list[i].as_ref())
3956            .or(rates.as_ref())
3957            .unwrap_or(&empty);
3958        let rs = split_rates(step_rates, &chain.inner)?;
3959        steps.push(Step::new(*dt, rs));
3960    }
3961    // Template system: `integrate` rebuilds the matrix per step from the
3962    // chain + step rates; the template's own rates are unused.
3963    let template = DepletionSystem::build((*chain.inner).clone(), &ReactionRates::new())
3964        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3965    // NOTE: plain (GIL held) call by design, matching the other CRAM
3966    // bindings; batch sizes here are small.
3967    let series =
3968        nucleide_depletion::integrate_with_method(&template, &n0_vec, &steps, integrator, method)
3969            .map_err(|e| PyValueError::new_err(e.to_string()))?;
3970    let names: Vec<&str> = template
3971        .chain
3972        .nuclides
3973        .iter()
3974        .map(|nuc| nuc.name.as_str())
3975        .collect();
3976    let keyed = |rows: &[Vec<f64>]| -> Vec<BTreeMap<String, f64>> {
3977        rows.iter()
3978            .map(|row| {
3979                names
3980                    .iter()
3981                    .zip(row)
3982                    .map(|(name, v)| ((*name).to_string(), *v))
3983                    .collect()
3984            })
3985            .collect()
3986    };
3987    // Skip the t = 0 initial row: one output entry per requested step.
3988    let atoms = keyed(&series.atoms[1..]);
3989    let activity = keyed(&series.activity[1..]);
3990    let decay_heat = keyed(&series.decay_heat[1..]);
3991    let times = series.times[1..].to_vec();
3992    Ok(Python::attach(|py| {
3993        use pyo3::types::PyDict;
3994        let out = PyDict::new(py);
3995        out.set_item("times", &times).ok();
3996        out.set_item("atoms", &atoms).ok();
3997        out.set_item("activity", &activity).ok();
3998        out.set_item("decay_heat", &decay_heat).ok();
3999        out.into_any().unbind()
4000    }))
4001}
4002
4003/// Thermal/fast cross sections [barn] for a nuclide name.
4004///
4005/// Screening-level values from the `nucleide-nuclei` table (thermal 2200 m/s
4006/// total + 14-MeV total); `None` for nuclides outside the table.
4007#[pyfunction]
4008fn simple_xs(name: &str) -> PyResult<Option<(f64, f64)>> {
4009    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4010    Ok(nucleide_nuclei::data::simple_xs_by_name(name))
4011}
4012
4013/// Coherent scattering length [fm] for a nuclide name.
4014///
4015/// First element of the `nucleide-nuclei` (coherent, incoherent) pair;
4016/// `None` for nuclides outside the table.
4017#[pyfunction]
4018fn scattering_length(name: &str) -> PyResult<Option<f64>> {
4019    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4020    Ok(nucleide_nuclei::data::scattering_length_by_name(name).map(|(b_coh, _)| b_coh))
4021}
4022
4023/// Mean decay energy per disintegration [MeV] for a nuclide name.
4024///
4025/// Screening-level placeholder values (NOT ENSDF); `None` when unknown.
4026#[pyfunction]
4027fn decay_energy(name: &str) -> PyResult<Option<f64>> {
4028    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4029    Ok(nucleide_nuclei::data::decay_energy_mev_by_name(name))
4030}
4031
4032/// Evaluated decay branches for a nuclide name.
4033///
4034/// One `(progeny GNDS name, branching fraction, mode)` tuple per kept
4035/// ENDF/B-VIII.0 branch (SF/fission branches dropped); empty when the
4036/// nuclide has no branch rows (stable nuclides).
4037#[pyfunction]
4038fn decay_branches(name: &str) -> PyResult<Vec<(String, f64, String)>> {
4039    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4040    Ok(nucleide_nuclei::data::decay_branches_by_name(name)
4041        .unwrap_or_default()
4042        .into_iter()
4043        .map(|b| {
4044            (
4045                nucleide_nuclei::NuclideId::from_nucid(b.progeny).to_name(),
4046                b.branching_fraction,
4047                b.mode.as_str().to_string(),
4048            )
4049        })
4050        .collect())
4051}
4052
4053/// Branching fraction from parent to progeny (GNDS names), if tabulated.
4054///
4055/// Evaluated ENDF/B-VIII.0 value, verbatim; `None` when the branch is
4056/// absent (including dropped SF branches). Named apart from the
4057/// chain-scoped `branching_fraction` (which takes a chain argument).
4058#[pyfunction]
4059fn decay_branch_fraction(parent: &str, progeny: &str) -> PyResult<Option<f64>> {
4060    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4061    NuclideId::from_name(progeny).map_err(wrap_nucid_err)?;
4062    Ok(nucleide_nuclei::data::branching_fraction_by_name(
4063        parent, progeny,
4064    ))
4065}
4066
4067/// Normalize a nuclide name in any accepted dialect to canonical GNDS form.
4068///
4069/// Accepts symbol-first (`Pu241`, `Pu-241`, `Ba137m`), mass-first (`241Pu`,
4070/// `40K`), and isomer suffix letters (`Ir-192n` → second isomer); see
4071/// `nucleide_nuclei::dialects::normalize_nuclide_name`.
4072#[pyfunction]
4073fn normalize_nuclide(name: &str) -> PyResult<String> {
4074    Ok(nucleide_nuclei::dialects::normalize_nuclide_name(name)
4075        .map_err(|e| PyValueError::new_err(e.to_string()))?
4076        .to_name())
4077}
4078
4079/// Decay heat [W] of a composition dict ({nuclide name: grams}).
4080///
4081/// Screening-level estimate via `Material::total_decay_heat` (Ame2020 masses,
4082/// ENDF/B-VIII.0 decay constants, placeholder decay energies). Stable
4083/// nuclides (known mass, no decay constant) contribute 0. Errors when a
4084/// nuclide lacks mass data, or a radioactive nuclide lacks energy data.
4085#[pyfunction]
4086fn decay_heat(comp: BTreeMap<String, f64>) -> PyResult<f64> {
4087    let mat = comp_to_material(comp)?;
4088    let analytics = nucleide_material::Analytics {
4089        masses: &nucleide_material::Ame2020,
4090        decays: &nucleide_material::ChainDecays,
4091    };
4092    mat.total_decay_heat(&analytics, &nucleide_material::DecayEnergies)
4093        .map_err(|e| PyValueError::new_err(e.to_string()))
4094}
4095
4096fn parse_dose_pathway(s: &str) -> PyResult<nucleide_material::DosePathway> {
4097    nucleide_material::DosePathway::parse(s).ok_or_else(|| {
4098        PyValueError::new_err(format!(
4099            "unknown dose pathway `{s}` (supported: air, soil, ingest, inhale)"
4100        ))
4101    })
4102}
4103
4104fn parse_dose_source(s: &str) -> PyResult<nucleide_material::DoseSource> {
4105    nucleide_material::DoseSource::parse(s).ok_or_else(|| {
4106        PyValueError::new_err(format!(
4107            "unknown dose source `{s}` (supported: EPA, DOE, GENII)"
4108        ))
4109    })
4110}
4111
4112/// Raw dose factor for a nuclide name, pathway, and source.
4113///
4114/// Pathway is one of `air`/`soil`/`ingest`/`inhale` (`ext_air`/`ext_soil`
4115/// aliases accepted); source is one of `EPA`/`DOE`/`GENII` (default `EPA`,
4116/// matching PyNE source id 0). Returns `None` when the nuclide has no row;
4117/// GENII/DOE air resolve to `-1.0` (PyNE missing-air sentinel).
4118#[pyfunction]
4119#[pyo3(signature = (name, pathway, source="EPA"))]
4120fn dose_factor(name: &str, pathway: &str, source: &str) -> PyResult<Option<f64>> {
4121    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4122    let p = parse_dose_pathway(pathway)?;
4123    let s = parse_dose_source(source)?;
4124    Ok(nucleide_nuclei::data::dose_factor_by_name(name, p, s))
4125}
4126
4127/// Total dose per gram of a composition dict ({nuclide name: grams}).
4128///
4129/// Thin wrapper over `Material::total_dose_per_g` (Ame2020 masses,
4130/// ENDF/B-VIII.0 decay constants, HNF-5636/PyNE dose factors). Pathway is one
4131/// of `air`/`soil`/`ingest`/`inhale`; source is `EPA`/`DOE`/`GENII` (default
4132/// `EPA`). Units follow the table: air `mrem/h per g per m^3`, soil
4133/// `mrem/h per g per m^2`, ingest/inhale `mrem per g`. Screening-level only —
4134/// not for safety decisions. Stable nuclides (known mass, no decay constant)
4135/// contribute 0 without a dose-factor lookup. Errors when a nuclide lacks
4136/// mass data, or a radioactive nuclide lacks dose data (including `-1`
4137/// GENII/DOE air sentinels).
4138#[pyfunction]
4139#[pyo3(signature = (comp, pathway, source="EPA"))]
4140fn dose_per_g(comp: BTreeMap<String, f64>, pathway: &str, source: &str) -> PyResult<f64> {
4141    let mat = comp_to_material(comp)?;
4142    let analytics = nucleide_material::Analytics {
4143        masses: &nucleide_material::Ame2020,
4144        decays: &nucleide_material::ChainDecays,
4145    };
4146    let p = parse_dose_pathway(pathway)?;
4147    let s = parse_dose_source(source)?;
4148    mat.total_dose_per_g(&analytics, &nucleide_material::DoseFactors, p, s)
4149        .map_err(|e| PyValueError::new_err(e.to_string()))
4150}
4151
4152/// Split a composition dict into product and tails dicts by per-nuclide
4153/// separation efficiency.
4154///
4155/// `comp` maps nuclide names to grams; `effs` maps nuclide names to
4156/// efficiencies in `[0, 1]` (unlisted nuclides go entirely to tails).
4157/// Returns `(product, tails)` with per-nuclide mass conserved. Thin wrapper
4158/// over `Material::separate`.
4159#[pyfunction]
4160#[allow(clippy::type_complexity)]
4161fn separate_material(
4162    comp: BTreeMap<String, f64>,
4163    effs: BTreeMap<String, f64>,
4164) -> PyResult<(BTreeMap<String, f64>, BTreeMap<String, f64>)> {
4165    let mat = comp_to_material(comp)?;
4166    let mut table = Vec::with_capacity(effs.len());
4167    for (name, eff) in &effs {
4168        let id = NuclideId::from_name(name)
4169            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
4170        table.push((id, *eff));
4171    }
4172    let (product, tails) = mat
4173        .separate(&table)
4174        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4175    let named =
4176        |m: nucleide_material::Material| m.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
4177    Ok((named(product), named(tails)))
4178}
4179
4180/// Blend composition dicts at fixed ratios with explicit normalization.
4181///
4182/// `parts` holds `(comp, ratio)` pairs; ratios are normalized by their sum
4183/// and the output is the weighted average. Errors on empty, all-zero, or
4184/// negative ratios (never a silent uniform split). Thin wrapper over
4185/// `Material::blend`.
4186#[pyfunction]
4187fn blend_material(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
4188    let mats: Vec<nucleide_material::Material> = parts
4189        .iter()
4190        .map(|(comp, _)| comp_to_material(comp.clone()))
4191        .collect::<PyResult<_>>()?;
4192    let refs: Vec<(&nucleide_material::Material, f64)> =
4193        mats.iter().zip(parts.iter().map(|(_, r)| *r)).collect();
4194    let out = nucleide_material::Material::blend(&refs)
4195        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4196    Ok(out.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect())
4197}
4198
4199/// One-sided upper Page CUSUM change detector with Welford statistics.
4200///
4201/// Thin stateful wrapper over `nucleide_material::Cusum`: `update(x)`
4202/// feeds one observation and returns the alarm status; `status()` reads it
4203/// without consuming input; `statistic()` reads the CUSUM value;
4204/// `reset()` drops all observations (tuning kept). Non-finite inputs to
4205/// `update` are ignored.
4206#[pyclass(name = "Cusum")]
4207struct PyCusum {
4208    inner: nucleide_material::Cusum,
4209}
4210
4211#[pymethods]
4212impl PyCusum {
4213    /// Build a detector (`ref_shift_k = 0.5`, `alarm_h = 4.0`,
4214    /// `startup = 10` by default).
4215    #[new]
4216    #[pyo3(signature = (ref_shift_k=0.5, alarm_h=4.0, startup=10))]
4217    fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> PyResult<Self> {
4218        nucleide_material::Cusum::new(ref_shift_k, alarm_h, startup)
4219            .map(|inner| Self { inner })
4220            .map_err(|e| PyValueError::new_err(e.to_string()))
4221    }
4222
4223    /// Feed one observation; returns the resulting alarm status.
4224    fn update(&mut self, x: f64) -> bool {
4225        self.inner.update(x)
4226    }
4227
4228    /// Whether the detector is currently alarmed.
4229    fn status(&self) -> bool {
4230        self.inner.status()
4231    }
4232
4233    /// Current CUSUM statistic (`>= 0`).
4234    fn statistic(&self) -> f64 {
4235        self.inner.statistic()
4236    }
4237
4238    /// Running observation count.
4239    fn count(&self) -> usize {
4240        self.inner.count()
4241    }
4242
4243    /// Running mean of the observations seen so far.
4244    fn mean(&self) -> f64 {
4245        self.inner.mean()
4246    }
4247
4248    /// Running sample variance (`0` with fewer than 2 points).
4249    fn variance(&self) -> f64 {
4250        self.inner.variance()
4251    }
4252
4253    /// Running sample standard deviation.
4254    fn std(&self) -> f64 {
4255        self.inner.std()
4256    }
4257
4258    /// Drop all observations; tuning parameters are kept.
4259    fn reset(&mut self) {
4260        self.inner.reset();
4261    }
4262}
4263
4264// ---------------------------------------------------------------------------
4265// 0.3.0 Tier 1: deck round-trip, decay inventories, ARMI dialects, checks
4266// ---------------------------------------------------------------------------
4267
4268/// A parsed MCNP input deck with format-preserving write-back.
4269#[pyclass(name = "DeckProblem")]
4270struct PyDeckProblem {
4271    inner: std::sync::Mutex<nucleide_mcnp_io::problem::DeckProblem>,
4272}
4273
4274fn deck_cell_dict(cell: &nucleide_mcnp_io::cell::CellCard) -> BTreeMap<String, String> {
4275    let mut d = BTreeMap::new();
4276    d.insert("num".to_string(), cell.num.to_string());
4277    d.insert("mat".to_string(), cell.mat.to_string());
4278    d.insert(
4279        "dens".to_string(),
4280        cell.dens.map(|v| v.to_string()).unwrap_or_default(),
4281    );
4282    d.insert("geom".to_string(), cell.geom.render());
4283    d.insert("params".to_string(), cell.params.join(" "));
4284    d
4285}
4286
4287#[pymethods]
4288impl PyDeckProblem {
4289    /// Parse a deck from text.
4290    #[staticmethod]
4291    fn loads(text: &str) -> PyResult<Self> {
4292        nucleide_mcnp_io::problem::parse_deck(text)
4293            .map(|inner| Self {
4294                inner: std::sync::Mutex::new(inner),
4295            })
4296            .map_err(|e| PyValueError::new_err(e.to_string()))
4297    }
4298
4299    /// Message (first) line.
4300    #[getter]
4301    fn message(&self) -> PyResult<String> {
4302        Ok(self
4303            .inner
4304            .lock()
4305            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4306            .message
4307            .clone())
4308    }
4309
4310    /// Title card (second line).
4311    #[getter]
4312    fn title(&self) -> PyResult<String> {
4313        Ok(self
4314            .inner
4315            .lock()
4316            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4317            .title
4318            .clone())
4319    }
4320
4321    /// Cell cards as `{num, mat, dens, geom, params}` dicts (`dens` is `""`
4322    /// for void cells).
4323    #[getter]
4324    fn cells(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4325        Ok(self
4326            .inner
4327            .lock()
4328            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4329            .cells
4330            .iter()
4331            .map(deck_cell_dict)
4332            .collect())
4333    }
4334
4335    /// Surface cards as `{num, reflecting, transform, periodic, kind, coeffs}`
4336    /// dicts (`transform`/`periodic` are `""` when absent).
4337    #[getter]
4338    fn surfs(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4339        Ok(self
4340            .inner
4341            .lock()
4342            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4343            .surfs
4344            .iter()
4345            .map(|s| {
4346                let mut d = BTreeMap::new();
4347                d.insert("num".to_string(), s.num.to_string());
4348                d.insert("reflecting".to_string(), s.reflecting.to_string());
4349                d.insert(
4350                    "transform".to_string(),
4351                    s.transform.map(|v| v.to_string()).unwrap_or_default(),
4352                );
4353                d.insert(
4354                    "periodic".to_string(),
4355                    s.periodic.map(|v| v.to_string()).unwrap_or_default(),
4356                );
4357                d.insert("kind".to_string(), s.kind.keyword().to_string());
4358                d.insert(
4359                    "coeffs".to_string(),
4360                    s.coeffs
4361                        .iter()
4362                        .map(|v| v.to_string())
4363                        .collect::<Vec<_>>()
4364                        .join(" "),
4365                );
4366                d
4367            })
4368            .collect())
4369    }
4370
4371    /// Material numbers in file order.
4372    #[getter]
4373    fn material_numbers(&self) -> PyResult<Vec<u32>> {
4374        Ok(self
4375            .inner
4376            .lock()
4377            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4378            .materials
4379            .iter()
4380            .map(|m| m.number)
4381            .collect())
4382    }
4383
4384    /// Data-card names in file order (`MODE`, `M1`, `KCODE`, ...).
4385    #[getter]
4386    fn data_names(&self) -> PyResult<Vec<String>> {
4387        Ok(self
4388            .inner
4389            .lock()
4390            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4391            .data
4392            .iter()
4393            .map(|d| d.name.clone())
4394            .collect())
4395    }
4396
4397    /// Serialize back to MCNP input text (byte-identical when unedited).
4398    fn dumps(&self) -> PyResult<String> {
4399        let guard = self
4400            .inner
4401            .lock()
4402            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
4403        Ok(nucleide_mcnp_io::problem::write_deck(&guard))
4404    }
4405
4406    /// Set a cell's density (re-renders that card canonically).
4407    fn set_cell_density(&self, cell: u32, dens: f64) -> PyResult<()> {
4408        self.inner
4409            .lock()
4410            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4411            .set_cell_density(cell, dens)
4412            .map_err(|e| PyValueError::new_err(e.to_string()))
4413    }
4414
4415    /// Set a cell's material number (re-renders that card canonically).
4416    fn set_cell_material(&self, cell: u32, mat: u32) -> PyResult<()> {
4417        self.inner
4418            .lock()
4419            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4420            .set_cell_material(cell, mat)
4421            .map_err(|e| PyValueError::new_err(e.to_string()))
4422    }
4423
4424    /// Typed `MODE` card as `{particles}` (`particles` is space-joined).
4425    #[getter]
4426    fn mode(&self) -> PyResult<BTreeMap<String, String>> {
4427        let mode = self
4428            .inner
4429            .lock()
4430            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4431            .mode()
4432            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4433        let mut d = BTreeMap::new();
4434        d.insert("particles".to_string(), mode.particles.join(" "));
4435        Ok(d)
4436    }
4437
4438    /// Typed `TRn` cards as `{number, displacement, rotation, in_degrees,
4439    /// main_to_aux, hidden}` dicts (vectors are space-joined).
4440    #[getter]
4441    fn transforms(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4442        let transforms = self
4443            .inner
4444            .lock()
4445            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4446            .transforms()
4447            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4448        Ok(transforms
4449            .iter()
4450            .map(|t| {
4451                let mut d = BTreeMap::new();
4452                d.insert("number".to_string(), t.number.to_string());
4453                d.insert(
4454                    "displacement".to_string(),
4455                    t.displacement
4456                        .iter()
4457                        .map(|v| v.to_string())
4458                        .collect::<Vec<_>>()
4459                        .join(" "),
4460                );
4461                d.insert(
4462                    "rotation".to_string(),
4463                    t.rotation
4464                        .iter()
4465                        .map(|v| v.to_string())
4466                        .collect::<Vec<_>>()
4467                        .join(" "),
4468                );
4469                d.insert("in_degrees".to_string(), t.is_in_degrees.to_string());
4470                d.insert("main_to_aux".to_string(), t.is_main_to_aux.to_string());
4471                d.insert("hidden".to_string(), t.hidden.to_string());
4472                d
4473            })
4474            .collect())
4475    }
4476
4477    /// Auto-created universes as `{number, cells, not_truncated}` dicts
4478    /// (cell lists are space-joined).
4479    #[getter]
4480    fn universes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4481        let universes = self
4482            .inner
4483            .lock()
4484            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4485            .universes()
4486            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4487        Ok(universes
4488            .iter()
4489            .map(|u| {
4490                let mut d = BTreeMap::new();
4491                d.insert("number".to_string(), u.number.to_string());
4492                d.insert(
4493                    "cells".to_string(),
4494                    u.cells
4495                        .iter()
4496                        .map(|v| v.to_string())
4497                        .collect::<Vec<_>>()
4498                        .join(" "),
4499                );
4500                d.insert(
4501                    "not_truncated".to_string(),
4502                    u.not_truncated
4503                        .iter()
4504                        .map(|v| v.to_string())
4505                        .collect::<Vec<_>>()
4506                        .join(" "),
4507                );
4508                d
4509            })
4510            .collect())
4511    }
4512
4513    /// Cell `LAT` assignments as `{cell, lattice}` dicts.
4514    #[getter]
4515    fn lattices(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4516        let lattices = self
4517            .inner
4518            .lock()
4519            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4520            .lattices()
4521            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4522        Ok(lattices
4523            .iter()
4524            .map(|l| {
4525                let mut d = BTreeMap::new();
4526                d.insert("cell".to_string(), l.cell.to_string());
4527                d.insert("lattice".to_string(), l.lattice.to_string());
4528                d
4529            })
4530            .collect())
4531    }
4532
4533    /// Cell `FILL` assignments as `{cell, kind, universe, min_index,
4534    /// max_index, universes, transform, hidden_transform, in_degrees}` dicts
4535    /// (`kind` is `single` or `matrix`; matrix empties render as `-`).
4536    #[getter]
4537    fn fills(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4538        use nucleide_mcnp_io::semantic::{FillTarget, FillTransform};
4539        let fills = self
4540            .inner
4541            .lock()
4542            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4543            .fills()
4544            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4545        Ok(fills
4546            .iter()
4547            .map(|f| {
4548                let mut d = BTreeMap::new();
4549                d.insert("cell".to_string(), f.cell.to_string());
4550                match &f.target {
4551                    FillTarget::Single(u) => {
4552                        d.insert("kind".to_string(), "single".to_string());
4553                        d.insert("universe".to_string(), u.to_string());
4554                        d.insert("min_index".to_string(), String::new());
4555                        d.insert("max_index".to_string(), String::new());
4556                        d.insert("universes".to_string(), String::new());
4557                    }
4558                    FillTarget::Matrix {
4559                        min_index,
4560                        max_index,
4561                        universes,
4562                    } => {
4563                        d.insert("kind".to_string(), "matrix".to_string());
4564                        d.insert("universe".to_string(), String::new());
4565                        d.insert(
4566                            "min_index".to_string(),
4567                            min_index
4568                                .iter()
4569                                .map(|v| v.to_string())
4570                                .collect::<Vec<_>>()
4571                                .join(" "),
4572                        );
4573                        d.insert(
4574                            "max_index".to_string(),
4575                            max_index
4576                                .iter()
4577                                .map(|v| v.to_string())
4578                                .collect::<Vec<_>>()
4579                                .join(" "),
4580                        );
4581                        d.insert(
4582                            "universes".to_string(),
4583                            universes
4584                                .iter()
4585                                .map(|u| {
4586                                    u.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
4587                                })
4588                                .collect::<Vec<_>>()
4589                                .join(" "),
4590                        );
4591                    }
4592                }
4593                match &f.transform {
4594                    None => {
4595                        d.insert("transform".to_string(), String::new());
4596                        d.insert("hidden_transform".to_string(), String::new());
4597                    }
4598                    Some(FillTransform::Reference(n)) => {
4599                        d.insert("transform".to_string(), n.to_string());
4600                        d.insert("hidden_transform".to_string(), String::new());
4601                    }
4602                    Some(FillTransform::Hidden(t)) => {
4603                        d.insert("transform".to_string(), String::new());
4604                        let mut coords: Vec<String> =
4605                            t.displacement.iter().map(|v| v.to_string()).collect();
4606                        coords.extend(t.rotation.iter().map(|v| v.to_string()));
4607                        d.insert("hidden_transform".to_string(), coords.join(" "));
4608                    }
4609                }
4610                d.insert("in_degrees".to_string(), f.in_degrees.to_string());
4611                d
4612            })
4613            .collect())
4614    }
4615
4616    /// Cell importance entries as `{cell, particle, value}` dicts.
4617    #[getter]
4618    fn importances(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4619        let importances = self
4620            .inner
4621            .lock()
4622            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4623            .importances()
4624            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4625        Ok(importances
4626            .iter()
4627            .map(|v| {
4628                let mut d = BTreeMap::new();
4629                d.insert("cell".to_string(), v.cell.to_string());
4630                d.insert("particle".to_string(), v.particle.clone());
4631                d.insert("value".to_string(), v.value.to_string());
4632                d
4633            })
4634            .collect())
4635    }
4636
4637    /// Manual cell volumes as `{cell, volume}` dicts.
4638    #[getter]
4639    fn volumes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4640        let volumes = self
4641            .inner
4642            .lock()
4643            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4644            .volumes()
4645            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4646        Ok(volumes
4647            .iter()
4648            .map(|v| {
4649                let mut d = BTreeMap::new();
4650                d.insert("cell".to_string(), v.cell.to_string());
4651                d.insert("volume".to_string(), v.volume.to_string());
4652                d
4653            })
4654            .collect())
4655    }
4656
4657    /// Typed tallies as `{number, type, particles, entries, fm, e_bins}` dicts
4658    /// (lists are space-joined, absent groups are `""`).
4659    #[getter]
4660    fn tallies(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
4661        let tallies = self
4662            .inner
4663            .lock()
4664            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4665            .tallies()
4666            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4667        Ok(tallies
4668            .iter()
4669            .map(|t| {
4670                let mut d = BTreeMap::new();
4671                d.insert("number".to_string(), t.number.to_string());
4672                d.insert("type".to_string(), t.tally_type.to_string());
4673                d.insert("particles".to_string(), t.particles.join(","));
4674                d.insert("entries".to_string(), t.entries.join(" "));
4675                d.insert("fm".to_string(), t.fm.clone().unwrap_or_default().join(" "));
4676                d.insert(
4677                    "e_bins".to_string(),
4678                    t.e_bins.clone().unwrap_or_default().join(" "),
4679                );
4680                d
4681            })
4682            .collect())
4683    }
4684
4685    /// Validate every L3 semantic rule (duplicate numbers, dangling links,
4686    /// redundant definitions, write-time state, lattice/fill cross-checks).
4687    fn validate(&self) -> PyResult<()> {
4688        self.inner
4689            .lock()
4690            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4691            .validate()
4692            .map_err(|e| PyValueError::new_err(e.to_string()))
4693    }
4694
4695    /// Non-fatal validation notes (particle/mode mismatches).
4696    fn validation_notes(&self) -> PyResult<Vec<String>> {
4697        Ok(self
4698            .inner
4699            .lock()
4700            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4701            .validation_notes())
4702    }
4703
4704    /// Set the `MODE` card particles (re-renders that card canonically).
4705    fn set_mode(&self, particles: Vec<String>) -> PyResult<()> {
4706        self.inner
4707            .lock()
4708            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4709            .set_mode(particles)
4710            .map_err(|e| PyValueError::new_err(e.to_string()))
4711    }
4712
4713    /// Set a cell's universe (`not_truncated` writes `U=-n`).
4714    fn set_cell_universe(&self, cell: u32, universe: u32, not_truncated: bool) -> PyResult<()> {
4715        self.inner
4716            .lock()
4717            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4718            .set_cell_universe(cell, universe, not_truncated)
4719            .map_err(|e| PyValueError::new_err(e.to_string()))
4720    }
4721
4722    /// Set (`1`/`2`) or clear (`None`) a cell's lattice.
4723    fn set_cell_lattice(&self, cell: u32, lattice: Option<u8>) -> PyResult<()> {
4724        self.inner
4725            .lock()
4726            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4727            .set_cell_lattice(cell, lattice)
4728            .map_err(|e| PyValueError::new_err(e.to_string()))
4729    }
4730
4731    /// Set a cell's fill to a single universe.
4732    fn set_cell_fill(&self, cell: u32, universe: u32) -> PyResult<()> {
4733        self.inner
4734            .lock()
4735            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4736            .set_cell_fill(cell, universe)
4737            .map_err(|e| PyValueError::new_err(e.to_string()))
4738    }
4739}
4740
4741/// Parse an MCNP input deck file into a [`PyDeckProblem`].
4742#[pyfunction]
4743fn read_deck(path: &str) -> PyResult<PyDeckProblem> {
4744    nucleide_mcnp_io::problem::parse_deck_file(path)
4745        .map(|inner| PyDeckProblem {
4746            inner: std::sync::Mutex::new(inner),
4747        })
4748        .map_err(|e| PyValueError::new_err(e.to_string()))
4749}
4750
4751/// Parse MCNP input deck text into a [`PyDeckProblem`].
4752#[pyfunction]
4753fn parse_deck(text: &str) -> PyResult<PyDeckProblem> {
4754    PyDeckProblem::loads(text)
4755}
4756
4757/// A unit-aware decay inventory over a depletion chain.
4758#[pyclass(name = "Inventory")]
4759struct PyInventory {
4760    chain: std::sync::Arc<nucleide_depletion::Chain>,
4761    atoms: BTreeMap<String, f64>,
4762}
4763
4764fn inventory_sys(
4765    chain: &nucleide_depletion::Chain,
4766    rates: &RateMap,
4767) -> PyResult<nucleide_depletion::DepletionSystem> {
4768    let rs = split_rates(rates, chain)?;
4769    nucleide_depletion::DepletionSystem::build(chain.clone(), &rs)
4770        .map_err(|e| PyValueError::new_err(e.to_string()))
4771}
4772
4773fn parse_quantity_unit(unit: &str) -> PyResult<nucleide_depletion::QuantityUnit> {
4774    nucleide_depletion::QuantityUnit::from_str(unit)
4775        .map_err(|e| PyValueError::new_err(format!("{e:?}")))
4776}
4777
4778#[pymethods]
4779impl PyInventory {
4780    /// Build from quantities in `units` (atom counts, `Bq`/`Ci` activity,
4781    /// `g`/`kg` mass, `mol`, ... — see `QuantityUnit`).
4782    #[new]
4783    #[pyo3(signature = (chain, comp, units="atoms"))]
4784    fn new(chain: &PyChain, comp: BTreeMap<String, f64>, units: &str) -> PyResult<Self> {
4785        let unit = parse_quantity_unit(units)?;
4786        let sys = inventory_sys(&chain.inner, &BTreeMap::new())?;
4787        let inv = nucleide_depletion::DecayInventory::from_units(&comp, unit, &sys)
4788            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4789        Ok(Self {
4790            chain: chain.inner.clone(),
4791            atoms: inv.atoms,
4792        })
4793    }
4794
4795    /// Atom counts by nuclide name.
4796    fn numbers(&self) -> BTreeMap<String, f64> {
4797        self.atoms.clone()
4798    }
4799
4800    /// Decay over `dt` in `time_unit` (`s`, `m`, `h`, `d`, `y`); optional
4801    /// one-group `rates` (`"Name:reaction"` keys), CRAM `order`, and solver
4802    /// `method` (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
4803    /// default `"cram48"` — an explicitly non-default `method` overrides
4804    /// `order`). Unlike the decay-only core inventory, this honors `rates`;
4805    /// a Bateman `method` with live rates falls back to CRAM-48.
4806    #[pyo3(signature = (dt, time_unit="s", rates=None, order=48, method="cram48"))]
4807    fn decay(
4808        &self,
4809        dt: f64,
4810        time_unit: &str,
4811        rates: Option<RateMap>,
4812        order: u8,
4813        method: &str,
4814    ) -> PyResult<Self> {
4815        let method = resolve_method(order, method)?;
4816        let unit = nucleide_depletion::inventory::time_unit_from_str(time_unit)
4817            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4818        let seconds = dt * unit.as_seconds();
4819        let empty = BTreeMap::new();
4820        let step_rates = rates.as_ref().unwrap_or(&empty);
4821        let template = inventory_sys(&self.chain, step_rates)?;
4822        // Route through the core series: predictor over one step equals the
4823        // single-kernel solve, and rates/method stay honored.
4824        let steps = vec![nucleide_depletion::Step::new(
4825            seconds,
4826            split_rates(step_rates, &self.chain)?,
4827        )];
4828        let series = nucleide_depletion::integrate_with_method(
4829            &template,
4830            &chain_vec(&self.chain, &self.atoms)?,
4831            &steps,
4832            nucleide_depletion::Integrator::Predictor,
4833            method,
4834        )
4835        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4836        let names: Vec<String> = self.chain.nuclides.iter().map(|n| n.name.clone()).collect();
4837        let atoms = names
4838            .iter()
4839            .zip(series.atoms.last().cloned().unwrap_or_default())
4840            .map(|(n, v)| (n.clone(), v))
4841            .collect();
4842        Ok(Self {
4843            chain: self.chain.clone(),
4844            atoms,
4845        })
4846    }
4847
4848    /// Activity per nuclide in `units`.
4849    fn activities(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
4850        let unit = parse_quantity_unit(units)?;
4851        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
4852        let inv = nucleide_depletion::DecayInventory {
4853            atoms: self.atoms.clone(),
4854        };
4855        inv.activities(&sys, unit)
4856            .map_err(|e| PyValueError::new_err(e.to_string()))
4857    }
4858
4859    /// Mass per nuclide in `units`.
4860    fn masses(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
4861        let unit = parse_quantity_unit(units)?;
4862        let inv = nucleide_depletion::DecayInventory {
4863            atoms: self.atoms.clone(),
4864        };
4865        inv.masses(unit)
4866            .map_err(|e| PyValueError::new_err(e.to_string()))
4867    }
4868
4869    /// Moles per nuclide in `units`.
4870    fn moles(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
4871        let unit = parse_quantity_unit(units)?;
4872        let inv = nucleide_depletion::DecayInventory {
4873            atoms: self.atoms.clone(),
4874        };
4875        inv.moles(unit)
4876            .map_err(|e| PyValueError::new_err(e.to_string()))
4877    }
4878
4879    /// Activity fractions by nuclide name.
4880    fn activity_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
4881        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
4882        let inv = nucleide_depletion::DecayInventory {
4883            atoms: self.atoms.clone(),
4884        };
4885        inv.activity_fractions(&sys)
4886            .map_err(|e| PyValueError::new_err(e.to_string()))
4887    }
4888
4889    /// Mass fractions by nuclide name.
4890    fn mass_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
4891        let inv = nucleide_depletion::DecayInventory {
4892            atoms: self.atoms.clone(),
4893        };
4894        inv.mass_fractions()
4895            .map_err(|e| PyValueError::new_err(e.to_string()))
4896    }
4897
4898    /// Mole fractions by nuclide name.
4899    fn mole_fractions(&self) -> BTreeMap<String, f64> {
4900        nucleide_depletion::DecayInventory {
4901            atoms: self.atoms.clone(),
4902        }
4903        .mole_fractions()
4904    }
4905
4906    /// Human-readable half-lives (`"3.2 d"`, `"stable"`, `"unknown"`).
4907    fn half_lives_readable(&self) -> BTreeMap<String, String> {
4908        nucleide_depletion::DecayInventory {
4909            atoms: self.atoms.clone(),
4910        }
4911        .half_lives_readable()
4912    }
4913
4914    /// Add two inventories (atom counts sum).
4915    fn add(&self, other: &Self) -> Self {
4916        let a = nucleide_depletion::DecayInventory {
4917            atoms: self.atoms.clone(),
4918        };
4919        let b = nucleide_depletion::DecayInventory {
4920            atoms: other.atoms.clone(),
4921        };
4922        Self {
4923            chain: self.chain.clone(),
4924            atoms: a.add(&b).atoms,
4925        }
4926    }
4927
4928    /// Subtract (clamped at zero).
4929    fn sub(&self, other: &Self) -> Self {
4930        let a = nucleide_depletion::DecayInventory {
4931            atoms: self.atoms.clone(),
4932        };
4933        let b = nucleide_depletion::DecayInventory {
4934            atoms: other.atoms.clone(),
4935        };
4936        Self {
4937            chain: self.chain.clone(),
4938            atoms: a.sub(&b).atoms,
4939        }
4940    }
4941
4942    /// Scale by a scalar.
4943    fn mul(&self, scalar: f64) -> Self {
4944        let a = nucleide_depletion::DecayInventory {
4945            atoms: self.atoms.clone(),
4946        };
4947        Self {
4948            chain: self.chain.clone(),
4949            atoms: a.mul(scalar).atoms,
4950        }
4951    }
4952
4953    /// Divide by a scalar.
4954    fn div(&self, scalar: f64) -> Self {
4955        let a = nucleide_depletion::DecayInventory {
4956            atoms: self.atoms.clone(),
4957        };
4958        Self {
4959            chain: self.chain.clone(),
4960            atoms: a.div(scalar).atoms,
4961        }
4962    }
4963
4964    /// Serialize as `nuclide,atoms` CSV rows.
4965    fn to_csv(&self) -> String {
4966        nucleide_depletion::DecayInventory {
4967            atoms: self.atoms.clone(),
4968        }
4969        .to_csv()
4970    }
4971
4972    /// Parse `to_csv` output back into an inventory over `chain`.
4973    #[staticmethod]
4974    fn from_csv(chain: &PyChain, text: &str) -> PyResult<Self> {
4975        // Validate names against the chain (core from_csv is chain-free).
4976        let inv = nucleide_depletion::DecayInventory::from_csv(text)
4977            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4978        for name in inv.atoms.keys() {
4979            if chain.inner.index_of(name).is_none() {
4980                return Err(PyValueError::new_err(format!(
4981                    "unknown nuclide `{name}` for this chain"
4982                )));
4983            }
4984        }
4985        Ok(Self {
4986            chain: chain.inner.clone(),
4987            atoms: inv.atoms,
4988        })
4989    }
4990}
4991
4992/// Atom vector in chain order for an inventory map (unknown names error).
4993fn chain_vec(
4994    chain: &nucleide_depletion::Chain,
4995    atoms: &BTreeMap<String, f64>,
4996) -> PyResult<Vec<f64>> {
4997    let mut vec = vec![0.0; chain.len()];
4998    for (name, value) in atoms {
4999        let idx = chain.index_of(name).ok_or_else(|| {
5000            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
5001        })?;
5002        vec[idx] = *value;
5003    }
5004    Ok(vec)
5005}
5006
5007/// Time-integrated decays per nuclide over one step (chain order → names).
5008#[pyfunction]
5009#[pyo3(signature = (chain, n0, dt, rates=None))]
5010fn cumulative_decays(
5011    chain: &PyChain,
5012    n0: BTreeMap<String, f64>,
5013    dt: f64,
5014    rates: Option<RateMap>,
5015) -> PyResult<BTreeMap<String, f64>> {
5016    let empty = BTreeMap::new();
5017    let sys = inventory_sys(&chain.inner, rates.as_ref().unwrap_or(&empty))?;
5018    let vec = chain_vec(&chain.inner, &n0)?;
5019    let out = nucleide_depletion::cumulative_decays(&sys, &vec, dt)
5020        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5021    Ok(chain
5022        .inner
5023        .nuclides
5024        .iter()
5025        .zip(out)
5026        .map(|(nuc, v)| (nuc.name.clone(), v))
5027        .collect())
5028}
5029
5030/// `(child, branching_ratio, decay_mode)` triples for a chain nuclide.
5031#[pyfunction]
5032fn progeny(chain: &PyChain, name: &str) -> Vec<(String, f64, String)> {
5033    nucleide_depletion::progeny(&chain.inner, name)
5034}
5035
5036/// Branching fraction from parent to child, if the decay exists.
5037#[pyfunction]
5038fn branching_fraction(chain: &PyChain, parent: &str, child: &str) -> Option<f64> {
5039    nucleide_depletion::branching_fraction(&chain.inner, parent, child)
5040}
5041
5042/// Decay-mode label from parent to child, if the decay exists.
5043#[pyfunction]
5044fn decay_mode(chain: &PyChain, parent: &str, child: &str) -> Option<String> {
5045    nucleide_depletion::decay_mode(&chain.inner, parent, child)
5046}
5047
5048/// `(parent, child, branching_ratio, decay_mode)` edges of a chain.
5049#[pyfunction]
5050fn chain_edges(chain: &PyChain) -> Vec<(String, String, f64, String)> {
5051    nucleide_depletion::chain_edges(&chain.inner)
5052}
5053
5054/// Parse an ARMI nuclide label (`nU235`, `92235`, ...) into a [`PyNuclide`].
5055#[pyfunction]
5056fn armi_to_nucid(name: &str) -> PyResult<PyNuclide> {
5057    nucleide_nuclei::armi::armi_name_to_nucid(name)
5058        .map(|inner| PyNuclide { inner })
5059        .map_err(|e| PyValueError::new_err(e.to_string()))
5060}
5061
5062/// Render a nuclide in ARMI database-label form.
5063#[pyfunction]
5064fn nucid_to_armi(nuclide: &PyNuclide) -> String {
5065    nucleide_nuclei::armi::nucid_to_armi_label(nuclide.inner)
5066}
5067
5068/// Parse an MCC3-style nuclide label into a [`PyNuclide`].
5069#[pyfunction]
5070fn mcc3_to_nucid(name: &str) -> PyResult<PyNuclide> {
5071    nucleide_nuclei::armi::mcc3_to_nucid(name)
5072        .map(|inner| PyNuclide { inner })
5073        .map_err(|e| PyValueError::new_err(e.to_string()))
5074}
5075
5076/// Truncated-label collisions in a composition at DIF3D/MC2 widths.
5077///
5078/// `comp` maps nuclide names to grams; `widths` defaults to `[6, 8]`.
5079/// Returns `[{truncated, width, members}]`.
5080#[pyfunction]
5081#[pyo3(signature = (comp, widths=None))]
5082fn check_labels(
5083    comp: BTreeMap<String, f64>,
5084    widths: Option<Vec<usize>>,
5085) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5086    let mat = comp_to_material(comp)?;
5087    let widths = widths.unwrap_or_else(|| nucleide_material::DEFAULT_WIDTHS.to_vec());
5088    let collisions = nucleide_material::check_labels(&mat, &widths);
5089    Python::attach(|py| {
5090        Ok(collisions
5091            .into_iter()
5092            .map(|c| {
5093                let mut d = BTreeMap::new();
5094                d.insert(
5095                    "truncated".to_string(),
5096                    c.truncated.into_pyobject(py).unwrap().unbind().into_any(),
5097                );
5098                d.insert(
5099                    "width".to_string(),
5100                    c.width.into_pyobject(py).unwrap().unbind().into_any(),
5101                );
5102                let members: Vec<String> = c.members.iter().map(|id| id.to_name()).collect();
5103                d.insert(
5104                    "members".to_string(),
5105                    members.into_pyobject(py).unwrap().unbind().into_any(),
5106                );
5107                d
5108            })
5109            .collect())
5110    })
5111}
5112
5113/// Conservation audit of a composition: `[{kind, detail}]` (empty = clean).
5114#[pyfunction]
5115fn audit_material(comp: BTreeMap<String, f64>) -> PyResult<Vec<BTreeMap<String, String>>> {
5116    let mat = comp_to_material(comp)?;
5117    Ok(nucleide_material::audit(&mat, &nucleide_material::Ame2020)
5118        .into_iter()
5119        .map(|issue| {
5120            let mut d = BTreeMap::new();
5121            d.insert("kind".to_string(), format!("{:?}", issue.kind));
5122            d.insert("detail".to_string(), issue.detail);
5123            d
5124        })
5125        .collect())
5126}
5127
5128/// Emit one composition through all five code dialects (MCNP, Serpent, FLUKA,
5129/// ALARA, PARTISN). Returns `{code: card_text}`.
5130///
5131/// `comp` maps nuclide names to grams; `density` is mass density [g/cm³] for
5132/// dialects that need one (falls back to none — Serpent/FLUKA/PARTISN error
5133/// without it).
5134#[pyfunction]
5135#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5136#[allow(clippy::too_many_arguments)]
5137fn emit_cards(
5138    comp: BTreeMap<String, f64>,
5139    name: &str,
5140    density: Option<f64>,
5141    mcnp_number: u32,
5142    xs_suffix: &str,
5143    serpent_lib: &str,
5144    fluka_fid: u32,
5145    partisn_zone: u32,
5146) -> PyResult<BTreeMap<String, String>> {
5147    let (emitted, _) = emit_drift_inner(
5148        comp,
5149        name,
5150        density,
5151        mcnp_number,
5152        xs_suffix,
5153        serpent_lib,
5154        fluka_fid,
5155        partisn_zone,
5156    )?;
5157    Ok(emitted
5158        .into_iter()
5159        .map(|e| (e.code.to_string(), e.text))
5160        .collect())
5161}
5162
5163/// Mass-drift report for one composition across all five code dialects.
5164/// Returns `[{code, mass_in, mass_out, rel_drift, dropped: [{nuclide, mass,
5165/// reason}], reparsed}]`.
5166#[pyfunction]
5167#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5168#[allow(clippy::too_many_arguments)]
5169fn emit_drift_table(
5170    comp: BTreeMap<String, f64>,
5171    name: &str,
5172    density: Option<f64>,
5173    mcnp_number: u32,
5174    xs_suffix: &str,
5175    serpent_lib: &str,
5176    fluka_fid: u32,
5177    partisn_zone: u32,
5178) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5179    let (_, table) = emit_drift_inner(
5180        comp,
5181        name,
5182        density,
5183        mcnp_number,
5184        xs_suffix,
5185        serpent_lib,
5186        fluka_fid,
5187        partisn_zone,
5188    )?;
5189    drift_table_to_py(table)
5190}
5191
5192fn drift_table_to_py(
5193    table: nucleide_emit::DriftTable,
5194) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5195    Python::attach(|py| {
5196        Ok(table
5197            .rows
5198            .into_iter()
5199            .map(|r| {
5200                let mut d = BTreeMap::new();
5201                d.insert(
5202                    "code".to_string(),
5203                    r.code
5204                        .to_string()
5205                        .into_pyobject(py)
5206                        .unwrap()
5207                        .unbind()
5208                        .into_any(),
5209                );
5210                d.insert(
5211                    "mass_in".to_string(),
5212                    r.mass_in.into_pyobject(py).unwrap().unbind().into_any(),
5213                );
5214                d.insert(
5215                    "mass_out".to_string(),
5216                    r.mass_out.into_pyobject(py).unwrap().unbind().into_any(),
5217                );
5218                d.insert(
5219                    "rel_drift".to_string(),
5220                    r.rel_drift.into_pyobject(py).unwrap().unbind().into_any(),
5221                );
5222                let dropped: Vec<BTreeMap<String, Py<PyAny>>> = r
5223                    .dropped
5224                    .into_iter()
5225                    .map(|x| {
5226                        let mut dd = BTreeMap::new();
5227                        dd.insert(
5228                            "nuclide".to_string(),
5229                            x.id.to_name()
5230                                .into_pyobject(py)
5231                                .unwrap()
5232                                .unbind()
5233                                .into_any(),
5234                        );
5235                        dd.insert(
5236                            "mass".to_string(),
5237                            x.mass.into_pyobject(py).unwrap().unbind().into_any(),
5238                        );
5239                        dd.insert(
5240                            "reason".to_string(),
5241                            x.reason.into_pyobject(py).unwrap().unbind().into_any(),
5242                        );
5243                        dd
5244                    })
5245                    .collect();
5246                d.insert(
5247                    "dropped".to_string(),
5248                    dropped.into_pyobject(py).unwrap().unbind().into_any(),
5249                );
5250                d.insert(
5251                    "reparsed".to_string(),
5252                    pyo3::types::PyBool::new(py, r.reparsed)
5253                        .to_owned()
5254                        .into_any()
5255                        .unbind(),
5256                );
5257                d
5258            })
5259            .collect())
5260    })
5261}
5262
5263#[allow(clippy::too_many_arguments)]
5264fn emit_drift_inner(
5265    comp: BTreeMap<String, f64>,
5266    name: &str,
5267    density: Option<f64>,
5268    mcnp_number: u32,
5269    xs_suffix: &str,
5270    serpent_lib: &str,
5271    fluka_fid: u32,
5272    partisn_zone: u32,
5273) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5274    let mut mat = comp_to_material(comp)?;
5275    mat.set_density(density);
5276    emit_drift_with_mat(
5277        mat,
5278        name,
5279        mcnp_number,
5280        xs_suffix,
5281        serpent_lib,
5282        fluka_fid,
5283        partisn_zone,
5284    )
5285}
5286
5287#[allow(clippy::too_many_arguments)]
5288fn emit_drift_with_mat(
5289    mat: nucleide_material::Material,
5290    name: &str,
5291    mcnp_number: u32,
5292    xs_suffix: &str,
5293    serpent_lib: &str,
5294    fluka_fid: u32,
5295    partisn_zone: u32,
5296) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5297    let mut opts = nucleide_emit::EmitOptions::new(name);
5298    opts.mcnp_number = mcnp_number;
5299    opts.xs_suffix = xs_suffix.to_string();
5300    opts.serpent_lib = serpent_lib.to_string();
5301    opts.fluka_fid = fluka_fid;
5302    opts.partisn_zone = partisn_zone;
5303    nucleide_emit::emit_drift(&mat, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
5304}
5305
5306#[allow(clippy::too_many_arguments)]
5307fn emit_armi_drift_inner(
5308    comp: BTreeMap<String, f64>,
5309    name: &str,
5310    density: Option<f64>,
5311    mcnp_number: u32,
5312    xs_suffix: &str,
5313    serpent_lib: &str,
5314    fluka_fid: u32,
5315    partisn_zone: u32,
5316) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
5317    // `from_armi_mass_fracs` sets the density exactly like `emit_drift_inner`
5318    // (`set_density(density)`), so the material is emission-ready here.
5319    let mat = nucleide_emit::armi::from_armi_mass_fracs(comp, density)
5320        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5321    emit_drift_with_mat(
5322        mat,
5323        name,
5324        mcnp_number,
5325        xs_suffix,
5326        serpent_lib,
5327        fluka_fid,
5328        partisn_zone,
5329    )
5330}
5331
5332/// Emit one ARMI-keyed composition through all five code dialects (MCNP,
5333/// Serpent, FLUKA, ALARA, PARTISN). Returns `{code: card_text}`.
5334///
5335/// `comp` maps ARMI nuclide keys (`nU235`, `92235`, `U-2355`, ...) to grams;
5336/// keys resolve via `nucleide_emit::armi::from_armi_mass_fracs` (elemental
5337/// keys, bare `AM242`, and negative/non-finite masses are `ValueError`s).
5338/// `density` is the hot mass density [g/cm³] for dialects that need one.
5339#[pyfunction]
5340#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5341#[allow(clippy::too_many_arguments)]
5342fn emit_armi_cards(
5343    comp: BTreeMap<String, f64>,
5344    name: &str,
5345    density: Option<f64>,
5346    mcnp_number: u32,
5347    xs_suffix: &str,
5348    serpent_lib: &str,
5349    fluka_fid: u32,
5350    partisn_zone: u32,
5351) -> PyResult<BTreeMap<String, String>> {
5352    let (emitted, _) = emit_armi_drift_inner(
5353        comp,
5354        name,
5355        density,
5356        mcnp_number,
5357        xs_suffix,
5358        serpent_lib,
5359        fluka_fid,
5360        partisn_zone,
5361    )?;
5362    Ok(emitted
5363        .into_iter()
5364        .map(|e| (e.code.to_string(), e.text))
5365        .collect())
5366}
5367
5368/// Mass-drift report for one ARMI-keyed composition across all five code
5369/// dialects. Returns `[{code, mass_in, mass_out, rel_drift, dropped:
5370/// [{nuclide, mass, reason}], reparsed}]`.
5371#[pyfunction]
5372#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
5373#[allow(clippy::too_many_arguments)]
5374fn emit_armi_drift_table(
5375    comp: BTreeMap<String, f64>,
5376    name: &str,
5377    density: Option<f64>,
5378    mcnp_number: u32,
5379    xs_suffix: &str,
5380    serpent_lib: &str,
5381    fluka_fid: u32,
5382    partisn_zone: u32,
5383) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
5384    let (_, table) = emit_armi_drift_inner(
5385        comp,
5386        name,
5387        density,
5388        mcnp_number,
5389        xs_suffix,
5390        serpent_lib,
5391        fluka_fid,
5392        partisn_zone,
5393    )?;
5394    drift_table_to_py(table)
5395}
5396
5397// ---------------------------------------------------------------------------
5398// Point kinetics (thin glue over `nucleide-kinetics`; solver stays in core)
5399// ---------------------------------------------------------------------------
5400
5401/// Parse a reactivity-spec dict into the core [`Reactivity`].
5402///
5403/// `kind` selects the schedule (`"constant"`, `"step"`, `"impulse"`,
5404/// `"ramp"`, `"polyline"`); all reactivities are in Δk and all times in
5405/// seconds. Keys per kind: constant (`rho`); step (`t_step`, `rho_init`,
5406/// `rho_final`); impulse (`t_start`, `t_end`, `rho_init`, `rho_max`); ramp
5407/// (`t_start`, `t_end`, `rho_init`, `rho_rise`, `rho_final`); polyline
5408/// (`times`, `values`).
5409fn parse_reactivity(
5410    spec: &BTreeMap<String, Py<PyAny>>,
5411    py: Python<'_>,
5412) -> PyResult<nucleide_kinetics::Reactivity> {
5413    use nucleide_kinetics::Reactivity as R;
5414    let kind: String = get_str(spec, py, "kind", "reactivity spec needs a `kind`")?;
5415    let num = |key: &str| -> PyResult<f64> { get_num(spec, py, key) };
5416    let vec = |key: &str| -> PyResult<Vec<f64>> { get_vec(spec, py, key) };
5417    let r = match kind.as_str() {
5418        "constant" => R::Constant { rho: num("rho")? },
5419        "step" => R::Step {
5420            t_step: num("t_step")?,
5421            rho_init: num("rho_init")?,
5422            rho_final: num("rho_final")?,
5423        },
5424        "impulse" => R::Impulse {
5425            t_start: num("t_start")?,
5426            t_end: num("t_end")?,
5427            rho_init: num("rho_init")?,
5428            rho_max: num("rho_max")?,
5429        },
5430        "ramp" => R::Ramp {
5431            t_start: num("t_start")?,
5432            t_end: num("t_end")?,
5433            rho_init: num("rho_init")?,
5434            rho_rise: num("rho_rise")?,
5435            rho_final: num("rho_final")?,
5436        },
5437        "polyline" => R::Polyline {
5438            times: vec("times")?,
5439            values: vec("values")?,
5440        },
5441        other => {
5442            return Err(PyValueError::new_err(format!(
5443                "unknown reactivity kind `{other}` (supported: constant, step, impulse, ramp, polyline)"
5444            )))
5445        }
5446    };
5447    r.validate()
5448        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5449    Ok(r)
5450}
5451
5452fn get_str(
5453    spec: &BTreeMap<String, Py<PyAny>>,
5454    py: Python<'_>,
5455    key: &str,
5456    missing: &str,
5457) -> PyResult<String> {
5458    spec.get(key)
5459        .ok_or_else(|| PyValueError::new_err(missing.to_string()))?
5460        .extract::<String>(py)
5461        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a string")))
5462}
5463
5464fn get_num(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<f64> {
5465    spec.get(key)
5466        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
5467        .extract::<f64>(py)
5468        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
5469}
5470
5471fn get_vec(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<Vec<f64>> {
5472    spec.get(key)
5473        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
5474        .extract::<Vec<f64>>(py)
5475        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a list of numbers")))
5476}
5477
5478fn kinetics_params(
5479    betas: Vec<f64>,
5480    lambdas: Vec<f64>,
5481    lambda_gen: f64,
5482) -> PyResult<nucleide_kinetics::KineticParams> {
5483    nucleide_kinetics::KineticParams::new(betas, lambdas, lambda_gen)
5484        .map_err(|e| PyValueError::new_err(e.to_string()))
5485}
5486
5487/// Solve a prescribed-reactivity point-kinetics transient.
5488///
5489/// Thin wrapper over `nucleide_kinetics::solve`: `betas`/`lambdas`/`Lambda`
5490/// carry the delayed-neutron data (see `KineticParams::from_ifp` for the
5491/// OpenMC provenance note — decay constants are caller-supplied), `rho` is
5492/// a spec dict (see `parse_reactivity`), `t` the output grid in seconds,
5493/// `n0` the initial neutron level, `C0` the optional initial precursors
5494/// (defaults to equilibrium). `method` is `"trapezoidal"` (default) or
5495/// `"backward_euler"`. Returns a dict with `times`, `n`, `C`
5496/// (`[time][group]`), and the echo of the initial state (`n0`, `C0`).
5497#[pyfunction]
5498#[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))]
5499#[allow(clippy::too_many_arguments)]
5500fn kinetics_solve(
5501    py: Python<'_>,
5502    betas: Vec<f64>,
5503    lambdas: Vec<f64>,
5504    lambda_gen: f64,
5505    rho: BTreeMap<String, Py<PyAny>>,
5506    t: Vec<f64>,
5507    n0: f64,
5508    c0: Option<Vec<f64>>,
5509    method: &str,
5510    rtol: f64,
5511    atol: f64,
5512    dt_min: f64,
5513    dt_max: Option<f64>,
5514    max_steps: usize,
5515) -> PyResult<Py<PyAny>> {
5516    use nucleide_kinetics::{Method as M, SolverOptions};
5517    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5518    let rho = parse_reactivity(&rho, py)?;
5519    let grid =
5520        nucleide_kinetics::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
5521    let state = nucleide_kinetics::State::new(&params, n0, c0)
5522        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5523    let method = if method.eq_ignore_ascii_case("trapezoidal") {
5524        M::Trapezoidal
5525    } else if method.eq_ignore_ascii_case("backward_euler") {
5526        M::BackwardEuler
5527    } else {
5528        return Err(PyValueError::new_err(format!(
5529            "unknown kinetics method `{method}` (supported: trapezoidal, backward_euler)"
5530        )));
5531    };
5532    let opts = SolverOptions {
5533        method,
5534        rtol,
5535        atol,
5536        dt_min,
5537        dt_max: dt_max.unwrap_or(f64::INFINITY),
5538        max_steps,
5539    };
5540    let sol = nucleide_kinetics::solve(&params, &rho, &grid, &state, &opts)
5541        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5542    use pyo3::types::PyDict;
5543    let out = PyDict::new(py);
5544    out.set_item("times", &sol.times).ok();
5545    out.set_item("n", &sol.n).ok();
5546    out.set_item("C", &sol.c).ok();
5547    out.set_item("n0", sol.initial.n0).ok();
5548    out.set_item("C0", &sol.initial.c0).ok();
5549    Ok(out.into_any().unbind())
5550}
5551
5552/// Equilibrium precursor populations `C_i = beta_i/(lambda_i*Lambda)*n0`.
5553#[pyfunction]
5554fn kinetics_equilibrium(
5555    betas: Vec<f64>,
5556    lambdas: Vec<f64>,
5557    lambda_gen: f64,
5558    n0: f64,
5559) -> PyResult<Vec<f64>> {
5560    kinetics_params(betas, lambdas, lambda_gen)?
5561        .equilibrium_precursors(n0)
5562        .map_err(|e| PyValueError::new_err(e.to_string()))
5563}
5564
5565/// Initial rate `dn/dt` at `t = 0` for the given schedule and initials.
5566#[pyfunction]
5567#[pyo3(signature = (betas, lambdas, lambda_gen, rho, n0, c0=None))]
5568fn kinetics_initial_rate(
5569    py: Python<'_>,
5570    betas: Vec<f64>,
5571    lambdas: Vec<f64>,
5572    lambda_gen: f64,
5573    rho: BTreeMap<String, Py<PyAny>>,
5574    n0: f64,
5575    c0: Option<Vec<f64>>,
5576) -> PyResult<f64> {
5577    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5578    let rho = parse_reactivity(&rho, py)?;
5579    let state = nucleide_kinetics::State::new(&params, n0, c0)
5580        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5581    Ok(nucleide_kinetics::solve::initial_rate(
5582        &params, &rho, &state,
5583    ))
5584}
5585
5586/// Inhour right-hand side `rho(omega)` [Δk] for the given data.
5587#[pyfunction]
5588fn kinetics_inhour_rho(
5589    betas: Vec<f64>,
5590    lambdas: Vec<f64>,
5591    lambda_gen: f64,
5592    omega: f64,
5593) -> PyResult<f64> {
5594    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5595    nucleide_kinetics::rho_of_omega(&params, omega)
5596        .map_err(|e| PyValueError::new_err(e.to_string()))
5597}
5598
5599/// Stable period `T = 1/omega` [s] at reactivity `rho` [Δk] (`0 < rho < beta`).
5600#[pyfunction]
5601fn kinetics_stable_period(
5602    betas: Vec<f64>,
5603    lambdas: Vec<f64>,
5604    lambda_gen: f64,
5605    rho: f64,
5606) -> PyResult<f64> {
5607    let params = kinetics_params(betas, lambdas, lambda_gen)?;
5608    nucleide_kinetics::stable_period(&params, rho).map_err(|e| PyValueError::new_err(e.to_string()))
5609}
5610
5611/// Prompt-jump estimate `n_before*(beta - rho_before)/(beta - rho_after)`.
5612///
5613/// Needs `rho_after < beta_total`; `beta_total` is the caller's total
5614/// delayed fraction (pass `sum(betas)`).
5615#[pyfunction]
5616fn kinetics_prompt_jump(
5617    n_before: f64,
5618    rho_before: f64,
5619    rho_after: f64,
5620    beta_total: f64,
5621) -> PyResult<f64> {
5622    nucleide_kinetics::prompt_jump(n_before, rho_before, rho_after, beta_total)
5623        .map_err(|e| PyValueError::new_err(e.to_string()))
5624}
5625
5626// ---------------------------------------------------------------------------
5627// Spectroscopy (thin glue over `nucleide-spectroscopy`; algorithms stay in core)
5628// ---------------------------------------------------------------------------
5629
5630/// Rectangular smoothing (E1): `m` must be odd and at least 3.
5631#[pyfunction]
5632fn spectroscopy_rect_smooth(counts: Vec<f64>, m: i64) -> PyResult<Vec<f64>> {
5633    let w = usize::try_from(m).map_err(|_| {
5634        PyValueError::new_err(format!("spectroscopy: smoothing width {m} is less than 3"))
5635    })?;
5636    nucleide_spectroscopy::rect_smooth(&counts, w).map_err(|e| PyValueError::new_err(e.to_string()))
5637}
5638
5639/// Five-point smoothing (E2); the first/last two channels are copied.
5640#[pyfunction]
5641fn spectroscopy_five_point_smooth(counts: Vec<f64>) -> PyResult<Vec<f64>> {
5642    nucleide_spectroscopy::five_point_smooth(&counts)
5643        .map_err(|e| PyValueError::new_err(e.to_string()))
5644}
5645
5646/// Background under a peak (E3, `m == 1` only).
5647#[pyfunction]
5648fn spectroscopy_calc_bg(
5649    counts: Vec<f64>,
5650    channels: Vec<f64>,
5651    c1: i64,
5652    c2: i64,
5653    m: i64,
5654) -> PyResult<f64> {
5655    nucleide_spectroscopy::calc_bg(&counts, &channels, c1, c2, m)
5656        .map_err(|e| PyValueError::new_err(e.to_string()))
5657}
5658
5659/// Gross counts between two channels, half-open (E4, excludes `c2`).
5660#[pyfunction]
5661fn spectroscopy_gross_count(
5662    counts: Vec<f64>,
5663    channels: Vec<f64>,
5664    c1: i64,
5665    c2: i64,
5666) -> PyResult<f64> {
5667    nucleide_spectroscopy::gross_count(&counts, &channels, c1, c2)
5668        .map_err(|e| PyValueError::new_err(e.to_string()))
5669}
5670
5671/// Net counts: gross minus background (E5).
5672#[pyfunction]
5673fn spectroscopy_net_counts(
5674    counts: Vec<f64>,
5675    channels: Vec<f64>,
5676    c1: i64,
5677    c2: i64,
5678    m: i64,
5679) -> PyResult<f64> {
5680    nucleide_spectroscopy::net_counts(&counts, &channels, c1, c2, m)
5681        .map_err(|e| PyValueError::new_err(e.to_string()))
5682}
5683
5684/// Energy per channel from the `[a0, a1, a2]` fit (E6).
5685#[pyfunction]
5686fn spectroscopy_energy_bins(channels: Vec<f64>, calib_e_fit: Vec<f64>) -> PyResult<Vec<f64>> {
5687    nucleide_spectroscopy::energy_bins(&channels, &calib_e_fit)
5688        .map_err(|e| PyValueError::new_err(e.to_string()))
5689}
5690
5691/// Detector efficiency at `energy_mev` (E7, energy in MeV, `eff_fit` 1 or 2).
5692#[pyfunction]
5693fn spectroscopy_detector_efficiency(
5694    energy_mev: f64,
5695    eff_coeff: Vec<f64>,
5696    eff_fit: i64,
5697) -> PyResult<f64> {
5698    nucleide_spectroscopy::detector_efficiency(energy_mev, &eff_coeff, eff_fit)
5699        .map_err(|e| PyValueError::new_err(e.to_string()))
5700}
5701
5702/// Fetch one caller-supplied atomic constant or raise a `ValueError`.
5703fn atomic_key(atomic: &BTreeMap<String, f64>, key: &str) -> PyResult<f64> {
5704    atomic
5705        .get(key)
5706        .copied()
5707        .ok_or_else(|| PyValueError::new_err(format!("atomic constants missing `{key}`")))
5708}
5709
5710/// X-ray lines (E8) as `[(energy_kev, intensity); Ka1, Ka2, Kb, L]`.
5711///
5712/// `atomic` carries the nine caller-supplied constants (`k_shell_fluor`,
5713/// `l_shell_fluor`, `prob`, `kb_to_ka`, `ka2_to_ka1`, `ka1_en_kev`,
5714/// `ka2_en_kev`, `kb_en_kev`, `l_en_kev`). `None` (or NaN, the upstream
5715/// sentinel) marks a conversion absent. Upstream exposes no combined
5716/// function for this routine — only a material method — so this explicit
5717/// entry point is the documented Nucleide surface.
5718#[pyfunction]
5719#[pyo3(signature = (atomic, k_conv=None, l_conv=None))]
5720fn spectroscopy_xray_lines(
5721    atomic: BTreeMap<String, f64>,
5722    k_conv: Option<f64>,
5723    l_conv: Option<f64>,
5724) -> PyResult<Vec<(f64, f64)>> {
5725    let data = nucleide_spectroscopy::AtomicData {
5726        k_shell_fluor: atomic_key(&atomic, "k_shell_fluor")?,
5727        l_shell_fluor: atomic_key(&atomic, "l_shell_fluor")?,
5728        prob: atomic_key(&atomic, "prob")?,
5729        kb_to_ka: atomic_key(&atomic, "kb_to_ka")?,
5730        ka2_to_ka1: atomic_key(&atomic, "ka2_to_ka1")?,
5731        ka1_en_kev: atomic_key(&atomic, "ka1_en_kev")?,
5732        ka2_en_kev: atomic_key(&atomic, "ka2_en_kev")?,
5733        kb_en_kev: atomic_key(&atomic, "kb_en_kev")?,
5734        l_en_kev: atomic_key(&atomic, "l_en_kev")?,
5735    };
5736    // NaN plays the upstream "conversion absent" sentinel role.
5737    let present = |v: Option<f64>| v.filter(|x| !x.is_nan());
5738    Ok(
5739        nucleide_spectroscopy::xray_lines(&data, present(k_conv), present(l_conv))
5740            .iter()
5741            .map(|l| (l.energy_kev, l.intensity))
5742            .collect(),
5743    )
5744}
5745
5746/// SDEF decay-source card (E9) as `(normalized_bins, card_text)`.
5747///
5748/// `lines` carries caller-supplied `(energy_mev, intensity)` pairs; every
5749/// energy and intensity is an input (no evaluated data is vendored).
5750/// Intensities are merged at duplicate energies, sorted ascending, and
5751/// normalized to probabilities summing to 1.0. The card keeps the upstream
5752/// monoenergetic point-source field order (`POS`, optional `VEC ... DIR=1`,
5753/// `ERG`, `WGT`, `PAR`); one surviving line renders inline `ERG=<E>`, while
5754/// several render the discrete-distribution form `ERG=D1` with paired
5755/// `SI1 L` / `SP1 D` cards. That distribution syntax is parser-verified
5756/// surface only — MCNP sampling semantics are the caller's responsibility.
5757/// `particle` parses through the `nucleide-nuclei` dialect (`"Neutron"`,
5758/// `"Photon"`, `"Electron"`, ...); `version` is 5 or 6 and selects the
5759/// `PAR=` designator.
5760#[pyfunction]
5761#[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))]
5762#[allow(clippy::too_many_arguments)]
5763fn spectroscopy_sdef_decay_source(
5764    lines: Vec<(f64, f64)>,
5765    x: f64,
5766    y: f64,
5767    z: f64,
5768    u: f64,
5769    v: f64,
5770    w: f64,
5771    weight: f64,
5772    particle: &str,
5773    version: u32,
5774) -> PyResult<(Vec<(f64, f64)>, String)> {
5775    let particle = particle
5776        .parse::<nucleide_nuclei::particles::ParticleId>()
5777        .map_err(|e| PyValueError::new_err(format!("spectroscopy: particle {e}")))?;
5778    let source = nucleide_spectroscopy::PointSource {
5779        x,
5780        y,
5781        z,
5782        u,
5783        v,
5784        w,
5785        weight,
5786        particle,
5787    };
5788    nucleide_spectroscopy::sdef_card(&lines, &source, version)
5789        .map_err(|e| PyValueError::new_err(e.to_string()))
5790}
5791
5792/// Render a parsed spectrum as a Python dict.
5793fn spectrum_to_py(
5794    py: Python<'_>,
5795    spec: &nucleide_spectroscopy::GammaSpectrum,
5796) -> PyResult<Py<PyAny>> {
5797    use pyo3::types::PyDict;
5798    let d = PyDict::new(py);
5799    let s = &spec.spectrum;
5800    d.set_item("spec_name", &s.spec_name)?;
5801    d.set_item("start_chan_num", s.start_chan_num)?;
5802    d.set_item("num_channels", s.num_channels)?;
5803    d.set_item("channels", &s.channels)?;
5804    d.set_item("counts", &s.counts)?;
5805    d.set_item("ebin", &s.ebin)?;
5806    d.set_item("real_time", spec.real_time)?;
5807    d.set_item("live_time", spec.live_time)?;
5808    d.set_item("dead_time", spec.dead_time())?;
5809    d.set_item("det_id", &spec.det_id)?;
5810    d.set_item("det_descp", &spec.det_descp)?;
5811    d.set_item("start_date", &spec.start_date)?;
5812    d.set_item("start_time", &spec.start_time)?;
5813    d.set_item("calib_e_fit", &spec.calib_e_fit)?;
5814    d.set_item("calib_fwhm_fit", &spec.calib_fwhm_fit)?;
5815    d.set_item("file_name", &spec.file_name)?;
5816    Ok(d.into_any().unbind())
5817}
5818
5819/// Parse dollar-format `.spe` text (first line must be `$SPEC_ID:`).
5820#[pyfunction]
5821fn spectroscopy_parse_dollar_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
5822    let spec = nucleide_spectroscopy::parse_dollar_spe(text, "")
5823        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5824    spectrum_to_py(py, &spec)
5825}
5826
5827/// Parse plain-format `.spe` text (rejects the `$SPEC_ID:` magic).
5828#[pyfunction]
5829fn spectroscopy_parse_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
5830    let spec = nucleide_spectroscopy::parse_plain_spe(text, "")
5831        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5832    spectrum_to_py(py, &spec)
5833}
5834
5835/// Read a dollar-format `.spe` file.
5836#[pyfunction]
5837fn spectroscopy_read_dollar_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
5838    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
5839    let spec = nucleide_spectroscopy::parse_dollar_spe(&text, path)
5840        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5841    spectrum_to_py(py, &spec)
5842}
5843
5844/// Read a plain-format `.spe` file.
5845#[pyfunction]
5846fn spectroscopy_read_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
5847    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
5848    let spec = nucleide_spectroscopy::parse_plain_spe(&text, path)
5849        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5850    spectrum_to_py(py, &spec)
5851}
5852
5853/// Parse decay-lines interchange TSV text into `(energy_MeV, intensity)`
5854/// pairs (`#` comments and blank lines skipped; E9 normalization stays in
5855/// `sdef_decay_source`).
5856#[pyfunction]
5857fn spectroscopy_parse_lines_tsv(text: &str) -> PyResult<Vec<(f64, f64)>> {
5858    nucleide_spectroscopy::parse_lines_tsv(text).map_err(|e| PyValueError::new_err(e.to_string()))
5859}
5860
5861/// Read a decay-lines interchange TSV file (same grammar as
5862/// `spectroscopy_parse_lines_tsv`).
5863#[pyfunction]
5864fn spectroscopy_read_decay_lines(path: &str) -> PyResult<Vec<(f64, f64)>> {
5865    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
5866    nucleide_spectroscopy::parse_lines_tsv(&text).map_err(|e| PyValueError::new_err(e.to_string()))
5867}
5868
5869// ---------------------------------------------------------------------------
5870// UQ-lite sampling kernel (thin glue over `linalg`; decay-only sub-scope)
5871// ---------------------------------------------------------------------------
5872
5873fn uq_sample_err(e: nucleide_linalg::sample::SampleError) -> PyErr {
5874    PyValueError::new_err(e.to_string())
5875}
5876
5877fn uq_decay_err(e: nucleide_linalg::decay::DecayError) -> PyErr {
5878    PyValueError::new_err(e.to_string())
5879}
5880
5881/// Seeded multivariate-normal draws over a caller-supplied covariance block.
5882///
5883/// Returns a dict with `samples` (list of `n` row lists), `method`
5884/// (`"cholesky"` or `"eigen_clip"`), and the unclipped `min_eigen` /
5885/// `max_eigen` (`None` on the Cholesky path). Same
5886/// `(mean, cov, n, seed)` inputs always yield identical samples. Thin
5887/// wrapper over `nucleide-linalg` `sample`.
5888#[pyfunction]
5889fn uq_sample_mvn(
5890    py: Python<'_>,
5891    mean: Vec<f64>,
5892    cov: Vec<Vec<f64>>,
5893    n: usize,
5894    seed: u64,
5895) -> PyResult<Py<PyAny>> {
5896    use pyo3::types::PyDict;
5897    let set = nucleide_linalg::sample::sample_mvn(&mean, &cov, n, seed).map_err(uq_sample_err)?;
5898    let d = PyDict::new(py);
5899    d.set_item("samples", set.samples)?;
5900    d.set_item("method", set.method.name())?;
5901    match &set.method {
5902        nucleide_linalg::sample::FactorMethod::Cholesky => {
5903            d.set_item("min_eigen", py.None())?;
5904            d.set_item("max_eigen", py.None())?;
5905        }
5906        nucleide_linalg::sample::FactorMethod::EigenClip {
5907            min_eigen,
5908            max_eigen,
5909        } => {
5910            d.set_item("min_eigen", *min_eigen)?;
5911            d.set_item("max_eigen", *max_eigen)?;
5912        }
5913    }
5914    Ok(d.into_any().unbind())
5915}
5916
5917/// Sample mean over draws (one entry per dimension).
5918#[pyfunction]
5919fn uq_sample_mean(samples: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
5920    nucleide_linalg::sample::sample_mean(&samples).map_err(uq_sample_err)
5921}
5922
5923/// Unbiased sample covariance (`1/(n-1)`, matching SANDY `Samples.get_cov`).
5924#[pyfunction]
5925fn uq_sample_cov(samples: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
5926    nucleide_linalg::sample::sample_cov(&samples).map_err(uq_sample_err)
5927}
5928
5929/// Sample mean/covariance convergence diagnostics à la SANDY.
5930///
5931/// Returns a dict with `mean_err_max`, `cov_err_fro`, the echoed
5932/// `mean_tol`/`cov_tol`, and `passed`. Thin wrapper over
5933/// `nucleide-linalg` `sample`.
5934#[pyfunction]
5935fn uq_check_convergence(
5936    py: Python<'_>,
5937    mean: Vec<f64>,
5938    cov: Vec<Vec<f64>>,
5939    samples: Vec<Vec<f64>>,
5940    mean_tol: f64,
5941    cov_tol: f64,
5942) -> PyResult<Py<PyAny>> {
5943    use pyo3::types::PyDict;
5944    let rep = nucleide_linalg::sample::check_convergence(&mean, &cov, &samples, mean_tol, cov_tol)
5945        .map_err(uq_sample_err)?;
5946    let d = PyDict::new(py);
5947    d.set_item("mean_err_max", rep.mean_err_max)?;
5948    d.set_item("cov_err_fro", rep.cov_err_fro)?;
5949    d.set_item("mean_tol", rep.mean_tol)?;
5950    d.set_item("cov_tol", rep.cov_tol)?;
5951    d.set_item("passed", rep.passed)?;
5952    Ok(d.into_any().unbind())
5953}
5954
5955/// Perturb one parent's kept branch fractions with relative deltas,
5956/// preserving the incoming `1 - BR(SF)` deficit by renormalisation.
5957#[pyfunction]
5958fn uq_perturb_branches(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
5959    nucleide_linalg::decay::perturb_branches(&base, &rel).map_err(uq_decay_err)
5960}
5961
5962/// Perturb decay energies under `convention` (`"relative"`/`"absolute"`);
5963/// negative results clamp to zero.
5964#[pyfunction]
5965fn uq_perturb_energies(base: Vec<f64>, delta: Vec<f64>, convention: &str) -> PyResult<Vec<f64>> {
5966    let conv = nucleide_linalg::sample::PerturbConvention::parse(convention)
5967        .map_err(PyValueError::new_err)?;
5968    nucleide_linalg::decay::perturb_energies(&base, &delta, conv).map_err(uq_decay_err)
5969}
5970
5971/// Passthrough copy of a perturbation vector (finiteness-checked).
5972#[pyfunction]
5973fn uq_passthrough(delta: Vec<f64>) -> PyResult<Vec<f64>> {
5974    nucleide_linalg::decay::passthrough(&delta).map_err(uq_decay_err)
5975}
5976
5977/// Fission-yield perturbation — named-open hook (waits on cycle 01 FY
5978/// tapes); always raises.
5979#[pyfunction]
5980fn uq_perturb_fission_yields(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
5981    nucleide_linalg::decay::perturb_fission_yields(&base, &rel).map_err(uq_decay_err)
5982}
5983
5984/// Python module entry point.
5985#[pymodule]
5986fn _internal(m: &Bound<'_, PyModule>) -> PyResult<()> {
5987    m.add_function(wrap_pyfunction!(version, m)?)?;
5988    m.add_function(wrap_pyfunction!(from_zaid, m)?)?;
5989    m.add_function(wrap_pyfunction!(atomic_mass, m)?)?;
5990    m.add_function(wrap_pyfunction!(natural_abundance, m)?)?;
5991    m.add_function(wrap_pyfunction!(rxname_id, m)?)?;
5992    m.add_function(wrap_pyfunction!(rxname_name, m)?)?;
5993    m.add_function(wrap_pyfunction!(rxname_mt, m)?)?;
5994    m.add_function(wrap_pyfunction!(read_xsdir, m)?)?;
5995    m.add_function(wrap_pyfunction!(read_meshtal, m)?)?;
5996    m.add_function(wrap_pyfunction!(read_wwinp, m)?)?;
5997    m.add_function(wrap_pyfunction!(read_mctal, m)?)?;
5998    m.add_function(wrap_pyfunction!(read_ssw, m)?)?;
5999    m.add_function(wrap_pyfunction!(read_ptrac, m)?)?;
6000    m.add_function(wrap_pyfunction!(read_mcpl, m)?)?;
6001    m.add_function(wrap_pyfunction!(write_mcpl, m)?)?;
6002    m.add_function(wrap_pyfunction!(ssw2mcpl, m)?)?;
6003    m.add_function(wrap_pyfunction!(mcpl2ssw, m)?)?;
6004    m.add_function(wrap_pyfunction!(read_endl, m)?)?;
6005    m.add_function(wrap_pyfunction!(endl_endftod, m)?)?;
6006    m.add_function(wrap_pyfunction!(combine_ssw_files, m)?)?;
6007    m.add_function(wrap_pyfunction!(read_chain, m)?)?;
6008    m.add_function(wrap_pyfunction!(build_depletion_system, m)?)?;
6009    m.add_function(wrap_pyfunction!(deplete, m)?)?;
6010    m.add_function(wrap_pyfunction!(deplete_series, m)?)?;
6011    m.add_function(wrap_pyfunction!(simple_xs, m)?)?;
6012    m.add_function(wrap_pyfunction!(scattering_length, m)?)?;
6013    m.add_function(wrap_pyfunction!(decay_energy, m)?)?;
6014    m.add_function(wrap_pyfunction!(decay_branches, m)?)?;
6015    m.add_function(wrap_pyfunction!(decay_branch_fraction, m)?)?;
6016    m.add_function(wrap_pyfunction!(normalize_nuclide, m)?)?;
6017    m.add_function(wrap_pyfunction!(decay_heat, m)?)?;
6018    m.add_function(wrap_pyfunction!(dose_factor, m)?)?;
6019    m.add_function(wrap_pyfunction!(dose_per_g, m)?)?;
6020    m.add_function(wrap_pyfunction!(read_serpent, m)?)?;
6021    m.add_function(wrap_pyfunction!(read_usrbin, m)?)?;
6022    m.add_function(wrap_pyfunction!(magic, m)?)?;
6023    m.add_function(wrap_pyfunction!(write_ssw, m)?)?;
6024    m.add_function(wrap_pyfunction!(mesh_to_geom, m)?)?;
6025    m.add_function(wrap_pyfunction!(half_life, m)?)?;
6026    m.add_function(wrap_pyfunction!(decay_constant, m)?)?;
6027    m.add_function(wrap_pyfunction!(q_value_capture, m)?)?;
6028    m.add_function(wrap_pyfunction!(q_value_alpha, m)?)?;
6029    m.add_function(wrap_pyfunction!(read_inp, m)?)?;
6030    m.add_function(wrap_pyfunction!(from_formula, m)?)?;
6031    m.add_function(wrap_pyfunction!(activity, m)?)?;
6032    m.add_function(wrap_pyfunction!(to_xml, m)?)?;
6033    m.add_function(wrap_pyfunction!(alara_parse_deck, m)?)?;
6034    m.add_function(wrap_pyfunction!(alara_parse_flux, m)?)?;
6035    m.add_function(wrap_pyfunction!(alara_parse_output, m)?)?;
6036    m.add_function(wrap_pyfunction!(alara_expand_schedule, m)?)?;
6037    m.add_function(wrap_pyfunction!(isotxs_parse, m)?)?;
6038    m.add_function(wrap_pyfunction!(rtflux_parse, m)?)?;
6039    m.add_function(wrap_pyfunction!(partisn_render, m)?)?;
6040    m.add_function(wrap_pyfunction!(partisn_validate, m)?)?;
6041    m.add_function(wrap_pyfunction!(fispact_parse_output, m)?)?;
6042    m.add_function(wrap_pyfunction!(origen_parse_tape5, m)?)?;
6043    m.add_function(wrap_pyfunction!(origen_parse_tape6, m)?)?;
6044    m.add_function(wrap_pyfunction!(origen_parse_tape9, m)?)?;
6045    m.add_function(wrap_pyfunction!(r2s_from_deck, m)?)?;
6046    m.add_function(wrap_pyfunction!(r2s_from_snapshot, m)?)?;
6047    m.add_function(wrap_pyfunction!(r2s_validate, m)?)?;
6048    m.add_function(wrap_pyfunction!(r2s_expand, m)?)?;
6049    m.add_function(wrap_pyfunction!(r2s_assemble, m)?)?;
6050    m.add_function(wrap_pyfunction!(r2s_tag_zone_strength, m)?)?;
6051    m.add_function(wrap_pyfunction!(r2s_photon_group_sums, m)?)?;
6052    m.add_function(wrap_pyfunction!(kinetics_solve, m)?)?;
6053    m.add_function(wrap_pyfunction!(kinetics_equilibrium, m)?)?;
6054    m.add_function(wrap_pyfunction!(kinetics_initial_rate, m)?)?;
6055    m.add_function(wrap_pyfunction!(kinetics_inhour_rho, m)?)?;
6056    m.add_function(wrap_pyfunction!(kinetics_stable_period, m)?)?;
6057    m.add_function(wrap_pyfunction!(kinetics_prompt_jump, m)?)?;
6058    m.add_function(wrap_pyfunction!(spectroscopy_rect_smooth, m)?)?;
6059    m.add_function(wrap_pyfunction!(spectroscopy_five_point_smooth, m)?)?;
6060    m.add_function(wrap_pyfunction!(spectroscopy_calc_bg, m)?)?;
6061    m.add_function(wrap_pyfunction!(spectroscopy_gross_count, m)?)?;
6062    m.add_function(wrap_pyfunction!(spectroscopy_net_counts, m)?)?;
6063    m.add_function(wrap_pyfunction!(spectroscopy_energy_bins, m)?)?;
6064    m.add_function(wrap_pyfunction!(spectroscopy_detector_efficiency, m)?)?;
6065    m.add_function(wrap_pyfunction!(spectroscopy_xray_lines, m)?)?;
6066    m.add_function(wrap_pyfunction!(spectroscopy_sdef_decay_source, m)?)?;
6067    m.add_function(wrap_pyfunction!(spectroscopy_parse_dollar_spe, m)?)?;
6068    m.add_function(wrap_pyfunction!(spectroscopy_parse_spe, m)?)?;
6069    m.add_function(wrap_pyfunction!(spectroscopy_read_dollar_spe, m)?)?;
6070    m.add_function(wrap_pyfunction!(spectroscopy_read_spe, m)?)?;
6071    m.add_function(wrap_pyfunction!(spectroscopy_parse_lines_tsv, m)?)?;
6072    m.add_function(wrap_pyfunction!(spectroscopy_read_decay_lines, m)?)?;
6073    m.add_function(wrap_pyfunction!(uq_sample_mvn, m)?)?;
6074    m.add_function(wrap_pyfunction!(uq_sample_mean, m)?)?;
6075    m.add_function(wrap_pyfunction!(uq_sample_cov, m)?)?;
6076    m.add_function(wrap_pyfunction!(uq_check_convergence, m)?)?;
6077    m.add_function(wrap_pyfunction!(uq_perturb_branches, m)?)?;
6078    m.add_function(wrap_pyfunction!(uq_perturb_energies, m)?)?;
6079    m.add_function(wrap_pyfunction!(uq_passthrough, m)?)?;
6080    m.add_function(wrap_pyfunction!(uq_perturb_fission_yields, m)?)?;
6081    m.add_function(wrap_pyfunction!(parse_deck, m)?)?;
6082    m.add_function(wrap_pyfunction!(read_deck, m)?)?;
6083    m.add_function(wrap_pyfunction!(cumulative_decays, m)?)?;
6084    m.add_function(wrap_pyfunction!(progeny, m)?)?;
6085    m.add_function(wrap_pyfunction!(branching_fraction, m)?)?;
6086    m.add_function(wrap_pyfunction!(decay_mode, m)?)?;
6087    m.add_function(wrap_pyfunction!(chain_edges, m)?)?;
6088    m.add_function(wrap_pyfunction!(armi_to_nucid, m)?)?;
6089    m.add_function(wrap_pyfunction!(nucid_to_armi, m)?)?;
6090    m.add_function(wrap_pyfunction!(mcc3_to_nucid, m)?)?;
6091    m.add_function(wrap_pyfunction!(check_labels, m)?)?;
6092    m.add_function(wrap_pyfunction!(audit_material, m)?)?;
6093    m.add_function(wrap_pyfunction!(separate_material, m)?)?;
6094    m.add_function(wrap_pyfunction!(blend_material, m)?)?;
6095    m.add_function(wrap_pyfunction!(enrichment_value_func, m)?)?;
6096    m.add_function(wrap_pyfunction!(enrichment_swu_per_feed, m)?)?;
6097    m.add_function(wrap_pyfunction!(enrichment_swu_per_prod, m)?)?;
6098    m.add_function(wrap_pyfunction!(enrichment_swu_per_tail, m)?)?;
6099    m.add_class::<PyCusum>()?;
6100    m.add_function(wrap_pyfunction!(emit_cards, m)?)?;
6101    m.add_function(wrap_pyfunction!(emit_drift_table, m)?)?;
6102    m.add_function(wrap_pyfunction!(emit_armi_cards, m)?)?;
6103    m.add_function(wrap_pyfunction!(emit_armi_drift_table, m)?)?;
6104    m.add_class::<PyNuclide>()?;
6105    m.add_class::<PyParticle>()?;
6106    m.add_class::<PyXsdir>()?;
6107    m.add_class::<PyXsdirTable>()?;
6108    m.add_class::<PyMeshtal>()?;
6109    m.add_class::<PyMeshTally>()?;
6110    m.add_class::<PyWwinp>()?;
6111    m.add_class::<PyMctal>()?;
6112    m.add_class::<PySurfSrc>()?;
6113    m.add_class::<PyPtracFile>()?;
6114    m.add_class::<PyMcplFile>()?;
6115    m.add_class::<PyEndlLibrary>()?;
6116    m.add_class::<PyChain>()?;
6117    m.add_class::<PyDepletionSystem>()?;
6118    m.add_class::<PyUsrbinTally>()?;
6119    m.add_class::<PyMagicOutput>()?;
6120    m.add_class::<PyAliasTable>()?;
6121    m.add_class::<PyMeshSourceSampler>()?;
6122    m.add_class::<PyCascade>()?;
6123    m.add_class::<PyMaterialsCompendium>()?;
6124    m.add_class::<PyDeckProblem>()?;
6125    m.add_class::<PyInventory>()?;
6126    Ok(())
6127}