Skip to main content

nucleide_bindings/
lib.rs

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