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/// Merge MCPL particle-list files into a new file (see `nucleide-mcpl-io`).
1707///
1708/// The first file's header wins (`srcname`, `comments`, `blobs`) and a
1709/// provenance comment is appended; `stat:sum` sums are never synthesized or
1710/// updated. All inputs must agree on the header options except
1711/// floating-point precision, which promotes to double on mixed input (the
1712/// lossless direction). Input order is the particle order of the output.
1713/// A `.gz` suffix on `out_path` compresses through gzip transparently.
1714/// Returns the merged particle count. Thin wrapper over `nucleide-mcpl-io`.
1715#[pyfunction]
1716fn merge_mcpl(paths: Vec<String>, out_path: &str) -> PyResult<u64> {
1717    let mut files = Vec::with_capacity(paths.len());
1718    for (i, p) in paths.iter().enumerate() {
1719        files.push(
1720            nucleide_mcpl_io::McplFile::open(p)
1721                .map_err(|e| PyValueError::new_err(format!("merge_mcpl: input {i} {p}: {e}")))?,
1722        );
1723    }
1724    let (header, particles) =
1725        nucleide_mcpl_io::merge_mcpl(&files).map_err(|e| PyValueError::new_err(e.to_string()))?;
1726    nucleide_mcpl_io::write_to_path(out_path, &header, &particles)
1727        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1728    Ok(particles.len() as u64)
1729}
1730
1731/// Parsed `extract_mcpl` options: the selection rule plus, for the predicate
1732/// form, the handle where a Python callback exception is parked until the
1733/// Rust-side extraction returns.
1734struct ParsedExtract {
1735    spec: nucleide_mcpl_io::ExtractSpec,
1736    pending: Option<std::rc::Rc<std::cell::RefCell<Option<PyErr>>>>,
1737}
1738
1739/// Parse the `extract_mcpl` options dict (see the `extract_mcpl` docs).
1740fn parse_extract_spec(
1741    options: Option<&Bound<'_, PyAny>>,
1742    nparticles: usize,
1743) -> PyResult<ParsedExtract> {
1744    let Some(d) = options else {
1745        return Ok(ParsedExtract {
1746            spec: nucleide_mcpl_io::ExtractSpec::Range(0..nparticles),
1747            pending: None,
1748        });
1749    };
1750    if !d.is_instance_of::<pyo3::types::PyDict>() {
1751        return Err(PyValueError::new_err("options must be a dict or None"));
1752    }
1753    let opt_usize = |key: &str| -> PyResult<Option<usize>> {
1754        match d.get_item(key) {
1755            Err(_) => Ok(None),
1756            Ok(v) if v.is_none() => Ok(None),
1757            Ok(v) => v.extract::<usize>().map(Some).map_err(|_| {
1758                PyValueError::new_err(format!("options `{key}` must be a non-negative int"))
1759            }),
1760        }
1761    };
1762    let start = opt_usize("start")?;
1763    let stop = opt_usize("stop")?;
1764    if let Ok(cb) = d.get_item("predicate") {
1765        if !cb.is_none() {
1766            if start.is_some() || stop.is_some() {
1767                return Err(PyValueError::new_err(
1768                    "options `start`/`stop` and `predicate` cannot be combined",
1769                ));
1770            }
1771            if !cb.is_callable() {
1772                return Err(PyValueError::new_err(
1773                    "options `predicate` must be callable",
1774                ));
1775            }
1776            let cb = cb.unbind();
1777            let pending: std::rc::Rc<std::cell::RefCell<Option<PyErr>>> =
1778                std::rc::Rc::new(std::cell::RefCell::new(None));
1779            let pending_inner = std::rc::Rc::clone(&pending);
1780            let spec = nucleide_mcpl_io::ExtractSpec::Predicate(Box::new(
1781                move |p: &nucleide_mcpl_io::Particle| -> bool {
1782                    if pending_inner.borrow().is_some() {
1783                        return false;
1784                    }
1785                    Python::attach(|py| {
1786                        let dict = match mcpl_particle_to_dict(py, p) {
1787                            Ok(d) => d,
1788                            Err(e) => {
1789                                *pending_inner.borrow_mut() = Some(e);
1790                                return false;
1791                            }
1792                        };
1793                        match cb.call1(py, (dict,)) {
1794                            Ok(v) => match v.is_truthy(py) {
1795                                Ok(t) => t,
1796                                Err(e) => {
1797                                    *pending_inner.borrow_mut() = Some(e);
1798                                    false
1799                                }
1800                            },
1801                            Err(e) => {
1802                                *pending_inner.borrow_mut() = Some(e);
1803                                false
1804                            }
1805                        }
1806                    })
1807                },
1808            ));
1809            return Ok(ParsedExtract {
1810                spec,
1811                pending: Some(pending),
1812            });
1813        }
1814    }
1815    Ok(ParsedExtract {
1816        spec: nucleide_mcpl_io::ExtractSpec::Range(start.unwrap_or(0)..stop.unwrap_or(nparticles)),
1817        pending: None,
1818    })
1819}
1820
1821/// Extract a particle subset from an MCPL file into a new file (see
1822/// `nucleide-mcpl-io`).
1823///
1824/// The source header (`srcname`, `comments`, `blobs`, option flags) is
1825/// preserved verbatim on the output; only the particle count is patched.
1826/// `options` (dict or None) selects the subset: `start`/`stop` (ints) for
1827/// the half-open index range `[start, stop)`, or `predicate` (callable over
1828/// one particle dict) to keep selected records; absent options copy the
1829/// whole file. A `.gz` suffix on either path is transparent. Returns the
1830/// extracted particle count. Thin wrapper over `nucleide-mcpl-io`.
1831#[pyfunction]
1832#[pyo3(signature = (src_path, out_path, options=None))]
1833fn extract_mcpl(
1834    src_path: &str,
1835    out_path: &str,
1836    options: Option<Bound<'_, PyAny>>,
1837) -> PyResult<u64> {
1838    let file = nucleide_mcpl_io::McplFile::open(src_path)
1839        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1840    let nparticles = file.header.nparticles as usize;
1841    let parsed = parse_extract_spec(options.as_ref(), nparticles)?;
1842    let (header, particles) = nucleide_mcpl_io::extract_mcpl(&file, &parsed.spec)
1843        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1844    // Surface a predicate callback exception raised mid-extraction.
1845    if let Some(err) = parsed.pending.and_then(|p| p.borrow_mut().take()) {
1846        return Err(err);
1847    }
1848    nucleide_mcpl_io::write_to_path(out_path, &header, &particles)
1849        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1850    Ok(particles.len() as u64)
1851}
1852
1853/// Compute record statistics over an MCPL file (see `nucleide-mcpl-io`).
1854///
1855/// Returns a dict with `nparticles`, `ekin_sum`/`ekin_min`/`ekin_max`/
1856/// `ekin_mean` (MeV; the `min`/`max`/`mean` entries are `None` for an empty
1857/// file), `weight_sum`, and `pdg_counts` (list of `(pdgcode, count)` pairs
1858/// sorted by PDG code). Thin wrapper over `nucleide-mcpl-io`.
1859#[pyfunction]
1860fn mcpl_stats(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
1861    use pyo3::types::PyDict;
1862    let file =
1863        nucleide_mcpl_io::McplFile::open(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1864    let s =
1865        nucleide_mcpl_io::mcpl_stats(&file).map_err(|e| PyValueError::new_err(e.to_string()))?;
1866    let d = PyDict::new(py);
1867    d.set_item("nparticles", s.nparticles)?;
1868    d.set_item("ekin_sum", s.ekin_sum)?;
1869    d.set_item("ekin_min", s.ekin_min)?;
1870    d.set_item("ekin_max", s.ekin_max)?;
1871    d.set_item("ekin_mean", s.ekin_mean)?;
1872    d.set_item("weight_sum", s.weight_sum)?;
1873    d.set_item("pdg_counts", s.pdg_counts)?;
1874    Ok(d.into_any().unbind())
1875}
1876
1877/// Repair an MCPL file that was never properly closed (see `nucleide-mcpl-io`).
1878///
1879/// Recomputes the stored particle count from the file size (complete
1880/// records only, ignoring a partially written trailing record) and rewrites
1881/// the header in place; record bytes and format version pass through
1882/// untouched. A `.gz` suffix reads and writes through gzip transparently.
1883/// Returns the repaired particle count. Thin wrapper over `nucleide-mcpl-io`.
1884#[pyfunction]
1885fn repair_mcpl(path: &str) -> PyResult<u64> {
1886    let file =
1887        nucleide_mcpl_io::McplFile::open(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
1888    let repaired = nucleide_mcpl_io::repair_mcpl(&file);
1889    let n = nucleide_mcpl_io::McplFile::from_bytes(repaired.clone())
1890        .map_err(|e| PyValueError::new_err(e.to_string()))?
1891        .header
1892        .nparticles;
1893    nucleide_mcpl_io::write_bytes_to_path(path, &repaired)
1894        .map_err(|e| PyValueError::new_err(e.to_string()))?;
1895    Ok(n)
1896}
1897
1898/// Parsed ENDL evaluation file (EEDL/EPDL scope).
1899#[pyclass(name = "EndlLibrary")]
1900struct PyEndlLibrary {
1901    inner: nucleide_mcnp_io::endl::Library,
1902}
1903
1904#[pymethods]
1905impl PyEndlLibrary {
1906    /// Distinct nucleus ids in file order.
1907    fn nuclides(&self) -> Vec<i64> {
1908        self.inner.nuclides()
1909    }
1910    /// Reaction data for one selector set.
1911    ///
1912    /// `nuc` is an integer nucleus id (e.g. `820000000` for natural Pb) or a
1913    /// fully-specified isotope name (`"Pb208"`); bare element names do not
1914    /// resolve. `x1`/`p_out` filter by subshell/outgoing particle when given.
1915    /// Returns rows of `fields_for_rprop(rprop)` floats.
1916    #[pyo3(signature = (nuc, p_in, rdesc, rprop, x1=None, p_out=None))]
1917    fn get_rx(
1918        &self,
1919        nuc: &Bound<'_, PyAny>,
1920        p_in: i32,
1921        rdesc: i32,
1922        rprop: i32,
1923        x1: Option<i32>,
1924        p_out: Option<i32>,
1925    ) -> PyResult<Vec<Vec<f64>>> {
1926        let id = if let Ok(n) = nuc.extract::<i64>() {
1927            n
1928        } else if let Ok(name) = nuc.extract::<&str>() {
1929            NuclideId::from_name(name).map_err(wrap_nucid_err)?.nucid() as i64
1930        } else {
1931            return Err(PyTypeError::new_err("expected int nucleus id or str name"));
1932        };
1933        self.inner
1934            .get_rx(id, p_in, rdesc, rprop, x1, p_out)
1935            .map(|rows| rows.to_vec())
1936            .map_err(|e| PyValueError::new_err(e.to_string()))
1937    }
1938}
1939
1940/// Read an ENDL evaluation file (EEDL/EPDL scope).
1941#[pyfunction]
1942fn read_endl(path: &str) -> PyResult<PyEndlLibrary> {
1943    nucleide_mcnp_io::endl::Library::open(path)
1944        .map(|inner| PyEndlLibrary { inner })
1945        .map_err(|e| PyValueError::new_err(e.to_string()))
1946}
1947
1948/// Convert one 11-character ENDL number field to float.
1949#[pyfunction]
1950fn endl_endftod(field: &str) -> f64 {
1951    nucleide_mcnp_io::endl::endftod(field)
1952}
1953
1954/// Combine several SSW surface-source files into one (`ssw_combine.py` port).
1955///
1956/// Headers must agree on kod/ver/loddat, particle type, surface counts and
1957/// per-surface records; the output header carries the signed `orignp1` sum
1958/// and the plain `nrss` sum, with later files' track `nps` shifted
1959/// sign-preservingly. Raises `ValueError` on incompatible inputs (upstream
1960/// returns `False`).
1961#[pyfunction]
1962fn combine_ssw_files(output: &str, inputs: Vec<String>) -> PyResult<()> {
1963    nucleide_mcnp_io::surfsrc::combine_files(&inputs, output)
1964        .map_err(|e| PyValueError::new_err(e.to_string()))
1965}
1966
1967// ---------------------------------------------------------------------------
1968// Depletion / CRAM
1969// ---------------------------------------------------------------------------
1970
1971/// A parsed depletion chain (XML format).
1972#[pyclass(name = "Chain")]
1973struct PyChain {
1974    inner: std::sync::Arc<nucleide_depletion::Chain>,
1975}
1976
1977#[pymethods]
1978impl PyChain {
1979    /// Nuclide names in chain order.
1980    #[getter]
1981    fn nuclides(&self) -> Vec<String> {
1982        self.inner.nuclides.iter().map(|n| n.name.clone()).collect()
1983    }
1984
1985    fn index_of(&self, name: &str) -> Option<usize> {
1986        self.inner.index_of(name)
1987    }
1988}
1989
1990/// Parse a depletion-chain XML file.
1991#[pyfunction]
1992fn read_chain(path: &str) -> PyResult<PyChain> {
1993    nucleide_depletion::Chain::from_file(path)
1994        .map(|inner| PyChain {
1995            inner: std::sync::Arc::new(inner),
1996        })
1997        .map_err(|e| PyValueError::new_err(e.to_string()))
1998}
1999
2000/// One-group reaction rates keyed by "NuclideName:reaction".
2001type RateMap = BTreeMap<String, f64>;
2002
2003/// Pre-built depletion system for repeated CRAM solves.
2004#[pyclass(name = "DepletionSystem")]
2005struct PyDepletionSystem {
2006    inner: std::sync::Arc<nucleide_depletion::DepletionSystem>,
2007}
2008
2009#[pymethods]
2010impl PyDepletionSystem {
2011    /// Solve one depletion step with the pre-built system.
2012    ///
2013    /// `order` selects the CRAM order (16 or 48); `method` selects the
2014    /// solver kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
2015    /// default `"cram48"`). An explicitly non-default `method` overrides
2016    /// `order`; the default `method` defers to `order` for backwards
2017    /// compatibility. `Bateman` arms fall back to CRAM-48 on non-decay
2018    /// systems.
2019    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
2020    fn solve(
2021        &self,
2022        n0: BTreeMap<String, f64>,
2023        dt: f64,
2024        order: u8,
2025        method: &str,
2026    ) -> PyResult<BTreeMap<String, f64>> {
2027        let method = resolve_method(order, method)?;
2028        nucleide_depletion::deplete_with_method(&self.inner, method, &n0, dt)
2029            .map(|r| r.atoms)
2030            .map_err(|e| PyValueError::new_err(e.to_string()))
2031    }
2032
2033    /// Solve one depletion step using pre-built index vectors.
2034    ///
2035    /// `n0` and the returned vector are in chain index order; this avoids the
2036    /// name-to-index mapping overhead of `solve()` for tight timing loops.
2037    /// `method` behaves as in [`PyDepletionSystem::solve`].
2038    #[pyo3(signature = (n0, dt, order=48, method="cram48"))]
2039    fn solve_vec(&self, n0: Vec<f64>, dt: f64, order: u8, method: &str) -> PyResult<Vec<f64>> {
2040        let method = resolve_method(order, method)?;
2041        nucleide_depletion::solve_with_method(&self.inner, method, &n0, dt)
2042            .map_err(|e| PyValueError::new_err(e.to_string()))
2043    }
2044}
2045
2046/// Build a reusable depletion system from a chain and reaction rates.
2047#[pyfunction]
2048fn build_depletion_system(chain: &PyChain, rates: RateMap) -> PyResult<PyDepletionSystem> {
2049    let rs = split_rates(&rates, &chain.inner)?;
2050    nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rs)
2051        .map(|sys| PyDepletionSystem {
2052            inner: std::sync::Arc::new(sys),
2053        })
2054        .map_err(|e| PyValueError::new_err(e.to_string()))
2055}
2056
2057fn parse_order(order: u8) -> PyResult<nucleide_depletion::Order> {
2058    match order {
2059        16 => Ok(nucleide_depletion::Order::Order16),
2060        48 => Ok(nucleide_depletion::Order::Order48),
2061        other => Err(PyValueError::new_err(format!(
2062            "unsupported CRAM order {other} (supported: 16, 48)"
2063        ))),
2064    }
2065}
2066
2067/// Parse a solver `method=` spelling (`"cram16"`, `"cram48"`, `"bateman"`,
2068/// `"bateman_hp"`; case-insensitive, `-`/`_` interchangeable).
2069fn parse_method(name: &str) -> PyResult<nucleide_depletion::Method> {
2070    name.parse().map_err(|e: String| PyValueError::new_err(e))
2071}
2072
2073/// Resolve the legacy `order` (16|48) plus `method=` into a core [`Method`].
2074///
2075/// An explicitly non-default `method` wins; the default `"cram48"` defers to
2076/// `order` so existing `order=16` calls keep working unchanged.
2077fn resolve_method(order: u8, method: &str) -> PyResult<nucleide_depletion::Method> {
2078    let parsed = parse_method(method)?;
2079    if parsed == nucleide_depletion::Method::default_cram() {
2080        parse_order(order).map(nucleide_depletion::Method::Cram)
2081    } else {
2082        Ok(parsed)
2083    }
2084}
2085
2086fn split_rates(
2087    rates: &RateMap,
2088    chain: &nucleide_depletion::Chain,
2089) -> PyResult<nucleide_depletion::ReactionRates> {
2090    let mut out = nucleide_depletion::ReactionRates::new();
2091    for (key, v) in rates {
2092        let (nuc, rx) = key.split_once(':').ok_or_else(|| {
2093            PyValueError::new_err(format!("rate key `{key}` must be `Name:reaction`"))
2094        })?;
2095        let idx = chain
2096            .index_of(nuc)
2097            .ok_or_else(|| PyValueError::new_err(format!("rate for unknown nuclide `{nuc}`")))?;
2098        out.entry(idx).or_default().insert(rx.to_string(), *v);
2099    }
2100    Ok(out)
2101}
2102
2103/// Solve one depletion step with IPF CRAM or the analytic Bateman fast path.
2104///
2105/// `n0` maps nuclide names to initial atom counts; `rates` maps
2106/// `"Name:(n,gamma)"`-style keys to one-group rates [1/s]; `dt` is the step
2107/// length in seconds; `order` is 16 or 48; `method` selects the solver
2108/// kernel (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`, default
2109/// `"cram48"` — an explicitly non-default `method` overrides `order`).
2110/// `Bateman` arms fall back to CRAM-48 on non-decay systems (rates on,
2111/// cyclic topology, near-degenerate half-lives).
2112#[pyfunction]
2113#[pyo3(signature = (chain, n0, dt, rates=None, order=48, method="cram48"))]
2114fn deplete(
2115    chain: &PyChain,
2116    n0: BTreeMap<String, f64>,
2117    dt: f64,
2118    rates: Option<RateMap>,
2119    order: u8,
2120    method: &str,
2121) -> PyResult<BTreeMap<String, f64>> {
2122    let method = resolve_method(order, method)?;
2123    let rates = split_rates(rates.as_ref().unwrap_or(&BTreeMap::new()), &chain.inner)?;
2124    let sys = nucleide_depletion::DepletionSystem::build((*chain.inner).clone(), &rates)
2125        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2126    nucleide_depletion::deplete_with_method(&sys, method, &n0, dt)
2127        .map(|r| r.atoms)
2128        .map_err(|e| PyValueError::new_err(e.to_string()))
2129}
2130
2131// ---------------------------------------------------------------------------
2132// Serpent / FLUKA / variance reduction + writers
2133// ---------------------------------------------------------------------------
2134
2135/// Parse a Serpent .m output file ("res", "dep", or "det") into a plain
2136/// Python dict keyed by variable name. Scalars become floats/strings, vectors
2137/// become 1-D lists, and matrices become 2-D lists of row lists (one row per
2138/// Serpent block). A matrix holding non-numeric values raises `ValueError`.
2139#[pyfunction]
2140fn read_serpent(path: &str, kind: &str) -> PyResult<Py<PyAny>> {
2141    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
2142    let table = match kind {
2143        "res" => nucleide_serpent_io::parse_res(&text),
2144        "dep" => nucleide_serpent_io::parse_dep(&text),
2145        "det" => nucleide_serpent_io::parse_det(&text),
2146        other => {
2147            return Err(PyValueError::new_err(format!(
2148                "kind must be res|dep|det, got `{other}`"
2149            )))
2150        }
2151    }
2152    .map_err(|e| PyValueError::new_err(e.to_string()))?;
2153    fn entry_to_py(py: Python<'_>, e: &nucleide_serpent_io::Entry) -> PyResult<Py<PyAny>> {
2154        use nucleide_serpent_io::Entry as E;
2155        let value = match e {
2156            E::Scalar(nucleide_serpent_io::Value::Num(n)) => {
2157                n.into_pyobject(py).unwrap().unbind().into_any()
2158            }
2159            E::Scalar(nucleide_serpent_io::Value::Str(s)) => {
2160                s.into_pyobject(py).unwrap().unbind().into_any()
2161            }
2162            E::Vector(vs) => vs
2163                .iter()
2164                .map(|v| match v {
2165                    nucleide_serpent_io::Value::Num(n) => {
2166                        n.into_pyobject(py).unwrap().unbind().into_any()
2167                    }
2168                    nucleide_serpent_io::Value::Str(s) => {
2169                        s.into_pyobject(py).unwrap().unbind().into_any()
2170                    }
2171                })
2172                .collect::<Vec<_>>()
2173                .into_pyobject(py)
2174                .unwrap()
2175                .unbind()
2176                .into_any(),
2177            E::Matrix(m) => m
2178                .to_rows_f64()
2179                .map_err(|err| PyValueError::new_err(err.to_string()))?
2180                .into_pyobject(py)
2181                .unwrap()
2182                .unbind()
2183                .into_any(),
2184        };
2185        Ok(value)
2186    }
2187    Python::attach(|py| {
2188        let dict = pyo3::types::PyDict::new(py);
2189        for (k, e) in table.iter() {
2190            dict.set_item(k, entry_to_py(py, e)?)?;
2191        }
2192        Ok(dict.into_any().unbind())
2193    })
2194}
2195
2196/// One FLUKA USRBIN detector.
2197#[pyclass(name = "UsrbinTally")]
2198struct PyUsrbinTally {
2199    inner: nucleide_fluka_io::usrbin::UsrbinTally,
2200}
2201
2202#[pymethods]
2203impl PyUsrbinTally {
2204    #[getter]
2205    fn name(&self) -> &str {
2206        &self.inner.name
2207    }
2208    #[getter]
2209    fn particle(&self) -> &str {
2210        &self.inner.particle
2211    }
2212    #[getter]
2213    fn nx(&self) -> usize {
2214        self.inner.x_info.bins
2215    }
2216    #[getter]
2217    fn ny(&self) -> usize {
2218        self.inner.y_info.bins
2219    }
2220    #[getter]
2221    fn nz(&self) -> usize {
2222        self.inner.z_info.bins
2223    }
2224    #[getter]
2225    fn x_bounds(&self) -> Vec<f64> {
2226        self.inner.x_bounds.clone()
2227    }
2228    #[getter]
2229    fn y_bounds(&self) -> Vec<f64> {
2230        self.inner.y_bounds.clone()
2231    }
2232    #[getter]
2233    fn z_bounds(&self) -> Vec<f64> {
2234        self.inner.z_bounds.clone()
2235    }
2236    /// Scored values, x slowest -> z fastest.
2237    #[getter]
2238    fn data(&self) -> Vec<f64> {
2239        self.inner.part_data.clone()
2240    }
2241    /// Statistical errors, same layout as `data`.
2242    #[getter]
2243    fn error(&self) -> Vec<f64> {
2244        self.inner.error_data.clone()
2245    }
2246    fn dims(&self) -> [usize; 3] {
2247        [self.nx(), self.ny(), self.nz()]
2248    }
2249}
2250
2251/// Parse all USRBIN tallies from a FLUKA .lis file.
2252#[pyfunction]
2253fn read_usrbin(path: &str) -> PyResult<Vec<PyUsrbinTally>> {
2254    let tallies = nucleide_fluka_io::usrbin::read_usrbin_file(path)
2255        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2256    Ok(tallies
2257        .into_iter()
2258        .map(|inner| PyUsrbinTally { inner })
2259        .collect())
2260}
2261
2262/// MAGIC weight-window output.
2263#[pyclass(name = "MagicOutput")]
2264struct PyMagicOutput {
2265    inner: nucleide_vr_tools::magic::MagicOutput,
2266}
2267
2268#[pymethods]
2269impl PyMagicOutput {
2270    /// Flat lower bounds ([ve] in total mode, [ve*g+g] per-group).
2271    #[getter]
2272    fn lower_bounds_ww(&self) -> Vec<f64> {
2273        self.inner.lower_bounds_ww.clone()
2274    }
2275    #[getter]
2276    fn groups_per_ve(&self) -> usize {
2277        self.inner.groups_per_ve
2278    }
2279    #[getter]
2280    fn scale_factors(&self) -> Vec<f64> {
2281        self.inner.scale_factors.clone()
2282    }
2283    #[getter]
2284    fn e_upper_bounds(&self) -> Vec<f64> {
2285        self.inner.e_upper_bounds.clone()
2286    }
2287    #[getter]
2288    fn ww_tag_name(&self) -> &str {
2289        &self.inner.ww_tag_name
2290    }
2291}
2292
2293/// Generate MAGIC weight-window lower bounds from a meshtal tally.
2294#[pyfunction]
2295#[pyo3(signature = (tally, per_group=false, tolerance=0.5))]
2296fn magic(tally: &PyMeshTally, per_group: bool, tolerance: f64) -> PyResult<PyMagicOutput> {
2297    let selection = if per_group {
2298        nucleide_vr_tools::magic::MagicSelection::PerGroup
2299    } else {
2300        nucleide_vr_tools::magic::MagicSelection::Total
2301    };
2302    let params = nucleide_vr_tools::magic::MagicParams {
2303        tolerance,
2304        ..Default::default()
2305    };
2306    nucleide_vr_tools::magic::magic_with(&tally.inner, selection, params)
2307        .map(|inner| PyMagicOutput { inner })
2308        .map_err(|e| PyValueError::new_err(e.to_string()))
2309}
2310
2311/// Walker alias table for discrete sampling.
2312#[pyclass(name = "AliasTable")]
2313struct PyAliasTable {
2314    inner: nucleide_vr_tools::sampling::AliasTable,
2315}
2316
2317#[pymethods]
2318impl PyAliasTable {
2319    /// Build from a probability density (normalized internally).
2320    #[new]
2321    fn new(pdf: Vec<f64>) -> PyResult<Self> {
2322        nucleide_vr_tools::sampling::AliasTable::new(&pdf)
2323            .map(|inner| PyAliasTable { inner })
2324            .map_err(|e| PyValueError::new_err(e.to_string()))
2325    }
2326    /// Sample an index from two uniform random numbers.
2327    fn sample(&self, r1: f64, r2: f64) -> usize {
2328        self.inner.sample(r1, r2)
2329    }
2330    #[getter]
2331    fn pdf(&self) -> Vec<f64> {
2332        self.inner.pdf().to_vec()
2333    }
2334    fn __len__(&self) -> usize {
2335        self.inner.len()
2336    }
2337}
2338
2339/// Mesh source sampler over a meshtal tally (ANALOG/UNIFORM/USER modes).
2340#[pyclass(name = "MeshSourceSampler")]
2341struct PyMeshSourceSampler {
2342    inner: nucleide_vr_tools::sampling::MeshSourceSampler,
2343}
2344
2345#[pymethods]
2346impl PyMeshSourceSampler {
2347    /// mode: "analog" | "uniform" | "user" (user requires user_pdf).
2348    #[new]
2349    #[pyo3(signature = (tally, mode, user_pdf=None))]
2350    fn new(tally: &PyMeshTally, mode: &str, user_pdf: Option<Vec<f64>>) -> PyResult<Self> {
2351        let user = if matches!(mode, "user") {
2352            Some(user_pdf.ok_or_else(|| PyValueError::new_err("user mode needs user_pdf"))?)
2353        } else {
2354            None
2355        };
2356        let m = match mode {
2357            "analog" => nucleide_vr_tools::sampling::Mode::Analog,
2358            "uniform" => nucleide_vr_tools::sampling::Mode::Uniform,
2359            "user" => nucleide_vr_tools::sampling::Mode::User,
2360            other => {
2361                return Err(PyValueError::new_err(format!(
2362                    "mode must be analog|uniform|user, got `{other}`"
2363                )))
2364            }
2365        };
2366        nucleide_vr_tools::sampling::MeshSourceSampler::new(&tally.inner, m, user.as_deref())
2367            .map(|inner| PyMeshSourceSampler { inner })
2368            .map_err(|e| PyValueError::new_err(e.to_string()))
2369    }
2370    /// Sample a voxel; returns dict(index, i, j, k, weight).
2371    fn sample(&self, r1: f64, r2: f64) -> BTreeMap<String, f64> {
2372        let s = self.inner.sample(r1, r2);
2373        let mut d = BTreeMap::new();
2374        d.insert("index".into(), s.index as f64);
2375        d.insert("i".into(), s.i as f64);
2376        d.insert("j".into(), s.j as f64);
2377        d.insert("k".into(), s.k as f64);
2378        d.insert("weight".into(), s.weight);
2379        d
2380    }
2381    /// The bias mode this sampler was constructed with.
2382    fn mode(&self) -> &'static str {
2383        match self.inner.mode() {
2384            nucleide_vr_tools::sampling::Mode::Analog => "analog",
2385            nucleide_vr_tools::sampling::Mode::Uniform => "uniform",
2386            nucleide_vr_tools::sampling::Mode::User => "user",
2387        }
2388    }
2389    /// Number of voxels in the sampling domain.
2390    fn num_voxels(&self) -> usize {
2391        self.inner.num_voxels()
2392    }
2393    /// Length of the underlying alias table (one entry per voxel).
2394    fn table_len(&self) -> usize {
2395        self.inner.table().len()
2396    }
2397}
2398
2399/// Gaussian KDE sampler over caller particle vectors (KDSource-class).
2400#[pyclass(name = "KdeSampler")]
2401struct PyKdeSampler {
2402    inner: nucleide_vr_tools::kde::KdeSampler,
2403}
2404
2405#[pymethods]
2406impl PyKdeSampler {
2407    /// Fit over `samples` (rectangular row lists); `bandwidth` is
2408    /// "silverman" (default) or a per-dimension width list.
2409    #[new]
2410    #[pyo3(signature = (samples, bandwidth=None))]
2411    fn new(samples: Vec<Vec<f64>>, bandwidth: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
2412        let rule = match bandwidth {
2413            None => nucleide_vr_tools::kde::Bandwidth::Silverman,
2414            Some(b) => {
2415                if let Ok(name) = b.extract::<String>() {
2416                    match name.as_str() {
2417                        "silverman" => nucleide_vr_tools::kde::Bandwidth::Silverman,
2418                        other => {
2419                            return Err(PyValueError::new_err(format!(
2420                                "bandwidth must be silverman or a width list, got `{other}`"
2421                            )))
2422                        }
2423                    }
2424                } else {
2425                    let widths = b.extract::<Vec<f64>>().map_err(|_| {
2426                        PyValueError::new_err("bandwidth must be silverman or a width list")
2427                    })?;
2428                    nucleide_vr_tools::kde::Bandwidth::Fixed(widths)
2429                }
2430            }
2431        };
2432        nucleide_vr_tools::kde::KdeSampler::fit(&samples, rule)
2433            .map(|inner| PyKdeSampler { inner })
2434            .map_err(|e| PyValueError::new_err(e.to_string()))
2435    }
2436    /// KDE density at `point`.
2437    fn pdf(&self, point: Vec<f64>) -> PyResult<f64> {
2438        self.inner
2439            .pdf(&point)
2440            .map_err(|e| PyValueError::new_err(e.to_string()))
2441    }
2442    /// Resample: `u` in [0, 1) picks the centre, `normals` perturbs it.
2443    fn draw(&self, u: f64, normals: Vec<f64>) -> PyResult<Vec<f64>> {
2444        self.inner
2445            .draw(u, &normals)
2446            .map_err(|e| PyValueError::new_err(e.to_string()))
2447    }
2448    /// Fitted per-dimension bandwidths.
2449    fn bandwidths(&self) -> Vec<f64> {
2450        self.inner.bandwidths().to_vec()
2451    }
2452    /// Number of fitted samples.
2453    fn n_samples(&self) -> usize {
2454        self.inner.n_samples()
2455    }
2456}
2457
2458/// Write a SurfSrc file back to disk. `tracks` defaults to re-reading the
2459/// original file's tracks.
2460#[pyfunction]
2461#[pyo3(signature = (ssw, path, tracks=None))]
2462fn write_ssw(
2463    ssw: &PySurfSrc,
2464    path: &str,
2465    tracks: Option<Vec<BTreeMap<String, f64>>>,
2466) -> PyResult<()> {
2467    let header = ssw.inner.header.clone();
2468    let track_data: Vec<nucleide_mcnp_io::surfsrc::TrackData> = match tracks {
2469        Some(dict_tracks) => dict_tracks
2470            .iter()
2471            .map(|d| {
2472                let g = |k: &str| d.get(k).copied().unwrap_or(0.0);
2473                let mut record = vec![0.0f64; nucleide_mcnp_io::surfsrc::TrackData::RECORD_WIDTH];
2474                record[0] = g("nps");
2475                record[1] = g("bitarray");
2476                record[2] = g("wgt");
2477                record[3] = g("erg");
2478                record[4] = g("tme");
2479                record[5] = g("x");
2480                record[6] = g("y");
2481                record[7] = g("z");
2482                record[8] = g("u");
2483                record[9] = g("v");
2484                record[10] = g("cs");
2485                nucleide_mcnp_io::surfsrc::TrackData::from_record(record)
2486            })
2487            .collect(),
2488        None => ssw
2489            .inner
2490            .read_tracklist()
2491            .map_err(|e| PyValueError::new_err(e.to_string()))?,
2492    };
2493    let mut f = std::fs::File::create(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
2494    nucleide_mcnp_io::surfsrc::write_to(&mut f, &header, &track_data)
2495        .map_err(|e| PyValueError::new_err(e.to_string()))
2496}
2497
2498/// Generate MCNP input-deck text from a structured mesh.
2499#[pyfunction]
2500fn mesh_to_geom(
2501    x_bounds: Vec<f64>,
2502    y_bounds: Vec<f64>,
2503    z_bounds: Vec<f64>,
2504    cell_materials: Vec<Option<(String, f64)>>,
2505    title_card: &str,
2506) -> String {
2507    let opts = nucleide_mcnp_io::deck::DeckOptions {
2508        title_card: title_card.to_string(),
2509        frac_type: nucleide_mcnp_io::deck::FracType::Mass,
2510    };
2511    nucleide_mcnp_io::deck::mesh_to_geom(&x_bounds, &y_bounds, &z_bounds, &cell_materials, &opts)
2512}
2513
2514// ---------------------------------------------------------------------------
2515// ALARA I/O (thin glue over `alara-io`; solver stays out of scope)
2516// ---------------------------------------------------------------------------
2517
2518/// Parse an ALARA input deck into plain Python containers.
2519///
2520/// Returns a dict with `block_kinds` (list[str] in file order), `geometry`
2521/// (str | None), `mixtures` (list of {name, entries}), `fluxes` (list of
2522/// {name, file, scale, skip, format}), `cooling_times_s` (list[float]),
2523/// `schedules`, `pulse_histories`, `outputs`, and `truncation`.
2524#[pyfunction]
2525fn alara_parse_deck(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
2526    let owned = text.to_owned();
2527    let deck = py
2528        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
2529        .map_err(ala_err)?;
2530    Ok(deck_to_py(py, &deck))
2531}
2532
2533fn ala_err(e: nucleide_alara_io::Error) -> PyErr {
2534    PyValueError::new_err(e.to_string())
2535}
2536
2537fn deck_to_py(py: Python<'_>, deck: &nucleide_alara_io::AlaraDeck) -> Py<PyAny> {
2538    use pyo3::types::PyDict;
2539    let out = PyDict::new(py);
2540    let block_kinds: Vec<&str> = deck.block_kinds();
2541    out.set_item("block_kinds", block_kinds).ok();
2542    out.set_item("geometry", deck.geometry.as_ref().map(|g| g.kind.clone()))
2543        .ok();
2544    let mixtures: Vec<Py<PyAny>> = deck.mixtures.iter().map(|m| mixture_to_py(py, m)).collect();
2545    out.set_item("mixtures", mixtures).ok();
2546    let fluxes: Vec<Py<PyAny>> = deck.fluxes.iter().map(|f| fluxdef_to_py(py, f)).collect();
2547    out.set_item("fluxes", fluxes).ok();
2548    out.set_item(
2549        "cooling_times_s",
2550        deck.cooling
2551            .as_ref()
2552            .map(|c| c.times_s.clone())
2553            .unwrap_or_default(),
2554    )
2555    .ok();
2556    let schedules: Vec<Py<PyAny>> = deck
2557        .schedules
2558        .iter()
2559        .map(|s| {
2560            let d = PyDict::new(py);
2561            let items: Vec<Vec<String>> = s.items.iter().map(|it| it.tokens.clone()).collect();
2562            d.set_item("name", &s.name).ok();
2563            d.set_item("items", items).ok();
2564            d.into_any().unbind()
2565        })
2566        .collect();
2567    out.set_item("schedules", schedules).ok();
2568    let histories: Vec<Py<PyAny>> = deck
2569        .pulse_histories
2570        .iter()
2571        .map(|h| {
2572            let d = PyDict::new(py);
2573            let levels: Vec<Py<PyAny>> = h
2574                .levels
2575                .iter()
2576                .map(|l| {
2577                    let e = PyDict::new(py);
2578                    e.set_item("pulses", l.pulses).ok();
2579                    e.set_item("delay_s", l.delay_s).ok();
2580                    e.into_any().unbind()
2581                })
2582                .collect();
2583            d.set_item("name", &h.name).ok();
2584            d.set_item("levels", levels).ok();
2585            d.into_any().unbind()
2586        })
2587        .collect();
2588    out.set_item("pulse_histories", histories).ok();
2589    let outputs: Vec<Py<PyAny>> = deck
2590        .outputs
2591        .iter()
2592        .map(|o| {
2593            let d = PyDict::new(py);
2594            d.set_item("resolution", &o.resolution).ok();
2595            d.set_item("entries", o.entries.clone()).ok();
2596            d.into_any().unbind()
2597        })
2598        .collect();
2599    out.set_item("outputs", outputs).ok();
2600    out.set_item("truncation", deck.truncation.as_ref().map(|t| t.tolerance))
2601        .ok();
2602    out.into_any().unbind()
2603}
2604
2605fn mixture_to_py(py: Python<'_>, mix: &nucleide_alara_io::deck::Mixture) -> Py<PyAny> {
2606    use pyo3::types::PyDict;
2607    let entries: Vec<Py<PyAny>> = mix
2608        .entries
2609        .iter()
2610        .map(|e| mixture_entry_to_py(py, e))
2611        .collect();
2612    let d = PyDict::new(py);
2613    d.set_item("name", &mix.name).ok();
2614    d.set_item("entries", entries).ok();
2615    d.into_any().unbind()
2616}
2617
2618fn mixture_entry_to_py(py: Python<'_>, entry: &nucleide_alara_io::deck::MixtureEntry) -> Py<PyAny> {
2619    use nucleide_alara_io::deck::MixtureEntry as E;
2620    use pyo3::types::PyDict;
2621    let d = PyDict::new(py);
2622    match entry {
2623        E::Material {
2624            name,
2625            rel_density,
2626            vol_fraction,
2627        } => {
2628            d.set_item("kind", "material").ok();
2629            d.set_item("name", name).ok();
2630            d.set_item("rel_density", *rel_density).ok();
2631            d.set_item("vol_fraction", *vol_fraction).ok();
2632        }
2633        E::Element {
2634            symbol,
2635            rel_density,
2636            vol_fraction,
2637        } => {
2638            d.set_item("kind", "element").ok();
2639            d.set_item("symbol", symbol).ok();
2640            d.set_item("rel_density", *rel_density).ok();
2641            d.set_item("vol_fraction", *vol_fraction).ok();
2642        }
2643        E::Like {
2644            mixture,
2645            rel_density,
2646        } => {
2647            d.set_item("kind", "like").ok();
2648            d.set_item("mixture", mixture).ok();
2649            d.set_item("rel_density", *rel_density).ok();
2650        }
2651        E::Target { target_kind, name } => {
2652            d.set_item("kind", "target").ok();
2653            d.set_item("target_kind", target_kind).ok();
2654            d.set_item("name", name).ok();
2655        }
2656    }
2657    d.into_any().unbind()
2658}
2659
2660fn fluxdef_to_py(py: Python<'_>, flux: &nucleide_alara_io::deck::FluxDef) -> Py<PyAny> {
2661    use pyo3::types::PyDict;
2662    let d = PyDict::new(py);
2663    d.set_item("name", &flux.name).ok();
2664    d.set_item("file", &flux.file).ok();
2665    d.set_item("scale", flux.scale).ok();
2666    d.set_item("skip", flux.skip).ok();
2667    d.set_item("format", &flux.format).ok();
2668    d.into_any().unbind()
2669}
2670
2671/// Parse an ALARA default-format group-flux file into plain containers.
2672///
2673/// Returns a dict with `name`, `groups_per_interval`, `num_intervals`,
2674/// `totals` (per-interval sums), `total` (grand sum), and `intervals`.
2675#[pyfunction]
2676fn alara_parse_flux(py: Python<'_>, text: &str, name: &str) -> PyResult<Py<PyAny>> {
2677    let owned_text = text.to_owned();
2678    let owned_name = name.to_owned();
2679    let spectra = py
2680        .detach(move || nucleide_alara_io::FluxSpectra::parse(&owned_name, &owned_text))
2681        .map_err(ala_err)?;
2682    use pyo3::types::PyDict;
2683    let d = PyDict::new(py);
2684    d.set_item("name", spectra.name.clone()).ok();
2685    d.set_item("groups_per_interval", spectra.groups_per_interval)
2686        .ok();
2687    d.set_item("num_intervals", spectra.num_intervals()).ok();
2688    let totals: Vec<f64> = spectra.intervals.iter().map(|iv| iv.iter().sum()).collect();
2689    d.set_item("totals", totals).ok();
2690    d.set_item("total", spectra.total()).ok();
2691    d.set_item("intervals", spectra.intervals.clone()).ok();
2692    Ok(d.into_any().unbind())
2693}
2694
2695/// Parse an ALARA activation-output listing into a list of row dicts.
2696///
2697/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
2698/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
2699/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
2700#[pyfunction]
2701fn alara_parse_output(
2702    py: Python<'_>,
2703    text: &str,
2704    run_lbl: &str,
2705) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2706    let owned_text = text.to_owned();
2707    let owned_lbl = run_lbl.to_owned();
2708    let rows = py
2709        .detach(move || {
2710            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl).map(|f| f.rows)
2711        })
2712        .map_err(ala_err)?;
2713    Ok(rows
2714        .iter()
2715        .map(|r| {
2716            let mut d = BTreeMap::new();
2717            d.insert(
2718                "time_s".to_string(),
2719                r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
2720            );
2721            d.insert(
2722                "time_label".to_string(),
2723                r.time_label
2724                    .clone()
2725                    .into_pyobject(py)
2726                    .unwrap()
2727                    .unbind()
2728                    .into_any(),
2729            );
2730            d.insert(
2731                "nuclide".to_string(),
2732                r.nuclide
2733                    .clone()
2734                    .into_pyobject(py)
2735                    .unwrap()
2736                    .unbind()
2737                    .into_any(),
2738            );
2739            d.insert(
2740                "half_life_s".to_string(),
2741                r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
2742            );
2743            d.insert(
2744                "run_lbl".to_string(),
2745                r.run_lbl
2746                    .clone()
2747                    .into_pyobject(py)
2748                    .unwrap()
2749                    .unbind()
2750                    .into_any(),
2751            );
2752            d.insert(
2753                "block".to_string(),
2754                r.block
2755                    .as_str()
2756                    .into_pyobject(py)
2757                    .unwrap()
2758                    .unbind()
2759                    .into_any(),
2760            );
2761            d.insert(
2762                "block_name".to_string(),
2763                r.block_name
2764                    .clone()
2765                    .into_pyobject(py)
2766                    .unwrap()
2767                    .unbind()
2768                    .into_any(),
2769            );
2770            d.insert(
2771                "block_num".to_string(),
2772                r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
2773            );
2774            d.insert(
2775                "variable".to_string(),
2776                r.variable
2777                    .as_str()
2778                    .into_pyobject(py)
2779                    .unwrap()
2780                    .unbind()
2781                    .into_any(),
2782            );
2783            d.insert(
2784                "var_unit".to_string(),
2785                r.var_unit
2786                    .clone()
2787                    .into_pyobject(py)
2788                    .unwrap()
2789                    .unbind()
2790                    .into_any(),
2791            );
2792            d.insert(
2793                "value".to_string(),
2794                r.value.into_pyobject(py).unwrap().unbind().into_any(),
2795            );
2796            d
2797        })
2798        .collect())
2799}
2800
2801/// Expand a deck's schedule hierarchy into flat irradiation/cooling steps.
2802///
2803/// Choice: takes deck text (plus optional top schedule name) instead of JSON
2804/// schedule/history blobs, so callers reuse the already-parsed deck blocks
2805/// without a parallel JSON schema. Returns a list of
2806/// {duration_s, flux, is_cooling} dicts.
2807#[pyfunction]
2808#[pyo3(signature = (deck_text, top=None))]
2809fn alara_expand_schedule(
2810    py: Python<'_>,
2811    deck_text: &str,
2812    top: Option<&str>,
2813) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2814    let owned_text = deck_text.to_owned();
2815    let owned_top = top.map(str::to_owned);
2816    let steps = py
2817        .detach(move || expand_deck_schedules(&owned_text, owned_top.as_deref()))
2818        .map_err(PyValueError::new_err)?;
2819    Ok(steps
2820        .into_iter()
2821        .map(|s| {
2822            let mut d = BTreeMap::new();
2823            let cooling = s.is_cooling();
2824            d.insert(
2825                "duration_s".to_string(),
2826                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
2827            );
2828            d.insert(
2829                "flux".to_string(),
2830                s.flux
2831                    .clone()
2832                    .into_pyobject(py)
2833                    .unwrap()
2834                    .unbind()
2835                    .into_any(),
2836            );
2837            d.insert(
2838                "is_cooling".to_string(),
2839                pyo3::types::PyBool::new(py, cooling)
2840                    .to_owned()
2841                    .into_any()
2842                    .unbind(),
2843            );
2844            d
2845        })
2846        .collect())
2847}
2848
2849fn expand_deck_schedules(
2850    deck_text: &str,
2851    top: Option<&str>,
2852) -> Result<Vec<nucleide_alara_io::FlatStep>, String> {
2853    let deck = nucleide_alara_io::AlaraDeck::parse(deck_text).map_err(|e| e.to_string())?;
2854    let mut scheds = Vec::with_capacity(deck.schedules.len());
2855    for raw in &deck.schedules {
2856        let mut items = Vec::with_capacity(raw.items.len());
2857        for entry in &raw.items {
2858            items.push(
2859                parse_deck_sched_item(&entry.tokens)
2860                    .map_err(|m| format!("schedule `{}` line {}: {m}", raw.name, entry.line))?,
2861            );
2862        }
2863        scheds.push(nucleide_alara_io::schedule::ScheduleDef {
2864            name: raw.name.clone(),
2865            items,
2866        });
2867    }
2868    let histories: Vec<nucleide_alara_io::schedule::PulseHistory> = deck
2869        .pulse_histories
2870        .iter()
2871        .map(|h| nucleide_alara_io::schedule::PulseHistory {
2872            name: h.name.clone(),
2873            levels: h
2874                .levels
2875                .iter()
2876                .map(|l| nucleide_alara_io::schedule::PulseLevel {
2877                    count: l.pulses,
2878                    delay_s: l.delay_s,
2879                })
2880                .collect(),
2881        })
2882        .collect();
2883    match top {
2884        Some(name) => {
2885            nucleide_alara_io::expand_from(name, &scheds, &histories).map_err(|e| e.to_string())
2886        }
2887        None => nucleide_alara_io::expand(&scheds, &histories).map_err(|e| e.to_string()),
2888    }
2889}
2890
2891fn parse_deck_sched_item(tokens: &[String]) -> Result<nucleide_alara_io::SchedItem, String> {
2892    match tokens {
2893        [op_text, op_unit, flux, history, delay_text, delay_unit] => {
2894            let op: f64 = op_text
2895                .parse()
2896                .map_err(|_| format!("expected operating time, found `{op_text}`"))?;
2897            let delay: f64 = delay_text
2898                .parse()
2899                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2900            let op_time_s =
2901                nucleide_alara_io::parse_time_to_seconds(op, op_unit).map_err(|e| e.to_string())?;
2902            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2903                .map_err(|e| e.to_string())?;
2904            Ok(nucleide_alara_io::SchedItem::Pulse {
2905                op_time_s,
2906                flux: flux.clone(),
2907                history: history.clone(),
2908                delay_s,
2909            })
2910        }
2911        [name, history, delay_text, delay_unit] => {
2912            let delay: f64 = delay_text
2913                .parse()
2914                .map_err(|_| format!("expected delay, found `{delay_text}`"))?;
2915            let delay_s = nucleide_alara_io::parse_time_to_seconds(delay, delay_unit)
2916                .map_err(|e| e.to_string())?;
2917            Ok(nucleide_alara_io::SchedItem::SubSchedule {
2918                name: name.clone(),
2919                history: history.clone(),
2920                delay_s,
2921            })
2922        }
2923        _ => Err(format!(
2924            "expected 4- or 6-token schedule item, found {}",
2925            tokens.join(" ")
2926        )),
2927    }
2928}
2929
2930// ---------------------------------------------------------------------------
2931// Data accessors, input parsing, enrichment, materials
2932// ---------------------------------------------------------------------------
2933
2934/// Half-life [s] for a nucid integer or name string.
2935#[pyfunction]
2936fn half_life(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2937    lookup(key, nucleide_nuclei::data::half_life)
2938}
2939
2940/// Decay constant lambda = ln2 / t_half [1/s].
2941#[pyfunction]
2942fn decay_constant(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2943    lookup(key, nucleide_nuclei::data::decay_constant)
2944}
2945
2946/// Neutron-capture Q value computed from AME2020 masses [MeV].
2947#[pyfunction]
2948fn q_value_capture(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2949    lookup(key, nucleide_nuclei::data::q_value_neutron_capture)
2950}
2951
2952/// Alpha-decay Q value from AME2020 masses [MeV].
2953#[pyfunction]
2954fn q_value_alpha(key: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
2955    lookup(key, nucleide_nuclei::data::q_value_alpha)
2956}
2957
2958/// Parse MCNP material cards from an input deck.
2959/// Returns a list of dicts: {number, fractions: {NuclideName: frac},
2960/// fraction_type: "atom"|"mass", density, comments}.
2961#[pyfunction]
2962fn read_inp(path: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
2963    let mats = nucleide_mcnp_io::inp::materials_from_file(path)
2964        .map_err(|e| PyValueError::new_err(e.to_string()))?;
2965    Python::attach(|py| {
2966        Ok(mats
2967            .into_iter()
2968            .map(|m| {
2969                let mut d = BTreeMap::new();
2970                d.insert(
2971                    "number".to_string(),
2972                    m.number.into_pyobject(py).unwrap().unbind().into_any(),
2973                );
2974                let fr: BTreeMap<String, f64> = m
2975                    .fractions
2976                    .iter()
2977                    .map(|(id, f)| (id.to_name(), *f))
2978                    .collect();
2979                d.insert(
2980                    "fractions".to_string(),
2981                    fr.into_pyobject(py).unwrap().unbind().into_any(),
2982                );
2983                d.insert(
2984                    "fraction_type".to_string(),
2985                    match m.fraction_type {
2986                        nucleide_mcnp_io::inp::FracKind::Atom => "atom",
2987                        nucleide_mcnp_io::inp::FracKind::Mass => "mass",
2988                    }
2989                    .into_pyobject(py)
2990                    .unwrap()
2991                    .unbind()
2992                    .into_any(),
2993                );
2994                d.insert(
2995                    "density".to_string(),
2996                    m.density.into_pyobject(py).unwrap().unbind().into_any(),
2997                );
2998                d.insert(
2999                    "comments".to_string(),
3000                    m.comments
3001                        .join(" ")
3002                        .into_pyobject(py)
3003                        .unwrap()
3004                        .unbind()
3005                        .into_any(),
3006                );
3007                d
3008            })
3009            .collect())
3010    })
3011}
3012
3013fn comp_to_material(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
3014    let mut mat = nucleide_material::Material::new();
3015    for (name, grams) in &comp {
3016        let id = nucleide_nuclei::NuclideId::from_name(name)
3017            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
3018        mat.add_nuclide(id, *grams);
3019    }
3020    Ok(mat)
3021}
3022
3023/// Expand a chemical formula into a natural-isotope composition dict
3024/// ({nuclide_name: atom_fraction}) using AME2020 masses + abundances.
3025#[pyfunction]
3026fn from_formula(formula: &str) -> PyResult<BTreeMap<String, f64>> {
3027    use nucleide_material::AbundanceProvider;
3028    let parsed = nucleide_material::parse_formula(formula)
3029        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3030    // Build a temporary element-count material then expand via abundances:
3031    let mut nat = Vec::new();
3032    for (z, count) in &parsed {
3033        if let Some(isotopes) = nucleide_material::NaturalAbundances.natural_isotopes(*z) {
3034            for (id, frac) in isotopes {
3035                nat.push((id, frac * count));
3036            }
3037        }
3038    }
3039    let total: f64 = nat.iter().map(|(_, c)| c).sum();
3040    if total <= 0.0 {
3041        return Err(PyValueError::new_err("empty formula expansion"));
3042    }
3043    let mut out: BTreeMap<String, f64> = BTreeMap::new();
3044    for (id, atoms) in nat {
3045        *out.entry(id.to_name()).or_insert(0.0) += atoms / total;
3046    }
3047    Ok(out)
3048}
3049
3050/// Activity [Bq] per nuclide plus whole-material specific activity.
3051/// Returns {name: Bq} entries and "specific" = Bq/g of the composition.
3052#[pyfunction]
3053fn activity(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
3054    let mat = comp_to_material(comp)?;
3055    let analytics = nucleide_material::Analytics {
3056        masses: &nucleide_material::Ame2020,
3057        decays: &nucleide_material::ChainDecays,
3058    };
3059    let per_nuc = mat
3060        .activity(&analytics)
3061        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3062    let specific = mat
3063        .specific_activity(&analytics)
3064        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3065    let mut out: BTreeMap<String, f64> = per_nuc
3066        .into_iter()
3067        .map(|(id, v)| (id.to_name(), v))
3068        .collect();
3069    out.insert("specific".to_string(), specific);
3070    Ok(out)
3071}
3072
3073/// Serialize a composition dictionary to a `<material>` XML fragment.
3074#[pyfunction]
3075fn to_xml(comp: BTreeMap<String, f64>, name: &str, density: f64, units: &str) -> PyResult<String> {
3076    let mat = comp_to_material(comp)?;
3077    mat.to_xml(name, density, units)
3078        .map_err(|e| PyValueError::new_err(e.to_string()))
3079}
3080
3081/// Enrichment cascade with numeric multicomponent solving.
3082#[pyclass(name = "Cascade")]
3083struct PyCascade {
3084    inner: std::sync::Mutex<nucleide_enrichment::Cascade>,
3085}
3086
3087#[pymethods]
3088impl PyCascade {
3089    /// Natural-uranium default cascade (alpha=1.05, Mstar=236, j=U235, k=U238).
3090    #[staticmethod]
3091    fn default_uranium() -> Self {
3092        Self {
3093            inner: std::sync::Mutex::new(nucleide_enrichment::default_uranium_cascade()),
3094        }
3095    }
3096
3097    /// Build a cascade from full parameters. `mat_feed` is a dict of
3098    /// nuclide-name strings to mass fractions.
3099    #[new]
3100    #[allow(non_snake_case)]
3101    #[allow(clippy::too_many_arguments)]
3102    fn new(
3103        alpha: f64,
3104        Mstar: f64,
3105        j: u32,
3106        k: u32,
3107        N: f64,
3108        M: f64,
3109        x_feed_j: f64,
3110        x_prod_j: f64,
3111        x_tail_j: f64,
3112        mat_feed: BTreeMap<String, f64>,
3113    ) -> PyResult<Self> {
3114        let mut feed = BTreeMap::new();
3115        for (name, frac) in mat_feed {
3116            let id = NuclideId::from_name(&name).map_err(wrap_nucid_err)?;
3117            feed.insert(id, frac);
3118        }
3119        let casc = nucleide_enrichment::Cascade {
3120            alpha,
3121            Mstar,
3122            j: NuclideId::from_nucid(j),
3123            k: NuclideId::from_nucid(k),
3124            N,
3125            M,
3126            x_feed_j,
3127            x_prod_j,
3128            x_tail_j,
3129            mat_feed: nucleide_enrichment::Stream::with_total_mass(feed, 1.0),
3130            mat_prod: nucleide_enrichment::Stream::new(),
3131            mat_tail: nucleide_enrichment::Stream::new(),
3132            l_t_per_feed: 0.0,
3133            swu_per_feed: 0.0,
3134            swu_per_prod: 0.0,
3135        };
3136        Ok(Self {
3137            inner: std::sync::Mutex::new(casc),
3138        })
3139    }
3140
3141    /// Solve via the numeric fixed-point + secant scheme in place.
3142    #[pyo3(signature = (tolerance=None, max_iterations=None))]
3143    fn solve(&self, tolerance: Option<f64>, max_iterations: Option<u32>) -> PyResult<()> {
3144        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
3145        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
3146        let mut c = self
3147            .inner
3148            .lock()
3149            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3150        *c = nucleide_enrichment::solve_numeric(&c, tol, iters)
3151            .map_err(|e| PyValueError::new_err(e.to_string()))?;
3152        Ok(())
3153    }
3154
3155    /// Solve and optimize `M*` for a multicomponent feed in place.
3156    #[pyo3(signature = (tolerance=None, max_iterations=None))]
3157    fn solve_multicomponent(
3158        &self,
3159        tolerance: Option<f64>,
3160        max_iterations: Option<u32>,
3161    ) -> PyResult<()> {
3162        let tol = tolerance.unwrap_or(nucleide_enrichment::DEFAULT_TOLERANCE);
3163        let iters = max_iterations.unwrap_or(nucleide_enrichment::DEFAULT_MAX_ITER);
3164        let mut c = self
3165            .inner
3166            .lock()
3167            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3168        *c = nucleide_enrichment::multicomponent(&c, tol, iters)
3169            .map_err(|e| PyValueError::new_err(e.to_string()))?;
3170        Ok(())
3171    }
3172
3173    #[getter]
3174    fn alpha(&self) -> PyResult<f64> {
3175        Ok(self
3176            .inner
3177            .lock()
3178            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3179            .alpha)
3180    }
3181    #[getter]
3182    #[allow(non_snake_case)]
3183    fn Mstar(&self) -> PyResult<f64> {
3184        Ok(self
3185            .inner
3186            .lock()
3187            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3188            .Mstar)
3189    }
3190    #[getter]
3191    #[allow(non_snake_case)]
3192    fn N(&self) -> PyResult<f64> {
3193        Ok(self
3194            .inner
3195            .lock()
3196            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3197            .N)
3198    }
3199    #[getter]
3200    #[allow(non_snake_case)]
3201    fn M(&self) -> PyResult<f64> {
3202        Ok(self
3203            .inner
3204            .lock()
3205            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3206            .M)
3207    }
3208    #[getter]
3209    fn x_feed_j(&self) -> PyResult<f64> {
3210        Ok(self
3211            .inner
3212            .lock()
3213            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3214            .x_feed_j)
3215    }
3216    #[getter]
3217    fn x_prod_j(&self) -> PyResult<f64> {
3218        Ok(self
3219            .inner
3220            .lock()
3221            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3222            .x_prod_j)
3223    }
3224    #[getter]
3225    fn x_tail_j(&self) -> PyResult<f64> {
3226        Ok(self
3227            .inner
3228            .lock()
3229            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3230            .x_tail_j)
3231    }
3232    #[getter]
3233    fn l_t_per_feed(&self) -> PyResult<f64> {
3234        Ok(self
3235            .inner
3236            .lock()
3237            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3238            .l_t_per_feed)
3239    }
3240    #[getter]
3241    fn swu_per_feed(&self) -> PyResult<f64> {
3242        Ok(self
3243            .inner
3244            .lock()
3245            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3246            .swu_per_feed)
3247    }
3248    #[getter]
3249    fn swu_per_prod(&self) -> PyResult<f64> {
3250        Ok(self
3251            .inner
3252            .lock()
3253            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3254            .swu_per_prod)
3255    }
3256    /// Feed composition as {nuclide_name: mass_fraction}.
3257    #[getter]
3258    fn mat_feed(&self) -> PyResult<BTreeMap<String, f64>> {
3259        Ok(self
3260            .inner
3261            .lock()
3262            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3263            .mat_feed
3264            .comp
3265            .iter()
3266            .map(|(id, frac)| (id.to_name(), *frac))
3267            .collect())
3268    }
3269    /// Product composition as {nuclide_name: mass_fraction}.
3270    #[getter]
3271    fn mat_prod(&self) -> PyResult<BTreeMap<String, f64>> {
3272        Ok(self
3273            .inner
3274            .lock()
3275            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3276            .mat_prod
3277            .comp
3278            .iter()
3279            .map(|(id, frac)| (id.to_name(), *frac))
3280            .collect())
3281    }
3282    /// Tails composition as {nuclide_name: mass_fraction}.
3283    #[getter]
3284    fn mat_tail(&self) -> PyResult<BTreeMap<String, f64>> {
3285        Ok(self
3286            .inner
3287            .lock()
3288            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?
3289            .mat_tail
3290            .comp
3291            .iter()
3292            .map(|(id, frac)| (id.to_name(), *frac))
3293            .collect())
3294    }
3295    /// Separative work per product [kg SWU/kg] from the key assays.
3296    fn separative_work_per_product(&self) -> PyResult<f64> {
3297        let c = self
3298            .inner
3299            .lock()
3300            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3301        Ok(nucleide_enrichment::swu_per_prod(
3302            c.x_feed_j, c.x_prod_j, c.x_tail_j,
3303        ))
3304    }
3305
3306    fn __repr__(&self) -> PyResult<String> {
3307        let c = self
3308            .inner
3309            .lock()
3310            .map_err(|_| PyValueError::new_err("cascade lock poisoned"))?;
3311        Ok(format!(
3312            "Cascade(alpha={}, Mstar={}, x_prod_j={:.5})",
3313            c.alpha, c.Mstar, c.x_prod_j
3314        ))
3315    }
3316}
3317
3318/// Dirac separation potential `V(x) = (2x - 1) ln(x / (1 - x))`.
3319///
3320/// Thin wrapper over `nucleide_enrichment::value_func`.
3321#[pyfunction]
3322fn enrichment_value_func(x: f64) -> f64 {
3323    nucleide_enrichment::value_func(x)
3324}
3325
3326/// SWU per unit mass of feed for assays `x_feed`, `x_prod`, `x_tail`.
3327///
3328/// Thin wrapper over `nucleide_enrichment::swu_per_feed`.
3329#[pyfunction]
3330fn enrichment_swu_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3331    nucleide_enrichment::swu_per_feed(x_feed, x_prod, x_tail)
3332}
3333
3334/// SWU per unit mass of product for assays `x_feed`, `x_prod`, `x_tail`.
3335///
3336/// Thin wrapper over `nucleide_enrichment::swu_per_prod`.
3337#[pyfunction]
3338fn enrichment_swu_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3339    nucleide_enrichment::swu_per_prod(x_feed, x_prod, x_tail)
3340}
3341
3342/// SWU per unit mass of tails for assays `x_feed`, `x_prod`, `x_tail`.
3343///
3344/// Thin wrapper over `nucleide_enrichment::swu_per_tail`.
3345#[pyfunction]
3346fn enrichment_swu_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
3347    nucleide_enrichment::swu_per_tail(x_feed, x_prod, x_tail)
3348}
3349
3350/// PNNL/DOE Materials Compendium library (411 named materials).
3351#[pyclass(name = "MaterialsCompendium")]
3352struct PyMaterialsCompendium {
3353    inner: nucleide_material::MaterialsLibrary,
3354}
3355
3356#[pymethods]
3357impl PyMaterialsCompendium {
3358    /// Load from the official MaterialsCompendium.json.
3359    #[staticmethod]
3360    fn load(path: &str) -> PyResult<Self> {
3361        nucleide_material::MaterialsLibrary::from_file(path)
3362            .map(|inner| PyMaterialsCompendium { inner })
3363            .map_err(|e| PyValueError::new_err(e.to_string()))
3364    }
3365
3366    fn __len__(&self) -> usize {
3367        self.inner.len()
3368    }
3369
3370    /// All display names in file order.
3371    fn names(&self) -> Vec<String> {
3372        self.inner.names().into_iter().map(String::from).collect()
3373    }
3374
3375    /// Case-insensitive lookup by name; returns
3376    /// {name, mat_num, density, fractions: {ZAID: weight_fraction}} or None.
3377    /// With as_material=True fractions are keyed by nuclide name instead.
3378    #[pyo3(signature = (name, as_material=false))]
3379    #[allow(clippy::type_complexity)]
3380    fn get(&self, name: &str, as_material: bool) -> PyResult<Option<BTreeMap<String, Py<PyAny>>>> {
3381        let entry = match self.inner.get(name) {
3382            Some(e) => e,
3383            None => return Ok(None),
3384        };
3385        // Material conversion needs no GIL; do it before attaching.
3386        let named_fractions = if as_material {
3387            Some(
3388                entry
3389                    .to_material()
3390                    .map_err(|e| PyValueError::new_err(e.to_string()))?,
3391            )
3392        } else {
3393            None
3394        };
3395
3396        Ok(Python::attach(|py| {
3397            let mut d: BTreeMap<String, Py<PyAny>> = BTreeMap::new();
3398            d.insert(
3399                "name".into(),
3400                entry
3401                    .name
3402                    .as_str()
3403                    .into_pyobject(py)
3404                    .unwrap()
3405                    .unbind()
3406                    .into_any(),
3407            );
3408            d.insert(
3409                "mat_num".into(),
3410                entry.mat_num.into_pyobject(py).unwrap().unbind().into_any(),
3411            );
3412            d.insert(
3413                "density".into(),
3414                entry.density.into_pyobject(py).unwrap().unbind().into_any(),
3415            );
3416            match &named_fractions {
3417                Some(mat) => {
3418                    let fr: BTreeMap<String, f64> =
3419                        mat.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
3420                    d.insert(
3421                        "fractions".into(),
3422                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3423                    );
3424                }
3425                None => {
3426                    let fr = entry.weight_fractions();
3427                    d.insert(
3428                        "fractions".into(),
3429                        fr.into_pyobject(py).unwrap().unbind().into_any(),
3430                    );
3431                }
3432            }
3433            Some(d)
3434        }))
3435    }
3436}
3437
3438// ---------------------------------------------------------------------------
3439// CCCC I/O (thin glue over `cccc-io`; no solver)
3440// ---------------------------------------------------------------------------
3441
3442/// Parse ISOTXS text into plain Python containers.
3443///
3444/// Returns a dict with `nuclides` (list of {label, zaid, groups, total_xs}
3445/// in file order).
3446#[pyfunction]
3447fn isotxs_parse(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3448    let owned = text.to_owned();
3449    let lib = py
3450        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3451        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3452    Ok(isotxs_to_py(py, &lib))
3453}
3454
3455fn isotxs_to_py(py: Python<'_>, lib: &nucleide_cccc_io::IsotxsLib) -> Py<PyAny> {
3456    use pyo3::types::PyDict;
3457    let out = PyDict::new(py);
3458    let nuclides: Vec<Py<PyAny>> = lib
3459        .nuclides
3460        .iter()
3461        .map(|n| {
3462            let d = PyDict::new(py);
3463            d.set_item("label", &n.label).ok();
3464            d.set_item("zaid", &n.zaid).ok();
3465            d.set_item("groups", n.groups).ok();
3466            d.set_item("total_xs", n.total_xs.clone()).ok();
3467            d.into_any().unbind()
3468        })
3469        .collect();
3470    out.set_item("nuclides", nuclides).ok();
3471    out.into_any().unbind()
3472}
3473
3474/// Parse an RTFLUX/ATFLUX/RZFLUX flux file into plain containers.
3475///
3476/// `kind` selects the expected header keyword (`rtflux`|`atflux`|`rzflux`,
3477/// case-insensitive). Returns a dict with `kind`, `groups`, `per_point`,
3478/// `npoints`, `values`, and `total`.
3479#[pyfunction]
3480#[pyo3(signature = (text, kind="rtflux"))]
3481fn rtflux_parse(py: Python<'_>, text: &str, kind: &str) -> PyResult<Py<PyAny>> {
3482    let flux_kind = match kind.to_ascii_lowercase().as_str() {
3483        "rtflux" => nucleide_cccc_io::rtflux::FluxKind::Rtflux,
3484        "atflux" => nucleide_cccc_io::rtflux::FluxKind::Atflux,
3485        "rzflux" => nucleide_cccc_io::rtflux::FluxKind::Rzflux,
3486        other => {
3487            return Err(PyValueError::new_err(format!(
3488                "kind must be rtflux|atflux|rzflux, got `{other}`"
3489            )))
3490        }
3491    };
3492    let owned = text.to_owned();
3493    let flux = py
3494        .detach(move || nucleide_cccc_io::FluxFile::parse(flux_kind, &owned))
3495        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3496    use pyo3::types::PyDict;
3497    let d = PyDict::new(py);
3498    d.set_item("kind", flux.kind.keyword()).ok();
3499    d.set_item("groups", flux.groups).ok();
3500    d.set_item("per_point", flux.per_point).ok();
3501    d.set_item("npoints", flux.npoints()).ok();
3502    d.set_item("values", flux.values.clone()).ok();
3503    d.set_item("total", flux.total()).ok();
3504    Ok(d.into_any().unbind())
3505}
3506
3507fn partisn_deck_from_dict(
3508    deck: &Bound<'_, pyo3::types::PyDict>,
3509) -> PyResult<nucleide_cccc_io::PartisnDeck> {
3510    let title: String = match deck.get_item("title")? {
3511        Some(v) => v
3512            .extract()
3513            .map_err(|_| PyValueError::new_err("partisn deck `title` must be str"))?,
3514        None => return Err(PyValueError::new_err("partisn deck missing `title`")),
3515    };
3516    let dim: u8 = match deck.get_item("dim")? {
3517        Some(v) => v
3518            .extract()
3519            .map_err(|_| PyValueError::new_err("partisn deck `dim` must be 1, 2, or 3"))?,
3520        None => return Err(PyValueError::new_err("partisn deck missing `dim`")),
3521    };
3522    let zones_value = match deck.get_item("zones")? {
3523        Some(v) => v,
3524        None => return Err(PyValueError::new_err("partisn deck missing `zones`")),
3525    };
3526    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = zones_value
3527        .extract()
3528        .map_err(|_| PyValueError::new_err("partisn deck `zones` must be a list of dicts"))?;
3529    let mut zones = Vec::with_capacity(zone_dicts.len());
3530    for z in &zone_dicts {
3531        let id: u32 = match z.get_item("id")? {
3532            Some(v) => v
3533                .extract()
3534                .map_err(|_| PyValueError::new_err("partisn zone `id` must be int"))?,
3535            None => return Err(PyValueError::new_err("partisn zone missing `id`")),
3536        };
3537        let material: String = match z.get_item("material")? {
3538            Some(v) => v
3539                .extract()
3540                .map_err(|_| PyValueError::new_err("partisn zone `material` must be str"))?,
3541            None => return Err(PyValueError::new_err("partisn zone missing `material`")),
3542        };
3543        let isotxs_labels: Vec<String> = match z.get_item("isotxs_labels")? {
3544            Some(v) => v.extract().map_err(|_| {
3545                PyValueError::new_err("partisn zone `isotxs_labels` must be a list of str")
3546            })?,
3547            None => {
3548                return Err(PyValueError::new_err(
3549                    "partisn zone missing `isotxs_labels`",
3550                ))
3551            }
3552        };
3553        let density: f64 = match z.get_item("density")? {
3554            Some(v) => v
3555                .extract()
3556                .map_err(|_| PyValueError::new_err("partisn zone `density` must be float"))?,
3557            None => return Err(PyValueError::new_err("partisn zone missing `density`")),
3558        };
3559        zones.push(nucleide_cccc_io::partisn::PartisnZone {
3560            id,
3561            material,
3562            isotxs_labels,
3563            density,
3564        });
3565    }
3566    let source: Option<String> = match deck.get_item("source")? {
3567        Some(v) if v.is_none() => None,
3568        Some(v) => Some(
3569            v.extract()
3570                .map_err(|_| PyValueError::new_err("partisn deck `source` must be str or None"))?,
3571        ),
3572        None => None,
3573    };
3574    Ok(nucleide_cccc_io::PartisnDeck {
3575        title,
3576        dim,
3577        zones,
3578        source,
3579    })
3580}
3581
3582/// Render a PARTISN deck dict to PARTISN input text.
3583///
3584/// Deck shape: {title: str, dim: 1|2|3, zones: [{id, material,
3585/// isotxs_labels, density}], source: str | None}.
3586#[pyfunction]
3587fn partisn_render(py: Python<'_>, deck: &Bound<'_, pyo3::types::PyDict>) -> PyResult<String> {
3588    let rust_deck = partisn_deck_from_dict(deck)?;
3589    Ok(py.detach(move || rust_deck.render()))
3590}
3591
3592/// Validate a PARTISN deck dict against ISOTXS text.
3593///
3594/// Raises `ValueError` when `dim` is not 1/2/3 or a zone names an ISOTXS
3595/// label absent from the library.
3596#[pyfunction]
3597fn partisn_validate(
3598    py: Python<'_>,
3599    deck: &Bound<'_, pyo3::types::PyDict>,
3600    isotxs_text: &str,
3601) -> PyResult<()> {
3602    let rust_deck = partisn_deck_from_dict(deck)?;
3603    let owned = isotxs_text.to_owned();
3604    let lib = py
3605        .detach(move || nucleide_cccc_io::IsotxsLib::parse(&owned))
3606        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3607    rust_deck
3608        .validate(&lib)
3609        .map_err(|e| PyValueError::new_err(e.to_string()))
3610}
3611
3612// ---------------------------------------------------------------------------
3613// FISPACT-II output (thin glue over `fispact-io`; reuses ResponseFrame)
3614// ---------------------------------------------------------------------------
3615
3616fn fispact_row_to_map(
3617    py: Python<'_>,
3618    r: &nucleide_alara_io::output::ResponseRow,
3619) -> BTreeMap<String, Py<PyAny>> {
3620    let mut d = BTreeMap::new();
3621    d.insert(
3622        "time_s".to_string(),
3623        r.time_s.into_pyobject(py).unwrap().unbind().into_any(),
3624    );
3625    d.insert(
3626        "time_label".to_string(),
3627        r.time_label
3628            .clone()
3629            .into_pyobject(py)
3630            .unwrap()
3631            .unbind()
3632            .into_any(),
3633    );
3634    d.insert(
3635        "nuclide".to_string(),
3636        r.nuclide
3637            .clone()
3638            .into_pyobject(py)
3639            .unwrap()
3640            .unbind()
3641            .into_any(),
3642    );
3643    d.insert(
3644        "half_life_s".to_string(),
3645        r.half_life_s.into_pyobject(py).unwrap().unbind().into_any(),
3646    );
3647    d.insert(
3648        "run_lbl".to_string(),
3649        r.run_lbl
3650            .clone()
3651            .into_pyobject(py)
3652            .unwrap()
3653            .unbind()
3654            .into_any(),
3655    );
3656    d.insert(
3657        "block".to_string(),
3658        r.block
3659            .as_str()
3660            .into_pyobject(py)
3661            .unwrap()
3662            .unbind()
3663            .into_any(),
3664    );
3665    d.insert(
3666        "block_name".to_string(),
3667        r.block_name
3668            .clone()
3669            .into_pyobject(py)
3670            .unwrap()
3671            .unbind()
3672            .into_any(),
3673    );
3674    d.insert(
3675        "block_num".to_string(),
3676        r.block_num.into_pyobject(py).unwrap().unbind().into_any(),
3677    );
3678    d.insert(
3679        "variable".to_string(),
3680        r.variable
3681            .as_str()
3682            .into_pyobject(py)
3683            .unwrap()
3684            .unbind()
3685            .into_any(),
3686    );
3687    d.insert(
3688        "var_unit".to_string(),
3689        r.var_unit
3690            .clone()
3691            .into_pyobject(py)
3692            .unwrap()
3693            .unbind()
3694            .into_any(),
3695    );
3696    d.insert(
3697        "value".to_string(),
3698        r.value.into_pyobject(py).unwrap().unbind().into_any(),
3699    );
3700    d
3701}
3702
3703/// Parse a FISPACT-II inventory listing into a list of row dicts.
3704///
3705/// Each row carries the 11 `ResponseRow` fields as plain floats/strings/ints:
3706/// `time_s`, `time_label`, `nuclide`, `half_life_s`, `run_lbl`, `block`,
3707/// `block_name`, `block_num`, `variable`, `var_unit`, `value`.
3708#[pyfunction]
3709fn fispact_parse_output(
3710    py: Python<'_>,
3711    text: &str,
3712    run_lbl: &str,
3713) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3714    let owned_text = text.to_owned();
3715    let owned_lbl = run_lbl.to_owned();
3716    let rows = py
3717        .detach(move || {
3718            nucleide_fispact_io::parse_to_frame(&owned_text, &owned_lbl).map(|f| f.rows)
3719        })
3720        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3721    Ok(rows.iter().map(|r| fispact_row_to_map(py, r)).collect())
3722}
3723
3724/// Parse the FISPACT-II clearance block (wide `HAZARDS` + `CLEAR` inventory
3725/// table) into a list of row dicts.
3726///
3727/// Each row carries: `interval` (int), `time_s`, `time_label`, `cooling`
3728/// (bool), `nuclide` (dialect spelling, e.g. `"Co-60"`, `"Rb-86m"`), `flags`
3729/// (str), `activity_bq`, `clearance_index`, `half_life_s` (`-1.0` for
3730/// `Stable`). The grammar is the real FISPACT-II main-output inventory
3731/// section (see the `fispact-io` `clearance` module docs for the citable
3732/// on-disk source). Raises `ValueError` on malformed headers/rows.
3733#[pyfunction]
3734fn fispact_parse_clearance(
3735    py: Python<'_>,
3736    text: &str,
3737) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3738    let owned_text = text.to_owned();
3739    let scan = py
3740        .detach(move || nucleide_fispact_io::parse_clearance(&owned_text))
3741        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3742    Ok(scan
3743        .rows
3744        .iter()
3745        .map(|row| {
3746            let mut d = BTreeMap::new();
3747            d.insert(
3748                "interval".to_string(),
3749                row.interval.into_pyobject(py).unwrap().unbind().into_any(),
3750            );
3751            d.insert(
3752                "time_s".to_string(),
3753                row.time_s.into_pyobject(py).unwrap().unbind().into_any(),
3754            );
3755            d.insert(
3756                "time_label".to_string(),
3757                row.time_label
3758                    .clone()
3759                    .into_pyobject(py)
3760                    .unwrap()
3761                    .unbind()
3762                    .into_any(),
3763            );
3764            d.insert(
3765                "cooling".to_string(),
3766                pyo3::types::PyBool::new(py, row.cooling)
3767                    .to_owned()
3768                    .into_any()
3769                    .unbind(),
3770            );
3771            d.insert(
3772                "nuclide".to_string(),
3773                row.nuclide
3774                    .clone()
3775                    .into_pyobject(py)
3776                    .unwrap()
3777                    .unbind()
3778                    .into_any(),
3779            );
3780            d.insert(
3781                "flags".to_string(),
3782                row.flags
3783                    .clone()
3784                    .into_pyobject(py)
3785                    .unwrap()
3786                    .unbind()
3787                    .into_any(),
3788            );
3789            d.insert(
3790                "activity_bq".to_string(),
3791                row.activity_bq
3792                    .into_pyobject(py)
3793                    .unwrap()
3794                    .unbind()
3795                    .into_any(),
3796            );
3797            d.insert(
3798                "clearance_index".to_string(),
3799                row.clearance_index
3800                    .into_pyobject(py)
3801                    .unwrap()
3802                    .unbind()
3803                    .into_any(),
3804            );
3805            d.insert(
3806                "half_life_s".to_string(),
3807                row.half_life_s
3808                    .into_pyobject(py)
3809                    .unwrap()
3810                    .unbind()
3811                    .into_any(),
3812            );
3813            d
3814        })
3815        .collect())
3816}
3817
3818// ---------------------------------------------------------------------------
3819// ORIGEN TAPE readers (thin glue over `origen-io`; scoped TAPE5/6/9)
3820// ---------------------------------------------------------------------------
3821
3822/// Parse ORIGEN TAPE5 input-echo text into plain containers.
3823///
3824/// Returns a dict with `titles` (list[str]), `irradiation_steps`
3825/// (list of {flux, days}), and `materials` (list of {name, entries:
3826/// [{nuclide, grams}]}).
3827#[pyfunction]
3828fn origen_parse_tape5(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3829    let owned = text.to_owned();
3830    let tape = py
3831        .detach(move || nucleide_origen_io::Tape5::parse(&owned))
3832        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3833    use pyo3::types::PyDict;
3834    let out = PyDict::new(py);
3835    out.set_item("titles", tape.titles.clone()).ok();
3836    let steps: Vec<Py<PyAny>> = tape
3837        .irradiation_steps
3838        .iter()
3839        .map(|s| {
3840            let d = PyDict::new(py);
3841            d.set_item("flux", s.flux).ok();
3842            d.set_item("days", s.days).ok();
3843            d.into_any().unbind()
3844        })
3845        .collect();
3846    out.set_item("irradiation_steps", steps).ok();
3847    let materials: Vec<Py<PyAny>> = tape
3848        .materials
3849        .iter()
3850        .map(|m| {
3851            let d = PyDict::new(py);
3852            d.set_item("name", &m.name).ok();
3853            let entries: Vec<Py<PyAny>> = m
3854                .grams
3855                .iter()
3856                .map(|(nuclide, grams)| {
3857                    let e = PyDict::new(py);
3858                    e.set_item("nuclide", nuclide).ok();
3859                    e.set_item("grams", *grams).ok();
3860                    e.into_any().unbind()
3861                })
3862                .collect();
3863            d.set_item("entries", entries).ok();
3864            d.into_any().unbind()
3865        })
3866        .collect();
3867    out.set_item("materials", materials).ok();
3868    Ok(out.into_any().unbind())
3869}
3870
3871/// Parse ORIGEN TAPE6 output-inventory text into plain containers.
3872///
3873/// Returns a dict with `records` (list of {nuclide, grams, activity_bq} in
3874/// file order) and `total_activity` (sum over records).
3875#[pyfunction]
3876fn origen_parse_tape6(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
3877    let owned = text.to_owned();
3878    let tape = py
3879        .detach(move || nucleide_origen_io::Tape6::parse(&owned))
3880        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3881    use pyo3::types::PyDict;
3882    let out = PyDict::new(py);
3883    let records: Vec<Py<PyAny>> = tape
3884        .records
3885        .iter()
3886        .map(|r| {
3887            let d = PyDict::new(py);
3888            d.set_item("nuclide", &r.nuclide).ok();
3889            d.set_item("grams", r.grams).ok();
3890            d.set_item("activity_bq", r.activity_bq).ok();
3891            d.into_any().unbind()
3892        })
3893        .collect();
3894    out.set_item("records", records).ok();
3895    out.set_item("total_activity", tape.total_activity()).ok();
3896    Ok(out.into_any().unbind())
3897}
3898
3899/// Parse ORIGEN TAPE9 decay-constant text into a list of row dicts.
3900///
3901/// Each entry is {nuclide, decay_const} in file order.
3902#[pyfunction]
3903fn origen_parse_tape9(py: Python<'_>, text: &str) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
3904    let owned = text.to_owned();
3905    let entries = py
3906        .detach(move || nucleide_origen_io::Tape9Entry::parse(&owned))
3907        .map_err(|e| PyValueError::new_err(e.to_string()))?;
3908    Ok(entries
3909        .iter()
3910        .map(|e| {
3911            let mut d = BTreeMap::new();
3912            d.insert(
3913                "nuclide".to_string(),
3914                e.nuclide
3915                    .clone()
3916                    .into_pyobject(py)
3917                    .unwrap()
3918                    .unbind()
3919                    .into_any(),
3920            );
3921            d.insert(
3922                "decay_const".to_string(),
3923                e.decay_const.into_pyobject(py).unwrap().unbind().into_any(),
3924            );
3925            d
3926        })
3927        .collect())
3928}
3929
3930// ---------------------------------------------------------------------------
3931// R2S workflow builder (thin glue over `r2s`; no transport/activation solve)
3932// ---------------------------------------------------------------------------
3933
3934fn r2s_workflow_to_py(py: Python<'_>, workflow: &nucleide_r2s::R2sWorkflow) -> Py<PyAny> {
3935    use pyo3::types::PyDict;
3936    let out = PyDict::new(py);
3937    let steps: Vec<Py<PyAny>> = workflow
3938        .steps
3939        .iter()
3940        .map(|s| {
3941            let d = PyDict::new(py);
3942            d.set_item("zone", &s.zone).ok();
3943            d.set_item("flux", &s.flux).ok();
3944            d.into_any().unbind()
3945        })
3946        .collect();
3947    out.set_item("steps", steps).ok();
3948    out.set_item("cooling_s", workflow.cooling_s.clone()).ok();
3949    out.set_item("top_schedule", &workflow.top_schedule).ok();
3950    out.into_any().unbind()
3951}
3952
3953fn r2s_workflow_from_dict(
3954    workflow: &Bound<'_, pyo3::types::PyDict>,
3955) -> PyResult<nucleide_r2s::R2sWorkflow> {
3956    let steps_value = match workflow.get_item("steps")? {
3957        Some(v) => v,
3958        None => return Err(PyValueError::new_err("r2s workflow missing `steps`")),
3959    };
3960    let step_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = steps_value
3961        .extract()
3962        .map_err(|_| PyValueError::new_err("r2s workflow `steps` must be a list of dicts"))?;
3963    let mut steps = Vec::with_capacity(step_dicts.len());
3964    for s in &step_dicts {
3965        let zone: String = match s.get_item("zone")? {
3966            Some(v) => v
3967                .extract()
3968                .map_err(|_| PyValueError::new_err("r2s step `zone` must be str"))?,
3969            None => return Err(PyValueError::new_err("r2s step missing `zone`")),
3970        };
3971        let flux: String = match s.get_item("flux")? {
3972            Some(v) => v
3973                .extract()
3974                .map_err(|_| PyValueError::new_err("r2s step `flux` must be str"))?,
3975            None => return Err(PyValueError::new_err("r2s step missing `flux`")),
3976        };
3977        steps.push(nucleide_r2s::R2sStep { zone, flux });
3978    }
3979    let cooling_s: Vec<f64> = match workflow.get_item("cooling_s")? {
3980        Some(v) => v.extract().map_err(|_| {
3981            PyValueError::new_err("r2s workflow `cooling_s` must be a list of float")
3982        })?,
3983        None => return Err(PyValueError::new_err("r2s workflow missing `cooling_s`")),
3984    };
3985    let top_schedule: String = match workflow.get_item("top_schedule")? {
3986        Some(v) => v
3987            .extract()
3988            .map_err(|_| PyValueError::new_err("r2s workflow `top_schedule` must be str"))?,
3989        None => return Err(PyValueError::new_err("r2s workflow missing `top_schedule`")),
3990    };
3991    Ok(nucleide_r2s::R2sWorkflow {
3992        steps,
3993        cooling_s,
3994        top_schedule,
3995    })
3996}
3997
3998/// Derive an R2S workflow summary from an ALARA deck.
3999///
4000/// Returns a dict with `steps` (list of {zone, flux}), `cooling_s`
4001/// (list[float]), and `top_schedule` (str).
4002#[pyfunction]
4003fn r2s_from_deck(py: Python<'_>, deck_text: &str) -> PyResult<Py<PyAny>> {
4004    let owned = deck_text.to_owned();
4005    let workflow = py
4006        .detach(move || {
4007            let deck = nucleide_alara_io::AlaraDeck::parse(&owned)
4008                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
4009            nucleide_r2s::R2sWorkflow::from_deck(&deck)
4010        })
4011        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4012    Ok(r2s_workflow_to_py(py, &workflow))
4013}
4014
4015/// Validate an R2S workflow dict against an ALARA deck.
4016///
4017/// Raises `ValueError` when a step zone/flux is unknown or cooling histories
4018/// are missing.
4019#[pyfunction]
4020fn r2s_validate(
4021    py: Python<'_>,
4022    workflow: &Bound<'_, pyo3::types::PyDict>,
4023    deck_text: &str,
4024) -> PyResult<()> {
4025    let rust_workflow = r2s_workflow_from_dict(workflow)?;
4026    let owned = deck_text.to_owned();
4027    let deck = py
4028        .detach(move || nucleide_alara_io::AlaraDeck::parse(&owned))
4029        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4030    rust_workflow
4031        .validate_against(&deck)
4032        .map_err(|e| PyValueError::new_err(e.to_string()))
4033}
4034
4035/// Expand an ALARA deck's irradiation hierarchy into flat steps via R2S.
4036///
4037/// Returns a list of {duration_s, flux, is_cooling} dicts. When `top` is
4038/// given it overrides the workflow's discovered top schedule.
4039#[pyfunction]
4040#[pyo3(signature = (deck_text, top=None))]
4041fn r2s_expand(
4042    py: Python<'_>,
4043    deck_text: &str,
4044    top: Option<&str>,
4045) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
4046    let owned_text = deck_text.to_owned();
4047    let owned_top = top.map(str::to_owned);
4048    let steps = py
4049        .detach(move || {
4050            let deck = nucleide_alara_io::AlaraDeck::parse(&owned_text)
4051                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
4052            let mut workflow = nucleide_r2s::R2sWorkflow::from_deck(&deck)?;
4053            if let Some(top) = owned_top {
4054                workflow.top_schedule = top;
4055            }
4056            workflow.expand(&deck, &[])
4057        })
4058        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4059    Ok(steps
4060        .into_iter()
4061        .map(|s| {
4062            let mut d = BTreeMap::new();
4063            let cooling = s.is_cooling();
4064            d.insert(
4065                "duration_s".to_string(),
4066                s.duration_s.into_pyobject(py).unwrap().unbind().into_any(),
4067            );
4068            d.insert(
4069                "flux".to_string(),
4070                s.flux.into_pyobject(py).unwrap().unbind().into_any(),
4071            );
4072            d.insert(
4073                "is_cooling".to_string(),
4074                pyo3::types::PyBool::new(py, cooling)
4075                    .to_owned()
4076                    .into_any()
4077                    .unbind(),
4078            );
4079            d
4080        })
4081        .collect())
4082}
4083
4084/// Assemble a uniform-split photon source summary for `zone`.
4085///
4086/// Parses an ALARA activation-output listing, sums shutdown
4087/// `SpecificActivity` over the zone's nuclide rows (skipping `total`
4088/// aggregates), and splits the total uniformly over `groups` energy groups.
4089/// Returns a dict with `zone`, `groups` (list[float]), and `total`.
4090///
4091/// Approximation: the uniform split preserves only the total shutdown
4092/// strength; real decay photons follow the nuclide- and energy-dependent
4093/// lines in ALARA `.photonSrc` spectra.
4094#[pyfunction]
4095fn r2s_assemble(
4096    py: Python<'_>,
4097    output_text: &str,
4098    run_lbl: &str,
4099    zone: &str,
4100    groups: usize,
4101) -> PyResult<Py<PyAny>> {
4102    let owned_text = output_text.to_owned();
4103    let owned_lbl = run_lbl.to_owned();
4104    let owned_zone = zone.to_owned();
4105    let source = py
4106        .detach(move || {
4107            let frame = nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
4108                .map_err(|e| nucleide_r2s::Error::Invalid(e.to_string()))?;
4109            Ok::<_, nucleide_r2s::Error>(nucleide_r2s::photon::assemble(
4110                &frame,
4111                &owned_zone,
4112                groups,
4113            ))
4114        })
4115        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4116    use pyo3::types::PyDict;
4117    let out = PyDict::new(py);
4118    out.set_item("zone", source.zone.clone()).ok();
4119    out.set_item("groups", source.groups.clone()).ok();
4120    out.set_item("total", source.total()).ok();
4121    Ok(out.into_any().unbind())
4122}
4123
4124/// Map zone totals onto voxels (`zone_of_voxel` holds zone indices).
4125///
4126/// `totals` carries one total source strength per zone; with `split=False`
4127/// every voxel copies its zone total (tag-as-attribute), with `split=True`
4128/// each zone total is divided conservatively over its voxels. Returns a
4129/// dict with `n_zones`, `zone_of_voxel`, `source_strength`,
4130/// `decay_time_s` (all shutdown `0.0`), and `total`. Thin wrapper over
4131/// `nucleide-r2s` `tag_zone_totals` / `split_zone_totals`.
4132#[pyfunction]
4133#[pyo3(signature = (totals, zone_of_voxel, split=false))]
4134fn r2s_tag_zone_strength(
4135    py: Python<'_>,
4136    totals: Vec<f64>,
4137    zone_of_voxel: Vec<usize>,
4138    split: bool,
4139) -> PyResult<Py<PyAny>> {
4140    let zones: Vec<nucleide_r2s::photon::ZonePhotonSource> = totals
4141        .into_iter()
4142        .enumerate()
4143        .map(|(i, total)| {
4144            let groups = if total == 0.0 {
4145                Vec::new()
4146            } else {
4147                vec![total]
4148            };
4149            nucleide_r2s::photon::ZonePhotonSource {
4150                zone: format!("zone{i}"),
4151                groups,
4152            }
4153        })
4154        .collect();
4155    let tags = if split {
4156        nucleide_r2s::tags::split_zone_totals(&zones, &zone_of_voxel)
4157    } else {
4158        nucleide_r2s::tags::tag_zone_totals(&zones, &zone_of_voxel)
4159    }
4160    .map_err(|e| PyValueError::new_err(e.to_string()))?;
4161    use pyo3::types::PyDict;
4162    let out = PyDict::new(py);
4163    out.set_item("n_zones", tags.n_zones).ok();
4164    out.set_item("zone_of_voxel", tags.zone_of_voxel.clone())
4165        .ok();
4166    out.set_item("source_strength", tags.source_strength.clone())
4167        .ok();
4168    out.set_item("decay_time_s", tags.decay_time_s.clone()).ok();
4169    out.set_item("total", tags.total_strength()).ok();
4170    Ok(out.into_any().unbind())
4171}
4172
4173/// Select and sum `.photonSrc` group spectra for `nuclides` at `time_s`.
4174///
4175/// Parses ALARA photon-source text, keeps rows matching the named nuclides
4176/// at exactly `time_s` seconds (shutdown `0.0`), and adds them element-wise
4177/// in ALARA group order. Returns a dict with `groups` (matching
4178/// `{nuclide, time_s, strengths}` rows), `sums`, and `total`. No rescaling:
4179/// strengths keep the file's normalization. Thin wrapper over
4180/// `nucleide-r2s` `photon_groups_at` / `sum_group_strengths`.
4181#[pyfunction]
4182fn r2s_photon_group_sums(
4183    py: Python<'_>,
4184    photon_text: &str,
4185    nuclides: Vec<String>,
4186    time_s: f64,
4187) -> PyResult<Py<PyAny>> {
4188    let source = nucleide_alara_io::photon::PhotonSource::from_str(photon_text)
4189        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4190    let names: Vec<&str> = nuclides.iter().map(String::as_str).collect();
4191    let at = nucleide_r2s::tags::photon_groups_at(&source, &names, time_s);
4192    let sums = nucleide_r2s::tags::sum_group_strengths(&at)
4193        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4194    use pyo3::types::PyDict;
4195    let out = PyDict::new(py);
4196    let rows: Vec<Py<PyAny>> = at
4197        .iter()
4198        .map(|g| {
4199            let d = PyDict::new(py);
4200            d.set_item("nuclide", g.nuclide.clone()).ok();
4201            d.set_item("time_s", g.time_s).ok();
4202            d.set_item("strengths", g.strengths.clone()).ok();
4203            d.into_any().unbind()
4204        })
4205        .collect();
4206    out.set_item("groups", rows).ok();
4207    out.set_item("sums", sums.clone()).ok();
4208    out.set_item("total", sums.iter().sum::<f64>()).ok();
4209    Ok(out.into_any().unbind())
4210}
4211
4212fn snapshot_dict_str(
4213    zone: &Bound<'_, pyo3::types::PyDict>,
4214    key: &str,
4215    what: &str,
4216) -> PyResult<String> {
4217    match zone.get_item(key)? {
4218        Some(v) => v
4219            .extract()
4220            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
4221        None => Err(PyValueError::new_err(format!(
4222            "snapshot {what} missing `{key}`"
4223        ))),
4224    }
4225}
4226
4227fn snapshot_dict_opt_str(
4228    zone: &Bound<'_, pyo3::types::PyDict>,
4229    key: &str,
4230    what: &str,
4231) -> PyResult<Option<String>> {
4232    match zone.get_item(key)? {
4233        Some(v) if v.is_none() => Ok(None),
4234        Some(v) => v
4235            .extract::<String>()
4236            .map(Some)
4237            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be str"))),
4238        None => Ok(None),
4239    }
4240}
4241
4242fn snapshot_dict_f64(
4243    zone: &Bound<'_, pyo3::types::PyDict>,
4244    key: &str,
4245    what: &str,
4246) -> PyResult<f64> {
4247    match zone.get_item(key)? {
4248        Some(v) => v
4249            .extract()
4250            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
4251        None => Err(PyValueError::new_err(format!(
4252            "snapshot {what} missing `{key}`"
4253        ))),
4254    }
4255}
4256
4257fn snapshot_dict_opt_f64(
4258    zone: &Bound<'_, pyo3::types::PyDict>,
4259    key: &str,
4260    what: &str,
4261) -> PyResult<Option<f64>> {
4262    match zone.get_item(key)? {
4263        Some(v) if v.is_none() => Ok(None),
4264        Some(v) => v
4265            .extract::<f64>()
4266            .map(Some)
4267            .map_err(|_| PyValueError::new_err(format!("snapshot {what} `{key}` must be float"))),
4268        None => Ok(None),
4269    }
4270}
4271
4272fn snapshot_zone_from_dict(
4273    zone: &Bound<'_, pyo3::types::PyDict>,
4274) -> PyResult<nucleide_r2s::snapshot::SnapshotZone> {
4275    let id = snapshot_dict_str(zone, "id", "zone")?;
4276    let volume_cm3 = snapshot_dict_f64(zone, "volume_cm3", "zone")?;
4277    let composition: BTreeMap<String, f64> = match zone.get_item("composition")? {
4278        Some(v) => v.extract().map_err(|_| {
4279            PyValueError::new_err("snapshot zone `composition` must be a dict of str to float")
4280        })?,
4281        None => return Err(PyValueError::new_err("snapshot zone missing `composition`")),
4282    };
4283    Ok(nucleide_r2s::snapshot::SnapshotZone {
4284        zone: id,
4285        volume_cm3,
4286        zbottom_cm: snapshot_dict_opt_f64(zone, "zbottom_cm", "zone")?,
4287        ztop_cm: snapshot_dict_opt_f64(zone, "ztop_cm", "zone")?,
4288        material: snapshot_dict_opt_str(zone, "material", "zone")?,
4289        xs_type: snapshot_dict_opt_str(zone, "xs_type", "zone")?,
4290        temperature_c: snapshot_dict_opt_f64(zone, "temperature_C", "zone")?,
4291        composition: composition.into_iter().collect(),
4292        flux_name: snapshot_dict_opt_str(zone, "flux", "zone")?,
4293    })
4294}
4295
4296fn snapshot_input_from_dict(
4297    snapshot: &Bound<'_, pyo3::types::PyDict>,
4298) -> PyResult<nucleide_r2s::snapshot::SnapshotInput> {
4299    let zone_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("zones")? {
4300        Some(v) => v
4301            .extract()
4302            .map_err(|_| PyValueError::new_err("snapshot `zones` must be a list of dicts"))?,
4303        None => return Err(PyValueError::new_err("snapshot missing `zones`")),
4304    };
4305    let mut zones = Vec::with_capacity(zone_dicts.len());
4306    for z in &zone_dicts {
4307        zones.push(snapshot_zone_from_dict(z)?);
4308    }
4309    let flux_dicts: Vec<Bound<'_, pyo3::types::PyDict>> = match snapshot.get_item("flux_defs")? {
4310        Some(v) => v
4311            .extract()
4312            .map_err(|_| PyValueError::new_err("snapshot `flux_defs` must be a list of dicts"))?,
4313        None => return Err(PyValueError::new_err("snapshot missing `flux_defs`")),
4314    };
4315    let mut flux_defs = Vec::with_capacity(flux_dicts.len());
4316    for f in &flux_dicts {
4317        flux_defs.push(nucleide_r2s::snapshot::SnapshotFluxDef {
4318            name: snapshot_dict_str(f, "name", "flux")?,
4319            file: snapshot_dict_str(f, "file", "flux")?,
4320            scale: snapshot_dict_f64(f, "scale", "flux")?,
4321        });
4322    }
4323    let cooling_s: Vec<f64> = match snapshot.get_item("cooling_s")? {
4324        Some(v) => v
4325            .extract()
4326            .map_err(|_| PyValueError::new_err("snapshot `cooling_s` must be a list of float"))?,
4327        None => return Err(PyValueError::new_err("snapshot missing `cooling_s`")),
4328    };
4329    Ok(nucleide_r2s::snapshot::SnapshotInput {
4330        zones,
4331        flux_defs,
4332        cooling_s,
4333        schedule_text: snapshot_dict_opt_str(snapshot, "schedule_text", "snapshot")?,
4334        output: snapshot_dict_opt_str(snapshot, "output", "snapshot")?,
4335    })
4336}
4337
4338/// Total facility inventory over snapshot zones (flow accounting).
4339///
4340/// Same `snapshot` dict shape as [`r2s_from_snapshot`]; returns
4341/// `{ARMI-name: total atoms}` (`N × V × 1e-24` summed over zones) for
4342/// differencing facility snapshots. Raises `ValueError` on invalid input.
4343#[pyfunction]
4344fn r2s_snapshot_inventory(
4345    snapshot: &Bound<'_, pyo3::types::PyDict>,
4346) -> PyResult<BTreeMap<String, f64>> {
4347    let input = snapshot_input_from_dict(snapshot)?;
4348    nucleide_r2s::snapshot::snapshot_inventory(&input)
4349        .map(|totals| totals.into_iter().collect())
4350        .map_err(|e| PyValueError::new_err(e.to_string()))
4351}
4352
4353/// Expand sweep axes to cartesian case bundles (WATTS-class parameter sweep).
4354///
4355/// `axes` is a list of `{name, values}` dicts; returns a list of
4356/// `{name, params}` dicts (`params` maps axis names to values).
4357/// Raises `ValueError` on empty/duplicate axes or non-finite values.
4358#[pyfunction]
4359fn r2s_expand_sweep(
4360    axes: Vec<BTreeMap<String, Bound<'_, pyo3::types::PyAny>>>,
4361) -> PyResult<Vec<BTreeMap<String, String>>> {
4362    use pyo3::types::PyAnyMethods;
4363    let mut parsed = Vec::with_capacity(axes.len());
4364    for axis in &axes {
4365        let name: String = axis
4366            .get("name")
4367            .and_then(|v| v.extract().ok())
4368            .ok_or_else(|| PyValueError::new_err("sweep axis needs a `name` string"))?;
4369        let values: Vec<f64> = axis
4370            .get("values")
4371            .and_then(|v| v.extract().ok())
4372            .ok_or_else(|| PyValueError::new_err("sweep axis needs a `values` float list"))?;
4373        parsed.push(
4374            nucleide_r2s::sweep::SweepAxis::new(&name, values)
4375                .map_err(|e| PyValueError::new_err(e.to_string()))?,
4376        );
4377    }
4378    let cases = nucleide_r2s::sweep::expand_sweep(&parsed)
4379        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4380    Ok(cases
4381        .into_iter()
4382        .map(|c| {
4383            let mut d = BTreeMap::new();
4384            d.insert("name".to_string(), c.name);
4385            d.insert(
4386                "params".to_string(),
4387                c.params
4388                    .iter()
4389                    .map(|(k, v)| format!("{k}={v}"))
4390                    .collect::<Vec<_>>()
4391                    .join(","),
4392            );
4393            d
4394        })
4395        .collect())
4396}
4397///
4398/// Build an R2S workflow bundle from a versionless ARMI DB snapshot dict.
4399///
4400/// `snapshot` mirrors `nucleide_r2s::snapshot::SnapshotInput`: `zones` (list
4401/// of `{id, volume_cm3, composition: {ARMI-name: ndens}}` with optional
4402/// `zbottom_cm`/`ztop_cm`/`material`/`xs_type`/`temperature_C`/`flux`),
4403/// `flux_defs` (list of `{name, file, scale}`), `cooling_s` (list[float]),
4404/// plus optional `schedule_text` and `output`. Returns `{workflow, deck,
4405/// decks}`: the workflow summary (same shape as `r2s_from_deck`), the
4406/// canonical template deck text, and one canonical deck text per step.
4407///
4408/// Composition keys follow the emit ARMI-input rule (post-expansion nuclide
4409/// keys; elemental keys, bare `AM242`, and unknown names are `ValueError`s);
4410/// densities are atoms/barn-cm. Empty `cooling_s` is a `ValueError` via
4411/// workflow validation. Raises `ValueError` on any invalid input or dangling
4412/// cross-reference.
4413#[pyfunction]
4414fn r2s_from_snapshot(
4415    py: Python<'_>,
4416    snapshot: &Bound<'_, pyo3::types::PyDict>,
4417) -> PyResult<Py<PyAny>> {
4418    let input = snapshot_input_from_dict(snapshot)?;
4419    let (workflow, template, decks) = py
4420        .detach(move || nucleide_r2s::snapshot::snapshot_workflow(&input))
4421        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4422    use pyo3::types::PyDict;
4423    let out = PyDict::new(py);
4424    out.set_item("workflow", r2s_workflow_to_py(py, &workflow))
4425        .ok();
4426    out.set_item("deck", template.to_string()).ok();
4427    let deck_texts: Vec<String> = decks.iter().map(ToString::to_string).collect();
4428    out.set_item("decks", deck_texts).ok();
4429    Ok(out.into_any().unbind())
4430}
4431
4432// ---------------------------------------------------------------------------
4433// 0.3.0 series driver, data accessors, list helpers
4434// ---------------------------------------------------------------------------
4435//
4436// Thin facade only (core tables and integrators live in `nucleide-nuclei` /
4437// `nucleide-material` / `nucleide-depletion`; nothing duplicated here):
4438//
4439// - `deplete_series` wraps the core `integrate` series (`predictor`/`cecm`/
4440//   `cf4`), omitting the core `t = 0` row so there is one output per step.
4441// - `simple_xs` / `scattering_length` / `decay_energy` / `decay_heat` are
4442//   thin wrappers over the vendored TSV tables + material analytics.
4443// - `MeshTally::to_list` / `totals_list` are plain-copy helpers alongside the
4444//   landed zero-copy NumPy bridge (`result_array()` / `totals_array()`):
4445//   `numpy = "0.28"` is a bindings-only
4446//   dependency (abi3-py310 inherited from the workspace PyO3).
4447
4448/// Supported `deplete_series` integrators (core `Integrator` variants).
4449fn parse_integrator(name: &str) -> PyResult<nucleide_depletion::Integrator> {
4450    use nucleide_depletion::Integrator as I;
4451    if name.eq_ignore_ascii_case("predictor") {
4452        return Ok(I::Predictor);
4453    }
4454    if name.eq_ignore_ascii_case("cecm") {
4455        return Ok(I::Cecm);
4456    }
4457    if name.eq_ignore_ascii_case("cf4") {
4458        return Ok(I::Cf4);
4459    }
4460    Err(PyValueError::new_err(format!(
4461        "unsupported integrator `{name}` (supported: predictor, cecm, cf4)"
4462    )))
4463}
4464
4465/// Solve a multi-step depletion series with the chosen core integrator.
4466///
4467/// Thin wrapper over `nucleide_depletion::integrate`: one [`Step`] per `dt`
4468/// (per-step `rates`/`rates_list`, `None` meaning decay-only), `n0` keyed by
4469/// nuclide name. `method` selects the solver kernel (`"cram16"`,
4470/// `"cram48"`, `"bateman"`, `"bateman_hp"`, default `"cram48"` — an
4471/// explicitly non-default `method` overrides `order`; Bateman steps with
4472/// live rates fall back to CRAM-48). Returns a dict with `times`
4473/// (cumulative seconds, one entry per step — the core `t = 0` initial row is
4474/// omitted so `atoms[k]` matches a single `deplete` call over `dts[k]`),
4475/// `atoms`, `activity` ([Bq]), and `decay_heat` ([W] per nuclide via the
4476/// shared chain → ENDF/B-VII.1 → 0.0 energy resolution).
4477#[pyfunction]
4478#[pyo3(signature = (chain, n0, dts, rates=None, rates_list=None, integrator="predictor", order=48, method="cram48"))]
4479#[allow(clippy::too_many_arguments)]
4480fn deplete_series(
4481    chain: &PyChain,
4482    n0: BTreeMap<String, f64>,
4483    dts: Vec<f64>,
4484    rates: Option<RateMap>,
4485    rates_list: Option<Vec<Option<RateMap>>>,
4486    integrator: &str,
4487    order: u8,
4488    method: &str,
4489) -> PyResult<Py<PyAny>> {
4490    use nucleide_depletion::{DepletionSystem, ReactionRates, Step};
4491    let integrator = parse_integrator(integrator)?;
4492    let method = resolve_method(order, method)?;
4493    if let Some(list) = &rates_list {
4494        if list.len() != dts.len() {
4495            return Err(PyValueError::new_err(format!(
4496                "rates_list has {} entries but dts has {}",
4497                list.len(),
4498                dts.len()
4499            )));
4500        }
4501    }
4502    if dts.is_empty() {
4503        return Err(PyValueError::new_err("dts must not be empty"));
4504    }
4505    // Atom vector in chain order; unknown names fail loudly like `deplete`.
4506    let mut n0_vec = vec![0.0; chain.inner.len()];
4507    for (name, value) in &n0 {
4508        let idx = chain.inner.index_of(name).ok_or_else(|| {
4509            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
4510        })?;
4511        n0_vec[idx] = *value;
4512    }
4513    let empty = BTreeMap::new();
4514    let mut steps = Vec::with_capacity(dts.len());
4515    for (i, dt) in dts.iter().enumerate() {
4516        let step_rates = rates_list
4517            .as_ref()
4518            .and_then(|list| list[i].as_ref())
4519            .or(rates.as_ref())
4520            .unwrap_or(&empty);
4521        let rs = split_rates(step_rates, &chain.inner)?;
4522        steps.push(Step::new(*dt, rs));
4523    }
4524    // Template system: `integrate` rebuilds the matrix per step from the
4525    // chain + step rates; the template's own rates are unused.
4526    let template = DepletionSystem::build((*chain.inner).clone(), &ReactionRates::new())
4527        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4528    // NOTE: plain (GIL held) call by design, matching the other CRAM
4529    // bindings; batch sizes here are small.
4530    let series =
4531        nucleide_depletion::integrate_with_method(&template, &n0_vec, &steps, integrator, method)
4532            .map_err(|e| PyValueError::new_err(e.to_string()))?;
4533    let names: Vec<&str> = template
4534        .chain
4535        .nuclides
4536        .iter()
4537        .map(|nuc| nuc.name.as_str())
4538        .collect();
4539    let keyed = |rows: &[Vec<f64>]| -> Vec<BTreeMap<String, f64>> {
4540        rows.iter()
4541            .map(|row| {
4542                names
4543                    .iter()
4544                    .zip(row)
4545                    .map(|(name, v)| ((*name).to_string(), *v))
4546                    .collect()
4547            })
4548            .collect()
4549    };
4550    // Skip the t = 0 initial row: one output entry per requested step.
4551    let atoms = keyed(&series.atoms[1..]);
4552    let activity = keyed(&series.activity[1..]);
4553    let decay_heat = keyed(&series.decay_heat[1..]);
4554    let times = series.times[1..].to_vec();
4555    Ok(Python::attach(|py| {
4556        use pyo3::types::PyDict;
4557        let out = PyDict::new(py);
4558        out.set_item("times", &times).ok();
4559        out.set_item("atoms", &atoms).ok();
4560        out.set_item("activity", &activity).ok();
4561        out.set_item("decay_heat", &decay_heat).ok();
4562        out.into_any().unbind()
4563    }))
4564}
4565
4566/// Thermal/fast cross sections [barn] for a nuclide name.
4567///
4568/// Screening-level values from the `nucleide-nuclei` table (thermal 2200 m/s
4569/// total + 14-MeV total); `None` for nuclides outside the table.
4570#[pyfunction]
4571fn simple_xs(name: &str) -> PyResult<Option<(f64, f64)>> {
4572    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4573    Ok(nucleide_nuclei::data::simple_xs_by_name(name))
4574}
4575
4576/// Coherent scattering length [fm] for a nuclide name.
4577///
4578/// First element of the `nucleide-nuclei` (coherent, incoherent) pair;
4579/// `None` for nuclides outside the table.
4580#[pyfunction]
4581fn scattering_length(name: &str) -> PyResult<Option<f64>> {
4582    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4583    Ok(nucleide_nuclei::data::scattering_length_by_name(name).map(|(b_coh, _)| b_coh))
4584}
4585
4586/// Mean decay energy per disintegration [MeV] for a nuclide name.
4587///
4588/// Screening-level placeholder values (NOT ENSDF); `None` when unknown.
4589#[pyfunction]
4590fn decay_energy(name: &str) -> PyResult<Option<f64>> {
4591    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4592    Ok(nucleide_nuclei::data::decay_energy_mev_by_name(name))
4593}
4594
4595/// Evaluated decay branches for a nuclide name.
4596///
4597/// One `(progeny GNDS name, branching fraction, mode)` tuple per kept
4598/// ENDF/B-VIII.0 branch (SF/fission branches dropped); empty when the
4599/// nuclide has no branch rows (stable nuclides).
4600#[pyfunction]
4601fn decay_branches(name: &str) -> PyResult<Vec<(String, f64, String)>> {
4602    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4603    Ok(nucleide_nuclei::data::decay_branches_by_name(name)
4604        .unwrap_or_default()
4605        .into_iter()
4606        .map(|b| {
4607            (
4608                nucleide_nuclei::NuclideId::from_nucid(b.progeny).to_name(),
4609                b.branching_fraction,
4610                b.mode.as_str().to_string(),
4611            )
4612        })
4613        .collect())
4614}
4615
4616/// Branching fraction from parent to progeny (GNDS names), if tabulated.
4617///
4618/// Evaluated ENDF/B-VIII.0 value, verbatim; `None` when the branch is
4619/// absent (including dropped SF branches). Named apart from the
4620/// chain-scoped `branching_fraction` (which takes a chain argument).
4621#[pyfunction]
4622fn decay_branch_fraction(parent: &str, progeny: &str) -> PyResult<Option<f64>> {
4623    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4624    NuclideId::from_name(progeny).map_err(wrap_nucid_err)?;
4625    Ok(nucleide_nuclei::data::branching_fraction_by_name(
4626        parent, progeny,
4627    ))
4628}
4629
4630/// Evaluated fission product yields for a nuclide name.
4631///
4632/// One `(energy_eV, [(daughter GNDS name, yield, uncertainty), ...])` tuple
4633/// per incident-energy set, lowest energy first; empty when the parent has
4634/// no evaluation for the requested `origin`/`kind`. `origin` is `n`
4635/// (neutron-induced, default) or `sf` (spontaneous); `kind` is
4636/// `independent` (MF8/MT454, default — what depletion consumes) or
4637/// `cumulative` (MF8/MT459). An uncertainty of `0.0` means the tape
4638/// evaluates none (the zero-yield rows of this sublibrary).
4639type PyFissionYieldSets = Vec<(f64, Vec<(String, f64, f64)>)>;
4640
4641#[pyfunction]
4642#[pyo3(signature = (parent, origin="n", kind="independent"))]
4643fn fission_yields(parent: &str, origin: &str, kind: &str) -> PyResult<PyFissionYieldSets> {
4644    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4645    let origin = nucleide_nuclei::data::FissionYieldOrigin::parse(origin).ok_or_else(|| {
4646        PyValueError::new_err(format!(
4647            "unknown fission-yield origin `{origin}` (expected `n` or `sf`)"
4648        ))
4649    })?;
4650    let kind = nucleide_nuclei::data::FissionYieldKind::parse(kind).ok_or_else(|| {
4651        PyValueError::new_err(format!(
4652            "unknown fission-yield kind `{kind}` (expected `independent` or `cumulative`)"
4653        ))
4654    })?;
4655    Ok(
4656        nucleide_nuclei::data::fission_yields_by_name(parent, origin, kind)
4657            .unwrap_or_default()
4658            .into_iter()
4659            .map(|set| {
4660                (
4661                    set.energy_ev,
4662                    set.products
4663                        .into_iter()
4664                        .map(|p| {
4665                            (
4666                                nucleide_nuclei::NuclideId::from_nucid(p.progeny).to_name(),
4667                                p.yield_fraction,
4668                                p.uncertainty,
4669                            )
4670                        })
4671                        .collect(),
4672                )
4673            })
4674            .collect(),
4675    )
4676}
4677
4678/// Independent neutron-induced fission yield of one daughter (GNDS names).
4679///
4680/// Uses the parent's lowest-energy yield set — the OpenMC
4681/// `get_default_fission_yields` depletion convention. `None` when either
4682/// nuclide is outside the table; the uncertainty and the other energy sets
4683/// are available through `fission_yields`.
4684#[pyfunction]
4685fn fission_yield(parent: &str, progeny: &str) -> PyResult<Option<f64>> {
4686    NuclideId::from_name(parent).map_err(wrap_nucid_err)?;
4687    NuclideId::from_name(progeny).map_err(wrap_nucid_err)?;
4688    Ok(nucleide_nuclei::data::fission_yield_by_name(
4689        parent, progeny,
4690    ))
4691}
4692
4693/// Normalize a nuclide name in any accepted dialect to canonical GNDS form.
4694///
4695/// Accepts symbol-first (`Pu241`, `Pu-241`, `Ba137m`), mass-first (`241Pu`,
4696/// `40K`), and isomer suffix letters (`Ir-192n` → second isomer); see
4697/// `nucleide_nuclei::dialects::normalize_nuclide_name`.
4698#[pyfunction]
4699fn normalize_nuclide(name: &str) -> PyResult<String> {
4700    Ok(nucleide_nuclei::dialects::normalize_nuclide_name(name)
4701        .map_err(|e| PyValueError::new_err(e.to_string()))?
4702        .to_name())
4703}
4704
4705/// Decay heat [W] of a composition dict ({nuclide name: grams}).
4706///
4707/// Screening-level estimate via `Material::total_decay_heat` (Ame2020 masses,
4708/// ENDF/B-VIII.0 decay constants, placeholder decay energies). Stable
4709/// nuclides (known mass, no decay constant) contribute 0. Errors when a
4710/// nuclide lacks mass data, or a radioactive nuclide lacks energy data.
4711#[pyfunction]
4712fn decay_heat(comp: BTreeMap<String, f64>) -> PyResult<f64> {
4713    let mat = comp_to_material(comp)?;
4714    let analytics = nucleide_material::Analytics {
4715        masses: &nucleide_material::Ame2020,
4716        decays: &nucleide_material::ChainDecays,
4717    };
4718    mat.total_decay_heat(&analytics, &nucleide_material::DecayEnergies)
4719        .map_err(|e| PyValueError::new_err(e.to_string()))
4720}
4721
4722fn parse_dose_pathway(s: &str) -> PyResult<nucleide_material::DosePathway> {
4723    nucleide_material::DosePathway::parse(s).ok_or_else(|| {
4724        PyValueError::new_err(format!(
4725            "unknown dose pathway `{s}` (supported: air, soil, ingest, inhale)"
4726        ))
4727    })
4728}
4729
4730fn parse_dose_source(s: &str) -> PyResult<nucleide_material::DoseSource> {
4731    nucleide_material::DoseSource::parse(s).ok_or_else(|| {
4732        PyValueError::new_err(format!(
4733            "unknown dose source `{s}` (supported: EPA, DOE, GENII)"
4734        ))
4735    })
4736}
4737
4738/// Raw dose factor for a nuclide name, pathway, and source.
4739///
4740/// Pathway is one of `air`/`soil`/`ingest`/`inhale` (`ext_air`/`ext_soil`
4741/// aliases accepted); source is one of `EPA`/`DOE`/`GENII` (default `EPA`,
4742/// matching PyNE source id 0). Returns `None` when the nuclide has no row;
4743/// GENII/DOE air resolve to `-1.0` (PyNE missing-air sentinel).
4744#[pyfunction]
4745#[pyo3(signature = (name, pathway, source="EPA"))]
4746fn dose_factor(name: &str, pathway: &str, source: &str) -> PyResult<Option<f64>> {
4747    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
4748    let p = parse_dose_pathway(pathway)?;
4749    let s = parse_dose_source(source)?;
4750    Ok(nucleide_nuclei::data::dose_factor_by_name(name, p, s))
4751}
4752
4753fn wrap_fgr15_err(e: nucleide_nuclei::fgr15::Error) -> PyErr {
4754    PyValueError::new_err(e.to_string())
4755}
4756
4757/// Parse one EPA FGR 15 `Table_4_*.DAT` member (table text) into a dict.
4758///
4759/// Thin wrapper over `nucleide_nuclei::fgr15::parse_table`. Returns
4760/// `{"scenario": str, "units": str, "coefficients": {name: [6 floats]}}`
4761/// with coefficient lists in canonical age order (newborn, 1-yr, 5-yr,
4762/// 10-yr, 15-yr, adult) and nuclide names in FGR 15 spelling (`H-3`,
4763/// `Ba-137m`, `Sb-124n`). `expected_rows` is the exact nuclide-row count the
4764/// table must hold (1,252 for the published EPA tables); structural
4765/// problems, malformed rows, duplicates, and row-count mismatches are loud
4766/// errors. Screening-level only — not for safety decisions.
4767#[pyfunction]
4768#[pyo3(signature = (text, expected_rows))]
4769fn parse_fgr15_table<'py>(
4770    py: Python<'py>,
4771    text: &str,
4772    expected_rows: usize,
4773) -> PyResult<pyo3::Bound<'py, pyo3::types::PyDict>> {
4774    let table = nucleide_nuclei::fgr15::parse_table(text, expected_rows).map_err(wrap_fgr15_err)?;
4775    let out = pyo3::types::PyDict::new(py);
4776    out.set_item("scenario", table.scenario().as_str())?;
4777    out.set_item("units", table.units())?;
4778    let coefficients = pyo3::types::PyDict::new(py);
4779    for (nucid, row) in table.iter() {
4780        coefficients.set_item(
4781            nucleide_nuclei::fgr15::name_of(NuclideId::from_nucid(nucid)),
4782            row.to_vec(),
4783        )?;
4784    }
4785    out.set_item("coefficients", coefficients)?;
4786    Ok(out)
4787}
4788
4789/// Column index (0-5) of an EPA FGR 15 age group.
4790///
4791/// Accepts `newborn`/`adult`, bare years (`1`, `5`, `10`, `15`), and
4792/// spelled variants (`1yr`, `1-yr`, `1-yr-old`, ...). Raises `ValueError`
4793/// for anything else.
4794#[pyfunction]
4795fn fgr15_age_index(age: &str) -> PyResult<usize> {
4796    nucleide_nuclei::fgr15::Fgr15Age::parse(age)
4797        .map(|a| a.index())
4798        .ok_or_else(|| {
4799            PyValueError::new_err(format!(
4800                "unknown FGR 15 age group `{age}` (supported: newborn, 1, 5, 10, 15, adult)"
4801            ))
4802        })
4803}
4804
4805/// Total dose per gram of a composition dict ({nuclide name: grams}).
4806///
4807/// Thin wrapper over `Material::total_dose_per_g` (Ame2020 masses,
4808/// ENDF/B-VIII.0 decay constants, HNF-5636/PyNE dose factors). Pathway is one
4809/// of `air`/`soil`/`ingest`/`inhale`; source is `EPA`/`DOE`/`GENII` (default
4810/// `EPA`). Units follow the table: air `mrem/h per g per m^3`, soil
4811/// `mrem/h per g per m^2`, ingest/inhale `mrem per g`. Screening-level only —
4812/// not for safety decisions. Stable nuclides (known mass, no decay constant)
4813/// contribute 0 without a dose-factor lookup. Errors when a nuclide lacks
4814/// mass data, or a radioactive nuclide lacks dose data (including `-1`
4815/// GENII/DOE air sentinels).
4816#[pyfunction]
4817#[pyo3(signature = (comp, pathway, source="EPA"))]
4818fn dose_per_g(comp: BTreeMap<String, f64>, pathway: &str, source: &str) -> PyResult<f64> {
4819    let mat = comp_to_material(comp)?;
4820    let analytics = nucleide_material::Analytics {
4821        masses: &nucleide_material::Ame2020,
4822        decays: &nucleide_material::ChainDecays,
4823    };
4824    let p = parse_dose_pathway(pathway)?;
4825    let s = parse_dose_source(source)?;
4826    mat.total_dose_per_g(&analytics, &nucleide_material::DoseFactors, p, s)
4827        .map_err(|e| PyValueError::new_err(e.to_string()))
4828}
4829
4830/// Split a composition dict into product and tails dicts by per-nuclide
4831/// separation efficiency.
4832///
4833/// `comp` maps nuclide names to grams; `effs` maps nuclide names to
4834/// efficiencies in `[0, 1]` (unlisted nuclides go entirely to tails).
4835/// Returns `(product, tails)` with per-nuclide mass conserved. Thin wrapper
4836/// over `Material::separate`.
4837#[pyfunction]
4838#[allow(clippy::type_complexity)]
4839fn separate_material(
4840    comp: BTreeMap<String, f64>,
4841    effs: BTreeMap<String, f64>,
4842) -> PyResult<(BTreeMap<String, f64>, BTreeMap<String, f64>)> {
4843    let mat = comp_to_material(comp)?;
4844    let mut table = Vec::with_capacity(effs.len());
4845    for (name, eff) in &effs {
4846        let id = NuclideId::from_name(name)
4847            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
4848        table.push((id, *eff));
4849    }
4850    let (product, tails) = mat
4851        .separate(&table)
4852        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4853    let named =
4854        |m: nucleide_material::Material| m.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
4855    Ok((named(product), named(tails)))
4856}
4857
4858/// Blend composition dicts at fixed ratios with explicit normalization.
4859///
4860/// `parts` holds `(comp, ratio)` pairs; ratios are normalized by their sum
4861/// and the output is the weighted average. Errors on empty, all-zero, or
4862/// negative ratios (never a silent uniform split). Thin wrapper over
4863/// `Material::blend`.
4864#[pyfunction]
4865fn blend_material(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
4866    let mats: Vec<nucleide_material::Material> = parts
4867        .iter()
4868        .map(|(comp, _)| comp_to_material(comp.clone()))
4869        .collect::<PyResult<_>>()?;
4870    let refs: Vec<(&nucleide_material::Material, f64)> =
4871        mats.iter().zip(parts.iter().map(|(_, r)| *r)).collect();
4872    let out = nucleide_material::Material::blend(&refs)
4873        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4874    Ok(out.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect())
4875}
4876
4877/// One-sided upper Page CUSUM change detector with Welford statistics.
4878///
4879/// Thin stateful wrapper over `nucleide_material::Cusum`: `update(x)`
4880/// feeds one observation and returns the alarm status; `status()` reads it
4881/// without consuming input; `statistic()` reads the CUSUM value;
4882/// `reset()` drops all observations (tuning kept). Non-finite inputs to
4883/// `update` are ignored.
4884#[pyclass(name = "Cusum")]
4885struct PyCusum {
4886    inner: nucleide_material::Cusum,
4887}
4888
4889#[pymethods]
4890impl PyCusum {
4891    /// Build a detector (`ref_shift_k = 0.5`, `alarm_h = 4.0`,
4892    /// `startup = 10` by default).
4893    #[new]
4894    #[pyo3(signature = (ref_shift_k=0.5, alarm_h=4.0, startup=10))]
4895    fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> PyResult<Self> {
4896        nucleide_material::Cusum::new(ref_shift_k, alarm_h, startup)
4897            .map(|inner| Self { inner })
4898            .map_err(|e| PyValueError::new_err(e.to_string()))
4899    }
4900
4901    /// Feed one observation; returns the resulting alarm status.
4902    fn update(&mut self, x: f64) -> bool {
4903        self.inner.update(x)
4904    }
4905
4906    /// Whether the detector is currently alarmed.
4907    fn status(&self) -> bool {
4908        self.inner.status()
4909    }
4910
4911    /// Current CUSUM statistic (`>= 0`).
4912    fn statistic(&self) -> f64 {
4913        self.inner.statistic()
4914    }
4915
4916    /// Running observation count.
4917    fn count(&self) -> usize {
4918        self.inner.count()
4919    }
4920
4921    /// Running mean of the observations seen so far.
4922    fn mean(&self) -> f64 {
4923        self.inner.mean()
4924    }
4925
4926    /// Running sample variance (`0` with fewer than 2 points).
4927    fn variance(&self) -> f64 {
4928        self.inner.variance()
4929    }
4930
4931    /// Running sample standard deviation.
4932    fn std(&self) -> f64 {
4933        self.inner.std()
4934    }
4935
4936    /// Drop all observations; tuning parameters are kept.
4937    fn reset(&mut self) {
4938        self.inner.reset();
4939    }
4940}
4941
4942// ---------------------------------------------------------------------------
4943// 0.3.0 Tier 1: deck round-trip, decay inventories, ARMI dialects, checks
4944// ---------------------------------------------------------------------------
4945
4946/// A parsed MCNP input deck with format-preserving write-back.
4947#[pyclass(name = "DeckProblem")]
4948struct PyDeckProblem {
4949    inner: std::sync::Mutex<nucleide_mcnp_io::problem::DeckProblem>,
4950}
4951
4952fn deck_cell_dict(cell: &nucleide_mcnp_io::cell::CellCard) -> BTreeMap<String, String> {
4953    let mut d = BTreeMap::new();
4954    d.insert("num".to_string(), cell.num.to_string());
4955    d.insert("mat".to_string(), cell.mat.to_string());
4956    d.insert(
4957        "dens".to_string(),
4958        cell.dens.map(|v| v.to_string()).unwrap_or_default(),
4959    );
4960    d.insert("geom".to_string(), cell.geom.render());
4961    d.insert("params".to_string(), cell.params.join(" "));
4962    d
4963}
4964
4965#[pymethods]
4966impl PyDeckProblem {
4967    /// Parse a deck from text.
4968    #[staticmethod]
4969    fn loads(text: &str) -> PyResult<Self> {
4970        nucleide_mcnp_io::problem::parse_deck(text)
4971            .map(|inner| Self {
4972                inner: std::sync::Mutex::new(inner),
4973            })
4974            .map_err(|e| PyValueError::new_err(e.to_string()))
4975    }
4976
4977    /// Message (first) line.
4978    #[getter]
4979    fn message(&self) -> PyResult<String> {
4980        Ok(self
4981            .inner
4982            .lock()
4983            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4984            .message
4985            .clone())
4986    }
4987
4988    /// Title card (second line).
4989    #[getter]
4990    fn title(&self) -> PyResult<String> {
4991        Ok(self
4992            .inner
4993            .lock()
4994            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
4995            .title
4996            .clone())
4997    }
4998
4999    /// Cell cards as `{num, mat, dens, geom, params}` dicts (`dens` is `""`
5000    /// for void cells).
5001    #[getter]
5002    fn cells(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5003        Ok(self
5004            .inner
5005            .lock()
5006            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5007            .cells
5008            .iter()
5009            .map(deck_cell_dict)
5010            .collect())
5011    }
5012
5013    /// Surface cards as `{num, reflecting, transform, periodic, kind, coeffs}`
5014    /// dicts (`transform`/`periodic` are `""` when absent).
5015    #[getter]
5016    fn surfs(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5017        Ok(self
5018            .inner
5019            .lock()
5020            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5021            .surfs
5022            .iter()
5023            .map(|s| {
5024                let mut d = BTreeMap::new();
5025                d.insert("num".to_string(), s.num.to_string());
5026                d.insert("reflecting".to_string(), s.reflecting.to_string());
5027                d.insert(
5028                    "transform".to_string(),
5029                    s.transform.map(|v| v.to_string()).unwrap_or_default(),
5030                );
5031                d.insert(
5032                    "periodic".to_string(),
5033                    s.periodic.map(|v| v.to_string()).unwrap_or_default(),
5034                );
5035                d.insert("kind".to_string(), s.kind.keyword().to_string());
5036                d.insert(
5037                    "coeffs".to_string(),
5038                    s.coeffs
5039                        .iter()
5040                        .map(|v| v.to_string())
5041                        .collect::<Vec<_>>()
5042                        .join(" "),
5043                );
5044                d
5045            })
5046            .collect())
5047    }
5048
5049    /// Material numbers in file order.
5050    #[getter]
5051    fn material_numbers(&self) -> PyResult<Vec<u32>> {
5052        Ok(self
5053            .inner
5054            .lock()
5055            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5056            .materials
5057            .iter()
5058            .map(|m| m.number)
5059            .collect())
5060    }
5061
5062    /// Data-card names in file order (`MODE`, `M1`, `KCODE`, ...).
5063    #[getter]
5064    fn data_names(&self) -> PyResult<Vec<String>> {
5065        Ok(self
5066            .inner
5067            .lock()
5068            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5069            .data
5070            .iter()
5071            .map(|d| d.name.clone())
5072            .collect())
5073    }
5074
5075    /// Serialize back to MCNP input text (byte-identical when unedited).
5076    fn dumps(&self) -> PyResult<String> {
5077        let guard = self
5078            .inner
5079            .lock()
5080            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
5081        Ok(nucleide_mcnp_io::problem::write_deck(&guard))
5082    }
5083
5084    /// Set a cell's density (re-renders that card canonically).
5085    fn set_cell_density(&self, cell: u32, dens: f64) -> PyResult<()> {
5086        self.inner
5087            .lock()
5088            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5089            .set_cell_density(cell, dens)
5090            .map_err(|e| PyValueError::new_err(e.to_string()))
5091    }
5092
5093    /// Set a cell's material number (re-renders that card canonically).
5094    fn set_cell_material(&self, cell: u32, mat: u32) -> PyResult<()> {
5095        self.inner
5096            .lock()
5097            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5098            .set_cell_material(cell, mat)
5099            .map_err(|e| PyValueError::new_err(e.to_string()))
5100    }
5101
5102    /// Typed `MODE` card as `{particles}` (`particles` is space-joined).
5103    #[getter]
5104    fn mode(&self) -> PyResult<BTreeMap<String, String>> {
5105        let mode = self
5106            .inner
5107            .lock()
5108            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5109            .mode()
5110            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5111        let mut d = BTreeMap::new();
5112        d.insert("particles".to_string(), mode.particles.join(" "));
5113        Ok(d)
5114    }
5115
5116    /// Typed `TRn` cards as `{number, displacement, rotation, in_degrees,
5117    /// main_to_aux, hidden}` dicts (vectors are space-joined).
5118    #[getter]
5119    fn transforms(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5120        let transforms = self
5121            .inner
5122            .lock()
5123            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5124            .transforms()
5125            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5126        Ok(transforms
5127            .iter()
5128            .map(|t| {
5129                let mut d = BTreeMap::new();
5130                d.insert("number".to_string(), t.number.to_string());
5131                d.insert(
5132                    "displacement".to_string(),
5133                    t.displacement
5134                        .iter()
5135                        .map(|v| v.to_string())
5136                        .collect::<Vec<_>>()
5137                        .join(" "),
5138                );
5139                d.insert(
5140                    "rotation".to_string(),
5141                    t.rotation
5142                        .iter()
5143                        .map(|v| v.to_string())
5144                        .collect::<Vec<_>>()
5145                        .join(" "),
5146                );
5147                d.insert("in_degrees".to_string(), t.is_in_degrees.to_string());
5148                d.insert("main_to_aux".to_string(), t.is_main_to_aux.to_string());
5149                d.insert("hidden".to_string(), t.hidden.to_string());
5150                d
5151            })
5152            .collect())
5153    }
5154
5155    /// Auto-created universes as `{number, cells, not_truncated}` dicts
5156    /// (cell lists are space-joined).
5157    #[getter]
5158    fn universes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5159        let universes = self
5160            .inner
5161            .lock()
5162            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5163            .universes()
5164            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5165        Ok(universes
5166            .iter()
5167            .map(|u| {
5168                let mut d = BTreeMap::new();
5169                d.insert("number".to_string(), u.number.to_string());
5170                d.insert(
5171                    "cells".to_string(),
5172                    u.cells
5173                        .iter()
5174                        .map(|v| v.to_string())
5175                        .collect::<Vec<_>>()
5176                        .join(" "),
5177                );
5178                d.insert(
5179                    "not_truncated".to_string(),
5180                    u.not_truncated
5181                        .iter()
5182                        .map(|v| v.to_string())
5183                        .collect::<Vec<_>>()
5184                        .join(" "),
5185                );
5186                d
5187            })
5188            .collect())
5189    }
5190
5191    /// Cell `LAT` assignments as `{cell, lattice}` dicts.
5192    #[getter]
5193    fn lattices(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5194        let lattices = self
5195            .inner
5196            .lock()
5197            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5198            .lattices()
5199            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5200        Ok(lattices
5201            .iter()
5202            .map(|l| {
5203                let mut d = BTreeMap::new();
5204                d.insert("cell".to_string(), l.cell.to_string());
5205                d.insert("lattice".to_string(), l.lattice.to_string());
5206                d
5207            })
5208            .collect())
5209    }
5210
5211    /// Cell `FILL` assignments as `{cell, kind, universe, min_index,
5212    /// max_index, universes, transform, hidden_transform, in_degrees}` dicts
5213    /// (`kind` is `single` or `matrix`; matrix empties render as `-`).
5214    #[getter]
5215    fn fills(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5216        use nucleide_mcnp_io::semantic::{FillTarget, FillTransform};
5217        let fills = self
5218            .inner
5219            .lock()
5220            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5221            .fills()
5222            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5223        Ok(fills
5224            .iter()
5225            .map(|f| {
5226                let mut d = BTreeMap::new();
5227                d.insert("cell".to_string(), f.cell.to_string());
5228                match &f.target {
5229                    FillTarget::Single(u) => {
5230                        d.insert("kind".to_string(), "single".to_string());
5231                        d.insert("universe".to_string(), u.to_string());
5232                        d.insert("min_index".to_string(), String::new());
5233                        d.insert("max_index".to_string(), String::new());
5234                        d.insert("universes".to_string(), String::new());
5235                    }
5236                    FillTarget::Matrix {
5237                        min_index,
5238                        max_index,
5239                        universes,
5240                    } => {
5241                        d.insert("kind".to_string(), "matrix".to_string());
5242                        d.insert("universe".to_string(), String::new());
5243                        d.insert(
5244                            "min_index".to_string(),
5245                            min_index
5246                                .iter()
5247                                .map(|v| v.to_string())
5248                                .collect::<Vec<_>>()
5249                                .join(" "),
5250                        );
5251                        d.insert(
5252                            "max_index".to_string(),
5253                            max_index
5254                                .iter()
5255                                .map(|v| v.to_string())
5256                                .collect::<Vec<_>>()
5257                                .join(" "),
5258                        );
5259                        d.insert(
5260                            "universes".to_string(),
5261                            universes
5262                                .iter()
5263                                .map(|u| {
5264                                    u.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
5265                                })
5266                                .collect::<Vec<_>>()
5267                                .join(" "),
5268                        );
5269                    }
5270                }
5271                match &f.transform {
5272                    None => {
5273                        d.insert("transform".to_string(), String::new());
5274                        d.insert("hidden_transform".to_string(), String::new());
5275                    }
5276                    Some(FillTransform::Reference(n)) => {
5277                        d.insert("transform".to_string(), n.to_string());
5278                        d.insert("hidden_transform".to_string(), String::new());
5279                    }
5280                    Some(FillTransform::Hidden(t)) => {
5281                        d.insert("transform".to_string(), String::new());
5282                        let mut coords: Vec<String> =
5283                            t.displacement.iter().map(|v| v.to_string()).collect();
5284                        coords.extend(t.rotation.iter().map(|v| v.to_string()));
5285                        d.insert("hidden_transform".to_string(), coords.join(" "));
5286                    }
5287                }
5288                d.insert("in_degrees".to_string(), f.in_degrees.to_string());
5289                d
5290            })
5291            .collect())
5292    }
5293
5294    /// Cell importance entries as `{cell, particle, value}` dicts.
5295    #[getter]
5296    fn importances(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5297        let importances = self
5298            .inner
5299            .lock()
5300            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5301            .importances()
5302            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5303        Ok(importances
5304            .iter()
5305            .map(|v| {
5306                let mut d = BTreeMap::new();
5307                d.insert("cell".to_string(), v.cell.to_string());
5308                d.insert("particle".to_string(), v.particle.clone());
5309                d.insert("value".to_string(), v.value.to_string());
5310                d
5311            })
5312            .collect())
5313    }
5314
5315    /// Manual cell volumes as `{cell, volume}` dicts.
5316    #[getter]
5317    fn volumes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5318        let volumes = self
5319            .inner
5320            .lock()
5321            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5322            .volumes()
5323            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5324        Ok(volumes
5325            .iter()
5326            .map(|v| {
5327                let mut d = BTreeMap::new();
5328                d.insert("cell".to_string(), v.cell.to_string());
5329                d.insert("volume".to_string(), v.volume.to_string());
5330                d
5331            })
5332            .collect())
5333    }
5334
5335    /// Typed tallies as `{number, type, particles, entries, fm, e_bins}` dicts
5336    /// (lists are space-joined, absent groups are `""`).
5337    #[getter]
5338    fn tallies(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5339        let tallies = self
5340            .inner
5341            .lock()
5342            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5343            .tallies()
5344            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5345        Ok(tallies
5346            .iter()
5347            .map(|t| {
5348                let mut d = BTreeMap::new();
5349                d.insert("number".to_string(), t.number.to_string());
5350                d.insert("type".to_string(), t.tally_type.to_string());
5351                d.insert("particles".to_string(), t.particles.join(","));
5352                d.insert("entries".to_string(), t.entries.join(" "));
5353                d.insert("fm".to_string(), t.fm.clone().unwrap_or_default().join(" "));
5354                d.insert(
5355                    "e_bins".to_string(),
5356                    t.e_bins.clone().unwrap_or_default().join(" "),
5357                );
5358                d
5359            })
5360            .collect())
5361    }
5362
5363    /// Typed `SDEF` fixed-source card as a dict (`None` when the deck has no
5364    /// `SDEF` card). See [`parse_sdef`] for the dict shape.
5365    #[getter]
5366    fn sdef(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
5367        let guard = self
5368            .inner
5369            .lock()
5370            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
5371        let sdef = guard
5372            .sdef()
5373            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5374        sdef.map(|s| sdef_to_py(py, &s)).transpose()
5375    }
5376
5377    /// Validate every L3 semantic rule (duplicate numbers, dangling links,
5378    /// redundant definitions, write-time state, lattice/fill cross-checks).
5379    fn validate(&self) -> PyResult<()> {
5380        self.inner
5381            .lock()
5382            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5383            .validate()
5384            .map_err(|e| PyValueError::new_err(e.to_string()))
5385    }
5386
5387    /// Non-fatal validation notes (particle/mode mismatches).
5388    fn validation_notes(&self) -> PyResult<Vec<String>> {
5389        Ok(self
5390            .inner
5391            .lock()
5392            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5393            .validation_notes())
5394    }
5395
5396    /// Set the `MODE` card particles (re-renders that card canonically).
5397    fn set_mode(&self, particles: Vec<String>) -> PyResult<()> {
5398        self.inner
5399            .lock()
5400            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5401            .set_mode(particles)
5402            .map_err(|e| PyValueError::new_err(e.to_string()))
5403    }
5404
5405    /// Set a cell's universe (`not_truncated` writes `U=-n`).
5406    fn set_cell_universe(&self, cell: u32, universe: u32, not_truncated: bool) -> PyResult<()> {
5407        self.inner
5408            .lock()
5409            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5410            .set_cell_universe(cell, universe, not_truncated)
5411            .map_err(|e| PyValueError::new_err(e.to_string()))
5412    }
5413
5414    /// Set (`1`/`2`) or clear (`None`) a cell's lattice.
5415    fn set_cell_lattice(&self, cell: u32, lattice: Option<u8>) -> PyResult<()> {
5416        self.inner
5417            .lock()
5418            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5419            .set_cell_lattice(cell, lattice)
5420            .map_err(|e| PyValueError::new_err(e.to_string()))
5421    }
5422
5423    /// Set a cell's fill to a single universe.
5424    fn set_cell_fill(&self, cell: u32, universe: u32) -> PyResult<()> {
5425        self.inner
5426            .lock()
5427            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5428            .set_cell_fill(cell, universe)
5429            .map_err(|e| PyValueError::new_err(e.to_string()))
5430    }
5431}
5432
5433/// Parse an MCNP input deck file into a [`PyDeckProblem`].
5434#[pyfunction]
5435fn read_deck(path: &str) -> PyResult<PyDeckProblem> {
5436    nucleide_mcnp_io::problem::parse_deck_file(path)
5437        .map(|inner| PyDeckProblem {
5438            inner: std::sync::Mutex::new(inner),
5439        })
5440        .map_err(|e| PyValueError::new_err(e.to_string()))
5441}
5442
5443/// Parse MCNP input deck text into a [`PyDeckProblem`].
5444#[pyfunction]
5445fn parse_deck(text: &str) -> PyResult<PyDeckProblem> {
5446    PyDeckProblem::loads(text)
5447}
5448
5449/// Render a typed `SDEF` source as a Python dict.
5450///
5451/// Shape: `{pos, cell, surf, vec, dir, erg, nrm, par, wgt, tme}` (canonical
5452/// strings, `""` when absent; `Dn` references render as `D<n>`),
5453/// `ignored` (verbatim out-of-subset keyword tokens), `distributions`
5454/// (`[{number, si_option, si, sp_option, sp, sb_option, sb}]`, value lists
5455/// space-joined, absent `SPn`/`SBn` groups render as `""`), and `card` (the
5456/// canonical re-emission, so `parse_sdef(d["card"])["card"] == d["card"]`).
5457fn sdef_to_py(py: Python<'_>, sdef: &nucleide_mcnp_io::sdef::SdefProblem) -> PyResult<Py<PyAny>> {
5458    use pyo3::types::PyDict;
5459    let opt3 = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<[f64; 3]>>| {
5460        v.as_ref().map(|r| r.render()).unwrap_or_default()
5461    };
5462    let opt1 = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<f64>>| {
5463        v.as_ref().map(|r| r.render()).unwrap_or_default()
5464    };
5465    let optu = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<u32>>| {
5466        v.as_ref().map(|r| r.render()).unwrap_or_default()
5467    };
5468    let d = PyDict::new(py);
5469    d.set_item("pos", opt3(&sdef.card.pos))?;
5470    d.set_item("cell", optu(&sdef.card.cell))?;
5471    d.set_item("surf", optu(&sdef.card.surf))?;
5472    d.set_item("vec", opt3(&sdef.card.vec))?;
5473    d.set_item("dir", opt1(&sdef.card.dir))?;
5474    d.set_item("axs", opt3(&sdef.card.axs))?;
5475    d.set_item("rad", opt1(&sdef.card.rad))?;
5476    d.set_item("ext", opt1(&sdef.card.ext))?;
5477    d.set_item("erg", opt1(&sdef.card.erg))?;
5478    d.set_item("nrm", opt1(&sdef.card.nrm))?;
5479    d.set_item(
5480        "par",
5481        sdef.card
5482            .par
5483            .as_ref()
5484            .map(|r| r.render())
5485            .unwrap_or_default(),
5486    )?;
5487    d.set_item("wgt", opt1(&sdef.card.wgt))?;
5488    d.set_item("tme", opt1(&sdef.card.tme))?;
5489    d.set_item("ignored", sdef.card.ignored.clone())?;
5490    let dists: Vec<Py<PyAny>> = sdef
5491        .dists
5492        .iter()
5493        .map(|dist| {
5494            let m = PyDict::new(py);
5495            m.set_item("number", dist.number.to_string())?;
5496            m.set_item("si_option", "L")?;
5497            m.set_item("si", dist.si_text())?;
5498            m.set_item(
5499                "sp_option",
5500                dist.sp.as_ref().map(|_| "D").unwrap_or_default(),
5501            )?;
5502            m.set_item("sp", dist.sp_text())?;
5503            m.set_item(
5504                "sb_option",
5505                dist.sb.as_ref().map(|_| "D").unwrap_or_default(),
5506            )?;
5507            m.set_item("sb", dist.sb_text())?;
5508            Ok(m.into_any().unbind())
5509        })
5510        .collect::<PyResult<Vec<_>>>()?;
5511    d.set_item("distributions", dists)?;
5512    d.set_item("card", sdef.emit())?;
5513    Ok(d.into_any().unbind())
5514}
5515
5516/// Parse standalone `SDEF` card text (plus `SI`/`SP`/`SB` cards, e.g. the
5517/// decay-source emitter's output) into the [`sdef_to_py`] dict shape.
5518/// Raises `ValueError` when no `SDEF` card is present or any validation rule
5519/// fails (duplicate cards, non-discrete distribution forms, dangling `Dn`
5520/// references, orphan `SPn`/`SBn` cards, entry-count mismatches).
5521#[pyfunction]
5522fn parse_sdef(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
5523    let sdef = nucleide_mcnp_io::sdef::parse_sdef_text(text)
5524        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5525    sdef_to_py(py, &sdef)
5526}
5527
5528/// Translate one deck's CSG to OpenMC `geometry.xml`.
5529///
5530/// Returns `(xml, drift)` where `drift` is `[{scope, target, action,
5531/// reason}]` (all strings; `target` is the cell/surface number, `"0"` for
5532/// deck scope). Scoped v1: surfaces, cells, and a material stub only;
5533/// transforms, universes, tallies, and sources raise `ValueError`.
5534fn csg_to_openmc_inner(
5535    deck: &nucleide_mcnp_io::problem::DeckProblem,
5536) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5537    let (xml, table) = nucleide_csg_xlate::deck_csg_to_openmc_xml(deck)
5538        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5539    Ok((
5540        xml,
5541        table
5542            .entries
5543            .into_iter()
5544            .map(|e| {
5545                let mut d = BTreeMap::new();
5546                d.insert("scope".to_string(), e.scope.to_string());
5547                d.insert("target".to_string(), e.target.to_string());
5548                d.insert("action".to_string(), e.action);
5549                d.insert("reason".to_string(), e.reason);
5550                d
5551            })
5552            .collect(),
5553    ))
5554}
5555
5556/// Translate MCNP deck text to OpenMC `geometry.xml` plus drift report.
5557/// See [`csg_to_openmc_inner`].
5558#[pyfunction]
5559fn parse_csg_to_openmc(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5560    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5561        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5562    csg_to_openmc_inner(&deck)
5563}
5564
5565/// Translate an MCNP deck file to OpenMC `geometry.xml` plus drift report.
5566/// See [`csg_to_openmc_inner`].
5567#[pyfunction]
5568fn read_csg_to_openmc(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5569    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5570        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5571    csg_to_openmc_inner(&deck)
5572}
5573
5574/// Translate one deck's CSG to Serpent input (`surf`/`cell` cards).
5575///
5576/// Returns `(text, drift)` with the same drift shape as
5577/// [`csg_to_openmc_inner`]. Same v2 scope (surfaces, cells, nested
5578/// universes, material-name stub); reflecting and periodic boundaries
5579/// raise `ValueError` (no verified Serpent mapping).
5580fn csg_to_serpent_inner(
5581    deck: &nucleide_mcnp_io::problem::DeckProblem,
5582) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5583    let (text, table) = nucleide_csg_xlate::deck_csg_to_serpent_input(deck)
5584        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5585    Ok((
5586        text,
5587        table
5588            .entries
5589            .into_iter()
5590            .map(|e| {
5591                let mut d = BTreeMap::new();
5592                d.insert("scope".to_string(), e.scope.to_string());
5593                d.insert("target".to_string(), e.target.to_string());
5594                d.insert("action".to_string(), e.action);
5595                d.insert("reason".to_string(), e.reason);
5596                d
5597            })
5598            .collect(),
5599    ))
5600}
5601
5602/// Translate MCNP deck text to Serpent input plus drift report.
5603/// See [`csg_to_serpent_inner`].
5604#[pyfunction]
5605fn parse_csg_to_serpent(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5606    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5607        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5608    csg_to_serpent_inner(&deck)
5609}
5610
5611/// Translate an MCNP deck file to Serpent input plus drift report.
5612/// See [`csg_to_serpent_inner`].
5613#[pyfunction]
5614fn read_csg_to_serpent(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5615    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5616        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5617    csg_to_serpent_inner(&deck)
5618}
5619
5620/// Translate one deck's CSG to PHITS `[Surface]`/`[Cell]` sections.
5621///
5622/// Returns `(text, drift)` with the same drift shape as
5623/// [`csg_to_openmc_inner`]. Same v2 scope with PHITS-native spellings
5624/// (verbatim surface symbols, native `#` complement, `U=`/`FILL=` params,
5625/// `*` reflective surfaces, outer-void `-1` heuristic); periodic pointers
5626/// raise `ValueError` (no PHITS spelling).
5627fn csg_to_phits_inner(
5628    deck: &nucleide_mcnp_io::problem::DeckProblem,
5629) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5630    let (text, table) = nucleide_csg_xlate::deck_csg_to_phits_input(deck)
5631        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5632    Ok((
5633        text,
5634        table
5635            .entries
5636            .into_iter()
5637            .map(|e| {
5638                let mut d = BTreeMap::new();
5639                d.insert("scope".to_string(), e.scope.to_string());
5640                d.insert("target".to_string(), e.target.to_string());
5641                d.insert("action".to_string(), e.action);
5642                d.insert("reason".to_string(), e.reason);
5643                d
5644            })
5645            .collect(),
5646    ))
5647}
5648
5649/// Translate MCNP deck text to PHITS sections plus drift report.
5650/// See [`csg_to_phits_inner`].
5651#[pyfunction]
5652fn parse_csg_to_phits(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5653    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5654        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5655    csg_to_phits_inner(&deck)
5656}
5657
5658/// Translate an MCNP deck file to PHITS sections plus drift report.
5659/// See [`csg_to_phits_inner`].
5660#[pyfunction]
5661fn read_csg_to_phits(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5662    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5663        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5664    csg_to_phits_inner(&deck)
5665}
5666
5667/// Translate one deck's CSG to a GDML (Geant4) document.
5668///
5669/// Returns `(xml, drift)` with the same drift shape as
5670/// [`csg_to_openmc_inner`]. Same v3 scope: surfaces, cells, nested
5671/// universes (as `<assembly>` volumes), rectangular `LAT=1` lattices
5672/// (expanded to per-element placements), and `mat_<n>` material stubs the
5673/// caller replaces; reflecting and periodic boundaries raise `ValueError`
5674/// (no GDML spelling).
5675fn csg_to_gdml_inner(
5676    deck: &nucleide_mcnp_io::problem::DeckProblem,
5677) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5678    let (xml, table) = nucleide_csg_xlate::deck_csg_to_gdml(deck)
5679        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5680    Ok((
5681        xml,
5682        table
5683            .entries
5684            .into_iter()
5685            .map(|e| {
5686                let mut d = BTreeMap::new();
5687                d.insert("scope".to_string(), e.scope.to_string());
5688                d.insert("target".to_string(), e.target.to_string());
5689                d.insert("action".to_string(), e.action);
5690                d.insert("reason".to_string(), e.reason);
5691                d
5692            })
5693            .collect(),
5694    ))
5695}
5696
5697/// Translate MCNP deck text to a GDML document plus drift report.
5698/// See [`csg_to_gdml_inner`].
5699#[pyfunction]
5700fn parse_csg_to_gdml(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5701    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5702        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5703    csg_to_gdml_inner(&deck)
5704}
5705
5706/// Translate an MCNP deck file to a GDML document plus drift report.
5707/// See [`csg_to_gdml_inner`].
5708#[pyfunction]
5709fn read_csg_to_gdml(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5710    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5711        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5712    csg_to_gdml_inner(&deck)
5713}
5714
5715/// A unit-aware decay inventory over a depletion chain.
5716#[pyclass(name = "Inventory")]
5717struct PyInventory {
5718    chain: std::sync::Arc<nucleide_depletion::Chain>,
5719    atoms: BTreeMap<String, f64>,
5720}
5721
5722fn inventory_sys(
5723    chain: &nucleide_depletion::Chain,
5724    rates: &RateMap,
5725) -> PyResult<nucleide_depletion::DepletionSystem> {
5726    let rs = split_rates(rates, chain)?;
5727    nucleide_depletion::DepletionSystem::build(chain.clone(), &rs)
5728        .map_err(|e| PyValueError::new_err(e.to_string()))
5729}
5730
5731fn parse_quantity_unit(unit: &str) -> PyResult<nucleide_depletion::QuantityUnit> {
5732    nucleide_depletion::QuantityUnit::from_str(unit)
5733        .map_err(|e| PyValueError::new_err(format!("{e:?}")))
5734}
5735
5736#[pymethods]
5737impl PyInventory {
5738    /// Build from quantities in `units` (atom counts, `Bq`/`Ci` activity,
5739    /// `g`/`kg` mass, `mol`, ... — see `QuantityUnit`).
5740    #[new]
5741    #[pyo3(signature = (chain, comp, units="atoms"))]
5742    fn new(chain: &PyChain, comp: BTreeMap<String, f64>, units: &str) -> PyResult<Self> {
5743        let unit = parse_quantity_unit(units)?;
5744        let sys = inventory_sys(&chain.inner, &BTreeMap::new())?;
5745        let inv = nucleide_depletion::DecayInventory::from_units(&comp, unit, &sys)
5746            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5747        Ok(Self {
5748            chain: chain.inner.clone(),
5749            atoms: inv.atoms,
5750        })
5751    }
5752
5753    /// Atom counts by nuclide name.
5754    fn numbers(&self) -> BTreeMap<String, f64> {
5755        self.atoms.clone()
5756    }
5757
5758    /// Decay over `dt` in `time_unit` (`s`, `m`, `h`, `d`, `y`); optional
5759    /// one-group `rates` (`"Name:reaction"` keys), CRAM `order`, and solver
5760    /// `method` (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
5761    /// default `"cram48"` — an explicitly non-default `method` overrides
5762    /// `order`). Unlike the decay-only core inventory, this honors `rates`;
5763    /// a Bateman `method` with live rates falls back to CRAM-48.
5764    #[pyo3(signature = (dt, time_unit="s", rates=None, order=48, method="cram48"))]
5765    fn decay(
5766        &self,
5767        dt: f64,
5768        time_unit: &str,
5769        rates: Option<RateMap>,
5770        order: u8,
5771        method: &str,
5772    ) -> PyResult<Self> {
5773        let method = resolve_method(order, method)?;
5774        let unit = nucleide_depletion::inventory::time_unit_from_str(time_unit)
5775            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5776        let seconds = dt * unit.as_seconds();
5777        let empty = BTreeMap::new();
5778        let step_rates = rates.as_ref().unwrap_or(&empty);
5779        let template = inventory_sys(&self.chain, step_rates)?;
5780        // Route through the core series: predictor over one step equals the
5781        // single-kernel solve, and rates/method stay honored.
5782        let steps = vec![nucleide_depletion::Step::new(
5783            seconds,
5784            split_rates(step_rates, &self.chain)?,
5785        )];
5786        let series = nucleide_depletion::integrate_with_method(
5787            &template,
5788            &chain_vec(&self.chain, &self.atoms)?,
5789            &steps,
5790            nucleide_depletion::Integrator::Predictor,
5791            method,
5792        )
5793        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5794        let names: Vec<String> = self.chain.nuclides.iter().map(|n| n.name.clone()).collect();
5795        let atoms = names
5796            .iter()
5797            .zip(series.atoms.last().cloned().unwrap_or_default())
5798            .map(|(n, v)| (n.clone(), v))
5799            .collect();
5800        Ok(Self {
5801            chain: self.chain.clone(),
5802            atoms,
5803        })
5804    }
5805
5806    /// Activity per nuclide in `units`.
5807    fn activities(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5808        let unit = parse_quantity_unit(units)?;
5809        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5810        let inv = nucleide_depletion::DecayInventory {
5811            atoms: self.atoms.clone(),
5812        };
5813        inv.activities(&sys, unit)
5814            .map_err(|e| PyValueError::new_err(e.to_string()))
5815    }
5816
5817    /// Mass per nuclide in `units`.
5818    fn masses(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5819        let unit = parse_quantity_unit(units)?;
5820        let inv = nucleide_depletion::DecayInventory {
5821            atoms: self.atoms.clone(),
5822        };
5823        inv.masses(unit)
5824            .map_err(|e| PyValueError::new_err(e.to_string()))
5825    }
5826
5827    /// Moles per nuclide in `units`.
5828    fn moles(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5829        let unit = parse_quantity_unit(units)?;
5830        let inv = nucleide_depletion::DecayInventory {
5831            atoms: self.atoms.clone(),
5832        };
5833        inv.moles(unit)
5834            .map_err(|e| PyValueError::new_err(e.to_string()))
5835    }
5836
5837    /// Activity fractions by nuclide name.
5838    fn activity_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5839        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5840        let inv = nucleide_depletion::DecayInventory {
5841            atoms: self.atoms.clone(),
5842        };
5843        inv.activity_fractions(&sys)
5844            .map_err(|e| PyValueError::new_err(e.to_string()))
5845    }
5846
5847    /// Mass fractions by nuclide name.
5848    fn mass_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5849        let inv = nucleide_depletion::DecayInventory {
5850            atoms: self.atoms.clone(),
5851        };
5852        inv.mass_fractions()
5853            .map_err(|e| PyValueError::new_err(e.to_string()))
5854    }
5855
5856    /// Mole fractions by nuclide name.
5857    fn mole_fractions(&self) -> BTreeMap<String, f64> {
5858        nucleide_depletion::DecayInventory {
5859            atoms: self.atoms.clone(),
5860        }
5861        .mole_fractions()
5862    }
5863
5864    /// Human-readable half-lives (`"3.2 d"`, `"stable"`, `"unknown"`).
5865    fn half_lives_readable(&self) -> BTreeMap<String, String> {
5866        nucleide_depletion::DecayInventory {
5867            atoms: self.atoms.clone(),
5868        }
5869        .half_lives_readable()
5870    }
5871
5872    /// Add two inventories (atom counts sum).
5873    fn add(&self, other: &Self) -> Self {
5874        let a = nucleide_depletion::DecayInventory {
5875            atoms: self.atoms.clone(),
5876        };
5877        let b = nucleide_depletion::DecayInventory {
5878            atoms: other.atoms.clone(),
5879        };
5880        Self {
5881            chain: self.chain.clone(),
5882            atoms: a.add(&b).atoms,
5883        }
5884    }
5885
5886    /// Subtract (clamped at zero).
5887    fn sub(&self, other: &Self) -> Self {
5888        let a = nucleide_depletion::DecayInventory {
5889            atoms: self.atoms.clone(),
5890        };
5891        let b = nucleide_depletion::DecayInventory {
5892            atoms: other.atoms.clone(),
5893        };
5894        Self {
5895            chain: self.chain.clone(),
5896            atoms: a.sub(&b).atoms,
5897        }
5898    }
5899
5900    /// Scale by a scalar.
5901    fn mul(&self, scalar: f64) -> Self {
5902        let a = nucleide_depletion::DecayInventory {
5903            atoms: self.atoms.clone(),
5904        };
5905        Self {
5906            chain: self.chain.clone(),
5907            atoms: a.mul(scalar).atoms,
5908        }
5909    }
5910
5911    /// Divide by a scalar.
5912    fn div(&self, scalar: f64) -> Self {
5913        let a = nucleide_depletion::DecayInventory {
5914            atoms: self.atoms.clone(),
5915        };
5916        Self {
5917            chain: self.chain.clone(),
5918            atoms: a.div(scalar).atoms,
5919        }
5920    }
5921
5922    /// Serialize as `nuclide,atoms` CSV rows.
5923    fn to_csv(&self) -> String {
5924        nucleide_depletion::DecayInventory {
5925            atoms: self.atoms.clone(),
5926        }
5927        .to_csv()
5928    }
5929
5930    /// Parse `to_csv` output back into an inventory over `chain`.
5931    #[staticmethod]
5932    fn from_csv(chain: &PyChain, text: &str) -> PyResult<Self> {
5933        // Validate names against the chain (core from_csv is chain-free).
5934        let inv = nucleide_depletion::DecayInventory::from_csv(text)
5935            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5936        for name in inv.atoms.keys() {
5937            if chain.inner.index_of(name).is_none() {
5938                return Err(PyValueError::new_err(format!(
5939                    "unknown nuclide `{name}` for this chain"
5940                )));
5941            }
5942        }
5943        Ok(Self {
5944            chain: chain.inner.clone(),
5945            atoms: inv.atoms,
5946        })
5947    }
5948}
5949
5950/// Atom vector in chain order for an inventory map (unknown names error).
5951fn chain_vec(
5952    chain: &nucleide_depletion::Chain,
5953    atoms: &BTreeMap<String, f64>,
5954) -> PyResult<Vec<f64>> {
5955    let mut vec = vec![0.0; chain.len()];
5956    for (name, value) in atoms {
5957        let idx = chain.index_of(name).ok_or_else(|| {
5958            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
5959        })?;
5960        vec[idx] = *value;
5961    }
5962    Ok(vec)
5963}
5964
5965/// Time-integrated decays per nuclide over one step (chain order → names).
5966#[pyfunction]
5967#[pyo3(signature = (chain, n0, dt, rates=None))]
5968fn cumulative_decays(
5969    chain: &PyChain,
5970    n0: BTreeMap<String, f64>,
5971    dt: f64,
5972    rates: Option<RateMap>,
5973) -> PyResult<BTreeMap<String, f64>> {
5974    let empty = BTreeMap::new();
5975    let sys = inventory_sys(&chain.inner, rates.as_ref().unwrap_or(&empty))?;
5976    let vec = chain_vec(&chain.inner, &n0)?;
5977    let out = nucleide_depletion::cumulative_decays(&sys, &vec, dt)
5978        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5979    Ok(chain
5980        .inner
5981        .nuclides
5982        .iter()
5983        .zip(out)
5984        .map(|(nuc, v)| (nuc.name.clone(), v))
5985        .collect())
5986}
5987
5988/// `(child, branching_ratio, decay_mode)` triples for a chain nuclide.
5989#[pyfunction]
5990fn progeny(chain: &PyChain, name: &str) -> Vec<(String, f64, String)> {
5991    nucleide_depletion::progeny(&chain.inner, name)
5992}
5993
5994/// Branching fraction from parent to child, if the decay exists.
5995#[pyfunction]
5996fn branching_fraction(chain: &PyChain, parent: &str, child: &str) -> Option<f64> {
5997    nucleide_depletion::branching_fraction(&chain.inner, parent, child)
5998}
5999
6000/// Decay-mode label from parent to child, if the decay exists.
6001#[pyfunction]
6002fn decay_mode(chain: &PyChain, parent: &str, child: &str) -> Option<String> {
6003    nucleide_depletion::decay_mode(&chain.inner, parent, child)
6004}
6005
6006/// `(parent, child, branching_ratio, decay_mode)` edges of a chain.
6007#[pyfunction]
6008fn chain_edges(chain: &PyChain) -> Vec<(String, String, f64, String)> {
6009    nucleide_depletion::chain_edges(&chain.inner)
6010}
6011
6012/// Parse an ARMI nuclide label (`nU235`, `92235`, ...) into a [`PyNuclide`].
6013#[pyfunction]
6014fn armi_to_nucid(name: &str) -> PyResult<PyNuclide> {
6015    nucleide_nuclei::armi::armi_name_to_nucid(name)
6016        .map(|inner| PyNuclide { inner })
6017        .map_err(|e| PyValueError::new_err(e.to_string()))
6018}
6019
6020/// Render a nuclide in ARMI database-label form.
6021#[pyfunction]
6022fn nucid_to_armi(nuclide: &PyNuclide) -> String {
6023    nucleide_nuclei::armi::nucid_to_armi_label(nuclide.inner)
6024}
6025
6026/// Parse an MCC3-style nuclide label into a [`PyNuclide`].
6027#[pyfunction]
6028fn mcc3_to_nucid(name: &str) -> PyResult<PyNuclide> {
6029    nucleide_nuclei::armi::mcc3_to_nucid(name)
6030        .map(|inner| PyNuclide { inner })
6031        .map_err(|e| PyValueError::new_err(e.to_string()))
6032}
6033
6034/// Truncated-label collisions in a composition at DIF3D/MC2 widths.
6035///
6036/// `comp` maps nuclide names to grams; `widths` defaults to `[6, 8]`.
6037/// Returns `[{truncated, width, members}]`.
6038#[pyfunction]
6039#[pyo3(signature = (comp, widths=None))]
6040fn check_labels(
6041    comp: BTreeMap<String, f64>,
6042    widths: Option<Vec<usize>>,
6043) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6044    let mat = comp_to_material(comp)?;
6045    let widths = widths.unwrap_or_else(|| nucleide_material::DEFAULT_WIDTHS.to_vec());
6046    let collisions = nucleide_material::check_labels(&mat, &widths);
6047    Python::attach(|py| {
6048        Ok(collisions
6049            .into_iter()
6050            .map(|c| {
6051                let mut d = BTreeMap::new();
6052                d.insert(
6053                    "truncated".to_string(),
6054                    c.truncated.into_pyobject(py).unwrap().unbind().into_any(),
6055                );
6056                d.insert(
6057                    "width".to_string(),
6058                    c.width.into_pyobject(py).unwrap().unbind().into_any(),
6059                );
6060                let members: Vec<String> = c.members.iter().map(|id| id.to_name()).collect();
6061                d.insert(
6062                    "members".to_string(),
6063                    members.into_pyobject(py).unwrap().unbind().into_any(),
6064                );
6065                d
6066            })
6067            .collect())
6068    })
6069}
6070
6071/// Conservation audit of a composition: `[{kind, detail}]` (empty = clean).
6072#[pyfunction]
6073fn audit_material(comp: BTreeMap<String, f64>) -> PyResult<Vec<BTreeMap<String, String>>> {
6074    let mat = comp_to_material(comp)?;
6075    Ok(nucleide_material::audit(&mat, &nucleide_material::Ame2020)
6076        .into_iter()
6077        .map(|issue| {
6078            let mut d = BTreeMap::new();
6079            d.insert("kind".to_string(), format!("{:?}", issue.kind));
6080            d.insert("detail".to_string(), issue.detail);
6081            d
6082        })
6083        .collect())
6084}
6085
6086/// Emit one composition through all five code dialects (MCNP, Serpent, FLUKA,
6087/// ALARA, PARTISN). Returns `{code: card_text}`.
6088///
6089/// `comp` maps nuclide names to grams; `density` is mass density [g/cm³] for
6090/// dialects that need one (falls back to none — Serpent/FLUKA/PARTISN error
6091/// without it).
6092#[pyfunction]
6093#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6094#[allow(clippy::too_many_arguments)]
6095fn emit_cards(
6096    comp: BTreeMap<String, f64>,
6097    name: &str,
6098    density: Option<f64>,
6099    mcnp_number: u32,
6100    xs_suffix: &str,
6101    serpent_lib: &str,
6102    fluka_fid: u32,
6103    partisn_zone: u32,
6104) -> PyResult<BTreeMap<String, String>> {
6105    let (emitted, _) = emit_drift_inner(
6106        comp,
6107        name,
6108        density,
6109        mcnp_number,
6110        xs_suffix,
6111        serpent_lib,
6112        fluka_fid,
6113        partisn_zone,
6114    )?;
6115    Ok(emitted
6116        .into_iter()
6117        .map(|e| (e.code.to_string(), e.text))
6118        .collect())
6119}
6120
6121/// Mass-drift report for one composition across all five code dialects.
6122/// Returns `[{code, mass_in, mass_out, rel_drift, dropped: [{nuclide, mass,
6123/// reason}], reparsed}]`.
6124#[pyfunction]
6125#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6126#[allow(clippy::too_many_arguments)]
6127fn emit_drift_table(
6128    comp: BTreeMap<String, f64>,
6129    name: &str,
6130    density: Option<f64>,
6131    mcnp_number: u32,
6132    xs_suffix: &str,
6133    serpent_lib: &str,
6134    fluka_fid: u32,
6135    partisn_zone: u32,
6136) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6137    let (_, table) = emit_drift_inner(
6138        comp,
6139        name,
6140        density,
6141        mcnp_number,
6142        xs_suffix,
6143        serpent_lib,
6144        fluka_fid,
6145        partisn_zone,
6146    )?;
6147    drift_table_to_py(table)
6148}
6149
6150fn drift_table_to_py(
6151    table: nucleide_emit::DriftTable,
6152) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6153    Python::attach(|py| {
6154        Ok(table
6155            .rows
6156            .into_iter()
6157            .map(|r| {
6158                let mut d = BTreeMap::new();
6159                d.insert(
6160                    "code".to_string(),
6161                    r.code
6162                        .to_string()
6163                        .into_pyobject(py)
6164                        .unwrap()
6165                        .unbind()
6166                        .into_any(),
6167                );
6168                d.insert(
6169                    "mass_in".to_string(),
6170                    r.mass_in.into_pyobject(py).unwrap().unbind().into_any(),
6171                );
6172                d.insert(
6173                    "mass_out".to_string(),
6174                    r.mass_out.into_pyobject(py).unwrap().unbind().into_any(),
6175                );
6176                d.insert(
6177                    "rel_drift".to_string(),
6178                    r.rel_drift.into_pyobject(py).unwrap().unbind().into_any(),
6179                );
6180                let dropped: Vec<BTreeMap<String, Py<PyAny>>> = r
6181                    .dropped
6182                    .into_iter()
6183                    .map(|x| {
6184                        let mut dd = BTreeMap::new();
6185                        dd.insert(
6186                            "nuclide".to_string(),
6187                            x.id.to_name()
6188                                .into_pyobject(py)
6189                                .unwrap()
6190                                .unbind()
6191                                .into_any(),
6192                        );
6193                        dd.insert(
6194                            "mass".to_string(),
6195                            x.mass.into_pyobject(py).unwrap().unbind().into_any(),
6196                        );
6197                        dd.insert(
6198                            "reason".to_string(),
6199                            x.reason.into_pyobject(py).unwrap().unbind().into_any(),
6200                        );
6201                        dd
6202                    })
6203                    .collect();
6204                d.insert(
6205                    "dropped".to_string(),
6206                    dropped.into_pyobject(py).unwrap().unbind().into_any(),
6207                );
6208                d.insert(
6209                    "reparsed".to_string(),
6210                    pyo3::types::PyBool::new(py, r.reparsed)
6211                        .to_owned()
6212                        .into_any()
6213                        .unbind(),
6214                );
6215                d
6216            })
6217            .collect())
6218    })
6219}
6220
6221#[allow(clippy::too_many_arguments)]
6222fn emit_drift_inner(
6223    comp: BTreeMap<String, f64>,
6224    name: &str,
6225    density: Option<f64>,
6226    mcnp_number: u32,
6227    xs_suffix: &str,
6228    serpent_lib: &str,
6229    fluka_fid: u32,
6230    partisn_zone: u32,
6231) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6232    let mut mat = comp_to_material(comp)?;
6233    mat.set_density(density);
6234    emit_drift_with_mat(
6235        mat,
6236        name,
6237        mcnp_number,
6238        xs_suffix,
6239        serpent_lib,
6240        fluka_fid,
6241        partisn_zone,
6242    )
6243}
6244
6245#[allow(clippy::too_many_arguments)]
6246fn emit_drift_with_mat(
6247    mat: nucleide_material::Material,
6248    name: &str,
6249    mcnp_number: u32,
6250    xs_suffix: &str,
6251    serpent_lib: &str,
6252    fluka_fid: u32,
6253    partisn_zone: u32,
6254) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6255    let mut opts = nucleide_emit::EmitOptions::new(name);
6256    opts.mcnp_number = mcnp_number;
6257    opts.xs_suffix = xs_suffix.to_string();
6258    opts.serpent_lib = serpent_lib.to_string();
6259    opts.fluka_fid = fluka_fid;
6260    opts.partisn_zone = partisn_zone;
6261    nucleide_emit::emit_drift(&mat, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
6262}
6263
6264#[allow(clippy::too_many_arguments)]
6265fn emit_armi_drift_inner(
6266    comp: BTreeMap<String, f64>,
6267    name: &str,
6268    density: Option<f64>,
6269    mcnp_number: u32,
6270    xs_suffix: &str,
6271    serpent_lib: &str,
6272    fluka_fid: u32,
6273    partisn_zone: u32,
6274) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6275    // `from_armi_mass_fracs` sets the density exactly like `emit_drift_inner`
6276    // (`set_density(density)`), so the material is emission-ready here.
6277    let mat = nucleide_emit::armi::from_armi_mass_fracs(comp, density)
6278        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6279    emit_drift_with_mat(
6280        mat,
6281        name,
6282        mcnp_number,
6283        xs_suffix,
6284        serpent_lib,
6285        fluka_fid,
6286        partisn_zone,
6287    )
6288}
6289
6290/// Emit one ARMI-keyed composition through all five code dialects (MCNP,
6291/// Serpent, FLUKA, ALARA, PARTISN). Returns `{code: card_text}`.
6292///
6293/// `comp` maps ARMI nuclide keys (`nU235`, `92235`, `U-2355`, ...) to grams;
6294/// keys resolve via `nucleide_emit::armi::from_armi_mass_fracs` (elemental
6295/// keys, bare `AM242`, and negative/non-finite masses are `ValueError`s).
6296/// `density` is the hot mass density [g/cm³] for dialects that need one.
6297#[pyfunction]
6298#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6299#[allow(clippy::too_many_arguments)]
6300fn emit_armi_cards(
6301    comp: BTreeMap<String, f64>,
6302    name: &str,
6303    density: Option<f64>,
6304    mcnp_number: u32,
6305    xs_suffix: &str,
6306    serpent_lib: &str,
6307    fluka_fid: u32,
6308    partisn_zone: u32,
6309) -> PyResult<BTreeMap<String, String>> {
6310    let (emitted, _) = emit_armi_drift_inner(
6311        comp,
6312        name,
6313        density,
6314        mcnp_number,
6315        xs_suffix,
6316        serpent_lib,
6317        fluka_fid,
6318        partisn_zone,
6319    )?;
6320    Ok(emitted
6321        .into_iter()
6322        .map(|e| (e.code.to_string(), e.text))
6323        .collect())
6324}
6325
6326/// Mass-drift report for one ARMI-keyed composition across all five code
6327/// dialects. Returns `[{code, mass_in, mass_out, rel_drift, dropped:
6328/// [{nuclide, mass, reason}], reparsed}]`.
6329#[pyfunction]
6330#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6331#[allow(clippy::too_many_arguments)]
6332fn emit_armi_drift_table(
6333    comp: BTreeMap<String, f64>,
6334    name: &str,
6335    density: Option<f64>,
6336    mcnp_number: u32,
6337    xs_suffix: &str,
6338    serpent_lib: &str,
6339    fluka_fid: u32,
6340    partisn_zone: u32,
6341) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6342    let (_, table) = emit_armi_drift_inner(
6343        comp,
6344        name,
6345        density,
6346        mcnp_number,
6347        xs_suffix,
6348        serpent_lib,
6349        fluka_fid,
6350        partisn_zone,
6351    )?;
6352    drift_table_to_py(table)
6353}
6354
6355// ---------------------------------------------------------------------------
6356// Point kinetics (thin glue over `nucleide-kinetics`; solver stays in core)
6357// ---------------------------------------------------------------------------
6358
6359/// Parse a reactivity-spec dict into the core [`Reactivity`].
6360///
6361/// `kind` selects the schedule (`"constant"`, `"step"`, `"impulse"`,
6362/// `"ramp"`, `"polyline"`); all reactivities are in Δk and all times in
6363/// seconds. Keys per kind: constant (`rho`); step (`t_step`, `rho_init`,
6364/// `rho_final`); impulse (`t_start`, `t_end`, `rho_init`, `rho_max`); ramp
6365/// (`t_start`, `t_end`, `rho_init`, `rho_rise`, `rho_final`); polyline
6366/// (`times`, `values`).
6367fn parse_reactivity(
6368    spec: &BTreeMap<String, Py<PyAny>>,
6369    py: Python<'_>,
6370) -> PyResult<nucleide_kinetics::Reactivity> {
6371    use nucleide_kinetics::Reactivity as R;
6372    let kind: String = get_str(spec, py, "kind", "reactivity spec needs a `kind`")?;
6373    let num = |key: &str| -> PyResult<f64> { get_num(spec, py, key) };
6374    let vec = |key: &str| -> PyResult<Vec<f64>> { get_vec(spec, py, key) };
6375    let r = match kind.as_str() {
6376        "constant" => R::Constant { rho: num("rho")? },
6377        "step" => R::Step {
6378            t_step: num("t_step")?,
6379            rho_init: num("rho_init")?,
6380            rho_final: num("rho_final")?,
6381        },
6382        "impulse" => R::Impulse {
6383            t_start: num("t_start")?,
6384            t_end: num("t_end")?,
6385            rho_init: num("rho_init")?,
6386            rho_max: num("rho_max")?,
6387        },
6388        "ramp" => R::Ramp {
6389            t_start: num("t_start")?,
6390            t_end: num("t_end")?,
6391            rho_init: num("rho_init")?,
6392            rho_rise: num("rho_rise")?,
6393            rho_final: num("rho_final")?,
6394        },
6395        "polyline" => R::Polyline {
6396            times: vec("times")?,
6397            values: vec("values")?,
6398        },
6399        other => {
6400            return Err(PyValueError::new_err(format!(
6401                "unknown reactivity kind `{other}` (supported: constant, step, impulse, ramp, polyline)"
6402            )))
6403        }
6404    };
6405    r.validate()
6406        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6407    Ok(r)
6408}
6409
6410fn get_str(
6411    spec: &BTreeMap<String, Py<PyAny>>,
6412    py: Python<'_>,
6413    key: &str,
6414    missing: &str,
6415) -> PyResult<String> {
6416    spec.get(key)
6417        .ok_or_else(|| PyValueError::new_err(missing.to_string()))?
6418        .extract::<String>(py)
6419        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a string")))
6420}
6421
6422fn get_num(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<f64> {
6423    spec.get(key)
6424        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
6425        .extract::<f64>(py)
6426        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6427}
6428
6429fn get_vec(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<Vec<f64>> {
6430    spec.get(key)
6431        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
6432        .extract::<Vec<f64>>(py)
6433        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a list of numbers")))
6434}
6435
6436fn kinetics_params(
6437    betas: Vec<f64>,
6438    lambdas: Vec<f64>,
6439    lambda_gen: f64,
6440) -> PyResult<nucleide_kinetics::KineticParams> {
6441    nucleide_kinetics::KineticParams::new(betas, lambdas, lambda_gen)
6442        .map_err(|e| PyValueError::new_err(e.to_string()))
6443}
6444
6445/// Solve a prescribed-reactivity point-kinetics transient.
6446///
6447/// Thin wrapper over `nucleide_kinetics::solve`: `betas`/`lambdas`/`Lambda`
6448/// carry the delayed-neutron data (see `KineticParams::from_ifp` for the
6449/// OpenMC provenance note — decay constants are caller-supplied), `rho` is
6450/// a spec dict (see `parse_reactivity`), `t` the output grid in seconds,
6451/// `n0` the initial neutron level, `C0` the optional initial precursors
6452/// (defaults to equilibrium). `method` is `"trapezoidal"` (default) or
6453/// `"backward_euler"`. Returns a dict with `times`, `n`, `C`
6454/// (`[time][group]`), and the echo of the initial state (`n0`, `C0`).
6455#[pyfunction]
6456#[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))]
6457#[allow(clippy::too_many_arguments)]
6458fn kinetics_solve(
6459    py: Python<'_>,
6460    betas: Vec<f64>,
6461    lambdas: Vec<f64>,
6462    lambda_gen: f64,
6463    rho: BTreeMap<String, Py<PyAny>>,
6464    t: Vec<f64>,
6465    n0: f64,
6466    c0: Option<Vec<f64>>,
6467    method: &str,
6468    rtol: f64,
6469    atol: f64,
6470    dt_min: f64,
6471    dt_max: Option<f64>,
6472    max_steps: usize,
6473) -> PyResult<Py<PyAny>> {
6474    use nucleide_kinetics::{Method as M, SolverOptions};
6475    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6476    let rho = parse_reactivity(&rho, py)?;
6477    let grid =
6478        nucleide_kinetics::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
6479    let state = nucleide_kinetics::State::new(&params, n0, c0)
6480        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6481    let method = if method.eq_ignore_ascii_case("trapezoidal") {
6482        M::Trapezoidal
6483    } else if method.eq_ignore_ascii_case("backward_euler") {
6484        M::BackwardEuler
6485    } else {
6486        return Err(PyValueError::new_err(format!(
6487            "unknown kinetics method `{method}` (supported: trapezoidal, backward_euler)"
6488        )));
6489    };
6490    let opts = SolverOptions {
6491        method,
6492        rtol,
6493        atol,
6494        dt_min,
6495        dt_max: dt_max.unwrap_or(f64::INFINITY),
6496        max_steps,
6497    };
6498    let sol = nucleide_kinetics::solve(&params, &rho, &grid, &state, &opts)
6499        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6500    use pyo3::types::PyDict;
6501    let out = PyDict::new(py);
6502    out.set_item("times", &sol.times).ok();
6503    out.set_item("n", &sol.n).ok();
6504    out.set_item("C", &sol.c).ok();
6505    out.set_item("n0", sol.initial.n0).ok();
6506    out.set_item("C0", &sol.initial.c0).ok();
6507    Ok(out.into_any().unbind())
6508}
6509
6510/// Equilibrium precursor populations `C_i = beta_i/(lambda_i*Lambda)*n0`.
6511#[pyfunction]
6512fn kinetics_equilibrium(
6513    betas: Vec<f64>,
6514    lambdas: Vec<f64>,
6515    lambda_gen: f64,
6516    n0: f64,
6517) -> PyResult<Vec<f64>> {
6518    kinetics_params(betas, lambdas, lambda_gen)?
6519        .equilibrium_precursors(n0)
6520        .map_err(|e| PyValueError::new_err(e.to_string()))
6521}
6522
6523/// Initial rate `dn/dt` at `t = 0` for the given schedule and initials.
6524#[pyfunction]
6525#[pyo3(signature = (betas, lambdas, lambda_gen, rho, n0, c0=None))]
6526fn kinetics_initial_rate(
6527    py: Python<'_>,
6528    betas: Vec<f64>,
6529    lambdas: Vec<f64>,
6530    lambda_gen: f64,
6531    rho: BTreeMap<String, Py<PyAny>>,
6532    n0: f64,
6533    c0: Option<Vec<f64>>,
6534) -> PyResult<f64> {
6535    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6536    let rho = parse_reactivity(&rho, py)?;
6537    let state = nucleide_kinetics::State::new(&params, n0, c0)
6538        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6539    Ok(nucleide_kinetics::solve::initial_rate(
6540        &params, &rho, &state,
6541    ))
6542}
6543
6544/// Inhour right-hand side `rho(omega)` [Δk] for the given data.
6545#[pyfunction]
6546fn kinetics_inhour_rho(
6547    betas: Vec<f64>,
6548    lambdas: Vec<f64>,
6549    lambda_gen: f64,
6550    omega: f64,
6551) -> PyResult<f64> {
6552    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6553    nucleide_kinetics::rho_of_omega(&params, omega)
6554        .map_err(|e| PyValueError::new_err(e.to_string()))
6555}
6556
6557/// Stable period `T = 1/omega` [s] at reactivity `rho` [Δk] (`0 < rho < beta`).
6558#[pyfunction]
6559fn kinetics_stable_period(
6560    betas: Vec<f64>,
6561    lambdas: Vec<f64>,
6562    lambda_gen: f64,
6563    rho: f64,
6564) -> PyResult<f64> {
6565    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6566    nucleide_kinetics::stable_period(&params, rho).map_err(|e| PyValueError::new_err(e.to_string()))
6567}
6568
6569/// Prompt-jump estimate `n_before*(beta - rho_before)/(beta - rho_after)`.
6570///
6571/// Needs `rho_after < beta_total`; `beta_total` is the caller's total
6572/// delayed fraction (pass `sum(betas)`).
6573#[pyfunction]
6574fn kinetics_prompt_jump(
6575    n_before: f64,
6576    rho_before: f64,
6577    rho_after: f64,
6578    beta_total: f64,
6579) -> PyResult<f64> {
6580    nucleide_kinetics::prompt_jump(n_before, rho_before, rho_after, beta_total)
6581        .map_err(|e| PyValueError::new_err(e.to_string()))
6582}
6583
6584// ---------------------------------------------------------------------------
6585// Neutron spectrum unfolding (thin glue over `nucleide-unfold`; model stays in core)
6586// ---------------------------------------------------------------------------
6587
6588/// SAND-II iterative spectral adjustment (McElroy et al., AFWL-TR-67-41, 1967).
6589///
6590/// Thin wrapper over `nucleide_unfold::sandii::unfold`: `response` holds one
6591/// row per detector/reaction (all rows one value per energy group),
6592/// `rates` the measured rate per detector, and `guess` one strictly positive
6593/// value per energy group. `tolerance` is the largest per-group relative
6594/// change between successive adjustments the run converges under (strictly
6595/// below); `max_iterations` is the explicit adjustment cap — exhausting it
6596/// raises a `ValueError` (non-convergence is a hard fail, never a silent
6597/// partial spectrum). Returns a dict with `spectrum`, the folded `rates`,
6598/// per-detector `rate_factors` (measured/folded), `iterations`, the echo of
6599/// `tolerance`, and the final `max_rel_change`.
6600#[pyfunction]
6601#[pyo3(signature = (response, rates, guess, tolerance=1e-3, max_iterations=200))]
6602fn unfold_sandii(
6603    py: Python<'_>,
6604    response: Vec<Vec<f64>>,
6605    rates: Vec<f64>,
6606    guess: Vec<f64>,
6607    tolerance: f64,
6608    max_iterations: usize,
6609) -> PyResult<Py<PyAny>> {
6610    let sol = nucleide_unfold::sandii::unfold(&response, &rates, &guess, tolerance, max_iterations)
6611        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6612    use pyo3::types::PyDict;
6613    let out = PyDict::new(py);
6614    out.set_item("spectrum", &sol.spectrum).ok();
6615    out.set_item("rates", &sol.rates).ok();
6616    out.set_item("rate_factors", &sol.rate_factors).ok();
6617    out.set_item("iterations", sol.iterations).ok();
6618    out.set_item("tolerance", sol.tolerance).ok();
6619    out.set_item("max_rel_change", sol.max_rel_change).ok();
6620    Ok(out.into_any().unbind())
6621}
6622
6623/// Forward operator: fold a spectrum through a response matrix (one rate per
6624/// detector row). This is the map the unfolding adjusts against — also the
6625/// natural way to synthesize round-trip rates from a known spectrum.
6626#[pyfunction]
6627fn unfold_forward_fold(response: Vec<Vec<f64>>, spectrum: Vec<f64>) -> PyResult<Vec<f64>> {
6628    nucleide_unfold::forward_fold(&response, &spectrum)
6629        .map_err(|e| PyValueError::new_err(e.to_string()))
6630}
6631
6632// ---------------------------------------------------------------------------
6633// Tokamak fusion sources (thin glue over `nucleide-plasma-source`; model stays in core)
6634// ---------------------------------------------------------------------------
6635
6636/// Parse a reaction name (`"dt"`, `"dd"`, case/separator-insensitive).
6637fn parse_plasma_reaction(name: &str) -> PyResult<nucleide_plasma_source::FusionReaction> {
6638    use nucleide_plasma_source::FusionReaction as R;
6639    match name
6640        .to_ascii_lowercase()
6641        .replace(['-', '_', ' '], "")
6642        .as_str()
6643    {
6644        "dt" => Ok(R::Dt),
6645        "dd" => Ok(R::Dd),
6646        other => Err(PyValueError::new_err(format!(
6647            "unknown fusion reaction `{other}` (supported: dt, dd)"
6648        ))),
6649    }
6650}
6651
6652/// A parsed source spec: ring/point or parametric plasma.
6653enum PyPlasmaSource {
6654    Basic(nucleide_plasma_source::PlasmaSourceConfig),
6655    Parametric(nucleide_plasma_source::ParametricPlasmaConfig),
6656}
6657
6658/// Parse a source-spec dict into a ring/point [`PlasmaSourceConfig`].
6659fn parse_plasma_basic_spec(
6660    spec: &BTreeMap<String, Py<PyAny>>,
6661    py: Python<'_>,
6662    kind: &str,
6663) -> PyResult<nucleide_plasma_source::PlasmaSourceConfig> {
6664    use nucleide_plasma_source as ps;
6665    let num = |key: &str| -> PyResult<f64> {
6666        spec.get(key)
6667            .ok_or_else(|| PyValueError::new_err(format!("source spec missing `{key}`")))?
6668            .extract::<f64>(py)
6669            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6670    };
6671    let reaction = parse_plasma_reaction(&get_str(
6672        spec,
6673        py,
6674        "reaction",
6675        "source spec missing `reaction`",
6676    )?)?;
6677    let model = match kind {
6678        "point" => {
6679            let position: Vec<f64> = spec
6680                .get("position")
6681                .ok_or_else(|| PyValueError::new_err("point source needs `position` [cm]"))?
6682                .extract::<Vec<f64>>(py)
6683                .map_err(|_| PyValueError::new_err("`position` must be a list of numbers"))?;
6684            if position.len() != 3 {
6685                return Err(PyValueError::new_err(
6686                    "`position` must have exactly three entries",
6687                ));
6688            }
6689            ps::SourceModel::Point(ps::PointSource {
6690                x_cm: position[0],
6691                y_cm: position[1],
6692                z_cm: position[2],
6693            })
6694        }
6695        "ring" => ps::SourceModel::Ring(ps::RingSource {
6696            radius_cm: num("radius")?,
6697            height_cm: num("height")?,
6698        }),
6699        other => {
6700            return Err(PyValueError::new_err(format!(
6701                "unknown source kind `{other}` (supported: point, ring, parametric)"
6702            )))
6703        }
6704    };
6705    let mut config = ps::PlasmaSourceConfig {
6706        model,
6707        reaction,
6708        ion_temperature_kev: num("ion_temperature_kev")?,
6709        weight: 1.0,
6710    };
6711    if let Some(weight) = spec.get("weight") {
6712        let weight = weight
6713            .extract::<f64>(py)
6714            .map_err(|_| PyValueError::new_err("`weight` must be a number"))?;
6715        config = config.with_weight(weight);
6716    }
6717    config
6718        .validate()
6719        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6720    Ok(config)
6721}
6722
6723/// Parse the parametric-plasma keys into a [`ParametricPlasmaConfig`].
6724///
6725/// Keys: `major_radius`, `minor_radius`, `elongation`, `triangularity`,
6726/// `shafranov_factor` (cm except the dimensionless shape factors), `mode`
6727/// (`"L"`/`"H"`/`"A"`), `pedestal_radius` (cm), the
6728/// `ion_density_{centre,peaking_factor,pedestal,separatrix}` parameters
6729/// (m⁻³ and dimensionless), and the
6730/// `ion_temperature_{centre,peaking_factor,beta,pedestal,separatrix}`
6731/// parameters (keV and dimensionless). Profiles are caller inputs. Fuel
6732/// mixtures (`fuel` dict) and toroidal sectors (`start_angle`/
6733/// `rotation_angle`) are the documented loud boundary — Eriksson-weighted
6734/// reactant distributions are not implemented.
6735fn parse_plasma_parametric_spec(
6736    spec: &BTreeMap<String, Py<PyAny>>,
6737    py: Python<'_>,
6738) -> PyResult<nucleide_plasma_source::ParametricPlasmaConfig> {
6739    use nucleide_plasma_source as ps;
6740    let num = |key: &str| -> PyResult<f64> {
6741        spec.get(key)
6742            .ok_or_else(|| PyValueError::new_err(format!("parametric spec missing `{key}`")))?
6743            .extract::<f64>(py)
6744            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6745    };
6746    for key in ["fuel", "start_angle", "rotation_angle"] {
6747        if spec.contains_key(key) {
6748            return Err(PyValueError::new_err(format!(
6749                "plasma-source: not yet supported: `{key}` (fuel mixtures are \
6750                 Eriksson-weighted reactant distributions; sectors need a \
6751                 toroidal-angle distribution — both outside the parametric model)"
6752            )));
6753        }
6754    }
6755    let mode = ps::ProfileMode::parse(&get_str(
6756        spec,
6757        py,
6758        "mode",
6759        "parametric spec missing `mode`",
6760    )?)
6761    .map_err(|e| PyValueError::new_err(e.to_string()))?;
6762    let fuel = parse_plasma_reaction(&get_str(
6763        spec,
6764        py,
6765        "reaction",
6766        "parametric spec missing `reaction`",
6767    )?)?;
6768    let mut config = ps::ParametricPlasmaConfig {
6769        geometry: ps::MillerGeometry {
6770            major_radius_cm: num("major_radius")?,
6771            minor_radius_cm: num("minor_radius")?,
6772            elongation: num("elongation")?,
6773            triangularity: num("triangularity")?,
6774            shafranov_factor_cm: num("shafranov_factor")?,
6775        },
6776        mode,
6777        ion_density: ps::DensityProfile {
6778            centre_m3: num("ion_density_centre")?,
6779            peaking_factor: num("ion_density_peaking_factor")?,
6780            pedestal_m3: num("ion_density_pedestal")?,
6781            separatrix_m3: num("ion_density_separatrix")?,
6782        },
6783        ion_temperature: ps::TemperatureProfile {
6784            centre_kev: num("ion_temperature_centre")?,
6785            peaking_factor: num("ion_temperature_peaking_factor")?,
6786            beta: num("ion_temperature_beta")?,
6787            pedestal_kev: num("ion_temperature_pedestal")?,
6788            separatrix_kev: num("ion_temperature_separatrix")?,
6789        },
6790        pedestal_radius_cm: num("pedestal_radius")?,
6791        fuel,
6792        weight: 1.0,
6793    };
6794    if let Some(weight) = spec.get("weight") {
6795        let weight = weight
6796            .extract::<f64>(py)
6797            .map_err(|_| PyValueError::new_err("`weight` must be a number"))?;
6798        config.weight = weight;
6799    }
6800    config
6801        .validate()
6802        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6803    Ok(config)
6804}
6805
6806/// Parse a source-spec dict: `kind` selects ring/point (`"point"`,
6807/// `"ring"`) or the parametric plasma (`"parametric"`).
6808fn parse_plasma_source_spec(
6809    spec: &BTreeMap<String, Py<PyAny>>,
6810    py: Python<'_>,
6811) -> PyResult<PyPlasmaSource> {
6812    let kind: String = spec
6813        .get("kind")
6814        .ok_or_else(|| PyValueError::new_err("source spec needs a `kind`"))?
6815        .extract::<String>(py)
6816        .map_err(|_| PyValueError::new_err("`kind` must be a string"))?;
6817    match kind.as_str() {
6818        "point" | "ring" => Ok(PyPlasmaSource::Basic(parse_plasma_basic_spec(
6819            spec, py, &kind,
6820        )?)),
6821        "parametric" => Ok(PyPlasmaSource::Parametric(parse_plasma_parametric_spec(
6822            spec, py,
6823        )?)),
6824        other => Err(PyValueError::new_err(format!(
6825            "unknown source kind `{other}` (supported: point, ring, parametric)"
6826        ))),
6827    }
6828}
6829
6830/// Drift report rows as a list of dicts (`quantity`, `accounted`,
6831/// `rel_drift`, `reparsed`, `note`).
6832fn plasma_drift_rows(
6833    py: Python<'_>,
6834    report: &nucleide_plasma_source::DriftReport,
6835) -> PyResult<Vec<Py<pyo3::types::PyDict>>> {
6836    use pyo3::types::PyDict;
6837    let mut rows = Vec::with_capacity(report.rows.len());
6838    for row in &report.rows {
6839        let d = PyDict::new(py);
6840        d.set_item("quantity", &row.quantity)?;
6841        d.set_item("accounted", row.accounted)?;
6842        d.set_item("rel_drift", row.rel_drift)?;
6843        d.set_item("reparsed", row.reparsed)?;
6844        d.set_item("note", &row.note)?;
6845        rows.push(d.unbind());
6846    }
6847    Ok(rows)
6848}
6849
6850/// Sample `n` source particles into per-field float64 NumPy arrays.
6851///
6852/// Thin wrapper over the ring/point `SourceSampler` and the parametric
6853/// `ParametricSampler` (seeded, deterministic per platform): `spec` is the
6854/// source-spec dict (see [`parse_plasma_source_spec`]), `seed` pins the
6855/// stream. Returns `x`, `y`, `z` \[cm\], direction cosines `u`, `v`, `w`
6856/// (unit vectors), `energy` \[MeV\], and `weight`. MCPL projection stays
6857/// caller-side: write the arrays with `nucleide.mcpl` / `nucleide.mcnp` if
6858/// a file is wanted.
6859#[pyfunction]
6860#[pyo3(signature = (spec, n, seed))]
6861fn plasma_source_particles(
6862    py: Python<'_>,
6863    spec: BTreeMap<String, Py<PyAny>>,
6864    n: usize,
6865    seed: u64,
6866) -> PyResult<Py<PyAny>> {
6867    use nucleide_plasma_source as ps;
6868    let particles = match parse_plasma_source_spec(&spec, py)? {
6869        PyPlasmaSource::Basic(config) => ps::SourceSampler::new(config, seed)
6870            .map_err(|e| PyValueError::new_err(e.to_string()))?
6871            .sample_n(n),
6872        PyPlasmaSource::Parametric(config) => ps::ParametricSampler::new(config, seed)
6873            .map_err(|e| PyValueError::new_err(e.to_string()))?
6874            .sample_n(n),
6875    };
6876    let mut x = Vec::with_capacity(n);
6877    let mut y = Vec::with_capacity(n);
6878    let mut z = Vec::with_capacity(n);
6879    let mut u = Vec::with_capacity(n);
6880    let mut v = Vec::with_capacity(n);
6881    let mut w = Vec::with_capacity(n);
6882    let mut energy = Vec::with_capacity(n);
6883    let mut weight = Vec::with_capacity(n);
6884    for p in &particles {
6885        x.push(p.position_cm[0]);
6886        y.push(p.position_cm[1]);
6887        z.push(p.position_cm[2]);
6888        u.push(p.direction[0]);
6889        v.push(p.direction[1]);
6890        w.push(p.direction[2]);
6891        energy.push(p.energy_mev);
6892        weight.push(p.weight);
6893    }
6894    use pyo3::types::PyDict;
6895    let out = PyDict::new(py);
6896    out.set_item("x", x.into_pyarray(py))?;
6897    out.set_item("y", y.into_pyarray(py))?;
6898    out.set_item("z", z.into_pyarray(py))?;
6899    out.set_item("u", u.into_pyarray(py))?;
6900    out.set_item("v", v.into_pyarray(py))?;
6901    out.set_item("w", w.into_pyarray(py))?;
6902    out.set_item("energy", energy.into_pyarray(py))?;
6903    out.set_item("weight", weight.into_pyarray(py))?;
6904    Ok(out.into_any().unbind())
6905}
6906
6907/// Emit MCNP `SDEF` and Serpent `src` source cards plus drift reports.
6908///
6909/// Thin wrapper over `nucleide_plasma_source::{emit_sdef, emit_serpent}`
6910/// (ring/point) and `{emit_sdef_parametric, emit_serpent_parametric}`.
6911/// `spec` is the source-spec dict (optional `mcnp_version`, 5 or 6, default
6912/// 5); `bins` sets the tabulation bin count. Returns `sdef` and `serpent`,
6913/// each `{"card": str, "drift": [row dicts]}`, plus `spectrum` moments
6914/// (`nominal_mev`, `mean_mev`, `sigma_mev`, `mono` — for a parametric
6915/// source these are the magnetic-axis moments). The SDEF card round-trips
6916/// through `nucleide.mcnp.parse_sdef` byte-identically; Serpent drift rows
6917/// are analytic by design (no Serpent source reader in the workspace).
6918#[pyfunction]
6919#[pyo3(signature = (spec, bins=21))]
6920fn plasma_source_emit_cards(
6921    py: Python<'_>,
6922    spec: BTreeMap<String, Py<PyAny>>,
6923    bins: usize,
6924) -> PyResult<Py<PyAny>> {
6925    use nucleide_plasma_source as ps;
6926    let source = parse_plasma_source_spec(&spec, py)?;
6927    let version = match spec.get("mcnp_version") {
6928        Some(v) => v
6929            .extract::<u32>(py)
6930            .map_err(|_| PyValueError::new_err("`mcnp_version` must be an integer (5 or 6)"))?,
6931        None => 5,
6932    };
6933    let emit = |card: ps::EmittedCard| -> PyResult<Py<pyo3::types::PyDict>> {
6934        use pyo3::types::PyDict;
6935        let d = PyDict::new(py);
6936        d.set_item("card", card.text)?;
6937        d.set_item("drift", plasma_drift_rows(py, &card.drift)?)?;
6938        Ok(d.unbind())
6939    };
6940    use pyo3::types::PyDict;
6941    let out = PyDict::new(py);
6942    let (nominal, mean, sigma, mono) = match &source {
6943        PyPlasmaSource::Basic(config) => {
6944            let sdef = ps::emit_sdef(config, version, bins)
6945                .map_err(|e| PyValueError::new_err(e.to_string()))?;
6946            let serpent =
6947                ps::emit_serpent(config, bins).map_err(|e| PyValueError::new_err(e.to_string()))?;
6948            let spectrum = config
6949                .spectrum()
6950                .map_err(|e| PyValueError::new_err(e.to_string()))?;
6951            let sigma = match spectrum {
6952                ps::SpectrumSpec::Gaussian { sigma_mev, .. } => sigma_mev,
6953                ps::SpectrumSpec::Mono { .. } => 0.0,
6954            };
6955            out.set_item("sdef", emit(sdef)?)?;
6956            out.set_item("serpent", emit(serpent)?)?;
6957            (
6958                config.reaction.nominal_energy_mev(),
6959                spectrum.mean_mev(),
6960                sigma,
6961                spectrum.is_mono(),
6962            )
6963        }
6964        PyPlasmaSource::Parametric(config) => {
6965            let sdef = ps::emit_sdef_parametric(config, version, bins)
6966                .map_err(|e| PyValueError::new_err(e.to_string()))?;
6967            let serpent = ps::emit_serpent_parametric(config, bins)
6968                .map_err(|e| PyValueError::new_err(e.to_string()))?;
6969            // Magnetic-axis spectrum summary (the profile peak).
6970            let (mean, sigma) = config
6971                .fuel
6972                .moments_mev(config.temperature_kev(0.0))
6973                .map_err(|e| PyValueError::new_err(e.to_string()))?;
6974            out.set_item("sdef", emit(sdef)?)?;
6975            out.set_item("serpent", emit(serpent)?)?;
6976            (config.fuel.nominal_energy_mev(), mean, sigma, sigma == 0.0)
6977        }
6978    };
6979    let spec_out = PyDict::new(py);
6980    spec_out.set_item("nominal_mev", nominal)?;
6981    spec_out.set_item("mean_mev", mean)?;
6982    spec_out.set_item("sigma_mev", sigma)?;
6983    spec_out.set_item("mono", mono)?;
6984    out.set_item("spectrum", spec_out)?;
6985    Ok(out.into_any().unbind())
6986}
6987
6988/// Closed-form spectrum moments of a fusion reaction at an ion temperature.
6989///
6990/// Returns `nominal_mev` (the `T_i = 0` line), `mean_mev`, and `sigma_mev`
6991/// (0 when the line is monoenergetic). `reaction` is `"dt"` or `"dd"`;
6992/// `ion_temperature_kev` is in keV. Moments follow Brysk (1973) as fitted by
6993/// Ballabio et al. (1998).
6994#[pyfunction]
6995fn plasma_source_spectrum_moments(
6996    py: Python<'_>,
6997    reaction: &str,
6998    ion_temperature_kev: f64,
6999) -> PyResult<Py<PyAny>> {
7000    use nucleide_plasma_source::FusionReaction as R;
7001    let reaction = parse_plasma_reaction(reaction)?;
7002    let (mean, sigma) = reaction
7003        .moments_mev(ion_temperature_kev)
7004        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7005    use pyo3::types::PyDict;
7006    let out = PyDict::new(py);
7007    out.set_item(
7008        "reaction",
7009        match reaction {
7010            R::Dt => "dt",
7011            R::Dd => "dd",
7012        },
7013    )?;
7014    out.set_item("label", reaction.label())?;
7015    out.set_item("nominal_mev", reaction.nominal_energy_mev())?;
7016    out.set_item("mean_mev", mean)?;
7017    out.set_item("sigma_mev", sigma)?;
7018    Ok(out.into_any().unbind())
7019}
7020
7021/// Thermonuclear reactivity ⟨σv⟩ \[m³/s\] of a fusion reaction at an ion
7022/// temperature \[keV\] (Bosch & Hale, Nucl. Fusion 32 (1992) 611, in the
7023/// Atzeni–Meyer-ter-Vehn parametrization). Zero at `T_i = 0`.
7024#[pyfunction]
7025fn plasma_source_reactivity(reaction: &str, ion_temperature_kev: f64) -> PyResult<f64> {
7026    parse_plasma_reaction(reaction)?
7027        .reactivity_m3_per_s(ion_temperature_kev)
7028        .map_err(|e| PyValueError::new_err(e.to_string()))
7029}
7030
7031// ---------------------------------------------------------------------------
7032// Damage and gas-production metrics (thin glue over `nucleide-damage`)
7033// ---------------------------------------------------------------------------
7034
7035/// Parse a nuclide key (int nucid or str name) into the core [`NuclideId`].
7036fn parse_damage_nuclide(key: &Bound<'_, PyAny>) -> PyResult<NuclideId> {
7037    if let Ok(nucid) = key.extract::<u32>() {
7038        return NuclideId::try_from_nucid(nucid).map_err(wrap_nucid_err);
7039    }
7040    if let Ok(name) = key.extract::<&str>() {
7041        return NuclideId::from_name(name).map_err(wrap_nucid_err);
7042    }
7043    Err(PyTypeError::new_err("expected int nucid or str name"))
7044}
7045
7046/// NRT-dpa: fold caller dpa cross sections (barns) over the group flux.
7047///
7048/// `flux` is the per-group integrated flux (n/cm²/s), `bounds` the G+1 MeV
7049/// group boundaries, `seconds` the exposure time. Piecewise-constant per
7050/// group; zero-flux groups contribute exactly 0.
7051#[pyfunction]
7052#[pyo3(signature = (flux, response, bounds, seconds))]
7053fn damage_nrt_dpa(
7054    flux: Vec<f64>,
7055    response: Vec<f64>,
7056    bounds: Vec<f64>,
7057    seconds: f64,
7058) -> PyResult<f64> {
7059    nucleide_damage::nrt_dpa(&flux, &response, &bounds, seconds)
7060        .map_err(|e| PyValueError::new_err(e.to_string()))
7061}
7062
7063/// arc-dpa fold: same as `damage_nrt_dpa` with arc-corrected cross sections.
7064#[pyfunction]
7065#[pyo3(signature = (flux, response, bounds, seconds))]
7066fn damage_arc_dpa(
7067    flux: Vec<f64>,
7068    response: Vec<f64>,
7069    bounds: Vec<f64>,
7070    seconds: f64,
7071) -> PyResult<f64> {
7072    nucleide_damage::arc_dpa(&flux, &response, &bounds, seconds)
7073        .map_err(|e| PyValueError::new_err(e.to_string()))
7074}
7075
7076/// Gas production in atomic parts per million (He or H, whichever gas the
7077/// caller's `response` counts), by the same fold with the appm normalization.
7078#[pyfunction]
7079#[pyo3(signature = (flux, response, bounds, seconds))]
7080fn damage_gas_appm(
7081    flux: Vec<f64>,
7082    response: Vec<f64>,
7083    bounds: Vec<f64>,
7084    seconds: f64,
7085) -> PyResult<f64> {
7086    nucleide_damage::gas_appm(&flux, &response, &bounds, seconds)
7087        .map_err(|e| PyValueError::new_err(e.to_string()))
7088}
7089
7090/// He/dpa ratio (appm per dpa) from one fold of the He production and
7091/// damage cross sections over the same flux. Zero dpa is a loud error,
7092/// never `inf`.
7093#[pyfunction]
7094#[pyo3(signature = (flux, he_response, damage_response, bounds, seconds))]
7095fn damage_he_dpa_ratio(
7096    flux: Vec<f64>,
7097    he_response: Vec<f64>,
7098    damage_response: Vec<f64>,
7099    bounds: Vec<f64>,
7100    seconds: f64,
7101) -> PyResult<f64> {
7102    nucleide_damage::he_dpa_ratio(&flux, &he_response, &damage_response, &bounds, seconds)
7103        .map_err(|e| PyValueError::new_err(e.to_string()))
7104}
7105
7106/// Lindhard partition fraction `P(ε) = 1/(1 + k_L·g(ε))` for a recoil of
7107/// energy `t_ev` (eV) stopped in a lattice; nuclides accept an int nucid or
7108/// a name string.
7109#[pyfunction]
7110#[pyo3(signature = (t_ev, recoil, lattice))]
7111fn damage_lindhard_partition(
7112    t_ev: f64,
7113    recoil: &Bound<'_, PyAny>,
7114    lattice: &Bound<'_, PyAny>,
7115) -> PyResult<f64> {
7116    let recoil = parse_damage_nuclide(recoil)?;
7117    let lattice = parse_damage_nuclide(lattice)?;
7118    nucleide_damage::lindhard_partition(t_ev, &recoil, &lattice)
7119        .map_err(|e| PyValueError::new_err(e.to_string()))
7120}
7121
7122/// Lindhard damage energy `T_dam = T·P(ε)` in eV.
7123#[pyfunction]
7124#[pyo3(signature = (t_ev, recoil, lattice))]
7125fn damage_damage_energy(
7126    t_ev: f64,
7127    recoil: &Bound<'_, PyAny>,
7128    lattice: &Bound<'_, PyAny>,
7129) -> PyResult<f64> {
7130    let recoil = parse_damage_nuclide(recoil)?;
7131    let lattice = parse_damage_nuclide(lattice)?;
7132    nucleide_damage::damage_energy(t_ev, &recoil, &lattice)
7133        .map_err(|e| PyValueError::new_err(e.to_string()))
7134}
7135
7136/// NRT displacement function `N_d(T)` for a self-recoil `target` (int nucid
7137/// or name) with average threshold displacement energy `ed_ev` (eV).
7138#[pyfunction]
7139#[pyo3(signature = (t_ev, ed_ev, target))]
7140fn damage_nrt_displacements(t_ev: f64, ed_ev: f64, target: &Bound<'_, PyAny>) -> PyResult<f64> {
7141    let target = parse_damage_nuclide(target)?;
7142    nucleide_damage::nrt_displacements(t_ev, ed_ev, &target)
7143        .map_err(|e| PyValueError::new_err(e.to_string()))
7144}
7145
7146/// arc-dpa efficiency `ξ(T_d)` (Nordlund 2018 Eq. (7)) at damage energy
7147/// `t_dam_ev` for threshold `ed_ev` and constants `b_arc`/`c_arc`.
7148#[pyfunction]
7149#[pyo3(signature = (t_dam_ev, ed_ev, b_arc, c_arc))]
7150fn damage_arc_efficiency(t_dam_ev: f64, ed_ev: f64, b_arc: f64, c_arc: f64) -> PyResult<f64> {
7151    let params = nucleide_damage::ArcParams::new(b_arc, c_arc)
7152        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7153    nucleide_damage::arc_efficiency(t_dam_ev, ed_ev, &params)
7154        .map_err(|e| PyValueError::new_err(e.to_string()))
7155}
7156
7157/// arc-dpa displacement function for a self-recoil `target` with threshold
7158/// `ed_ev` (eV) and arc constants `b_arc`/`c_arc`.
7159#[pyfunction]
7160#[pyo3(signature = (t_ev, ed_ev, target, b_arc, c_arc))]
7161fn damage_arc_displacements(
7162    t_ev: f64,
7163    ed_ev: f64,
7164    target: &Bound<'_, PyAny>,
7165    b_arc: f64,
7166    c_arc: f64,
7167) -> PyResult<f64> {
7168    let target = parse_damage_nuclide(target)?;
7169    let params = nucleide_damage::ArcParams::new(b_arc, c_arc)
7170        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7171    nucleide_damage::arc_displacements(t_ev, ed_ev, &target, &params)
7172        .map_err(|e| PyValueError::new_err(e.to_string()))
7173}
7174
7175/// UQ sweep over the fold: seeded MVN draws over the caller's relative
7176/// `[flux, response]` block, refolded per draw, gated at `k` standard
7177/// errors against the exact expectation and the first-order propagated
7178/// standard deviation (the landed U1–U4/U7 pattern). `metric` is one of
7179/// `"nrt_dpa"`, `"arc_dpa"`, `"gas_appm"` (`"he_dpa_ratio"` is a loud
7180/// named-open). Returns the sample/analytic moments plus the gate verdict.
7181#[pyfunction]
7182#[pyo3(signature = (metric, flux, response, bounds, seconds, mean, cov, n, seed, k))]
7183#[allow(clippy::too_many_arguments)] // mirrors the core fold_uq signature plus the PyO3 py handle
7184fn damage_fold_uq(
7185    py: Python<'_>,
7186    metric: &str,
7187    flux: Vec<f64>,
7188    response: Vec<f64>,
7189    bounds: Vec<f64>,
7190    seconds: f64,
7191    mean: Vec<f64>,
7192    cov: Vec<Vec<f64>>,
7193    n: usize,
7194    seed: u64,
7195    k: f64,
7196) -> PyResult<Py<PyAny>> {
7197    use nucleide_damage::FoldMetric as M;
7198    let metric = match metric
7199        .to_ascii_lowercase()
7200        .replace(['-', ' '], "_")
7201        .as_str()
7202    {
7203        "nrt_dpa" => M::NrtDpa,
7204        "arc_dpa" => M::ArcDpa,
7205        "gas_appm" => M::GasAppm,
7206        "he_dpa_ratio" => M::HeDpaRatio,
7207        other => {
7208            return Err(PyValueError::new_err(format!(
7209                "unknown fold metric `{other}` (supported: nrt_dpa, arc_dpa, gas_appm)"
7210            )))
7211        }
7212    };
7213    let s = nucleide_damage::fold_uq(
7214        metric, &flux, &response, &bounds, seconds, &mean, &cov, n, seed, k,
7215    )
7216    .map_err(|e| PyValueError::new_err(e.to_string()))?;
7217    use pyo3::types::PyDict;
7218    let out = PyDict::new(py);
7219    out.set_item("metric", s.metric.name())?;
7220    out.set_item("nominal", s.nominal)?;
7221    out.set_item("mean", s.mean)?;
7222    out.set_item("std", s.std)?;
7223    out.set_item("expected", s.expected)?;
7224    out.set_item("analytic_std", s.analytic_std)?;
7225    out.set_item("k", s.k)?;
7226    out.set_item("n", s.n)?;
7227    out.set_item("seed", s.seed)?;
7228    out.set_item("passed", s.passed)?;
7229    Ok(out.into_any().unbind())
7230}
7231
7232// ---------------------------------------------------------------------------
7233// Tritium transport (thin glue over `nucleide-tritium`; solver stays in core)
7234// ---------------------------------------------------------------------------
7235
7236/// Parse a boundary-spec dict into the core [`Boundary`].
7237///
7238/// `kind` selects the surface law (`"dirichlet"`, `"sieverts"`, `"henry"`,
7239/// `"recombination"`, `"zero_flux"`). Keys per kind: dirichlet (`value`
7240/// [mol/m³]); sieverts/henry (`solubility`, `pressure` [Pa]);
7241/// recombination (`rate`); zero_flux (no keys). Recombination ends close
7242/// per solve — steady (G5) and transient (G6) alike.
7243fn parse_tritium_boundary(
7244    spec: &BTreeMap<String, Py<PyAny>>,
7245    py: Python<'_>,
7246) -> PyResult<nucleide_tritium::Boundary> {
7247    use nucleide_tritium::Boundary as B;
7248    let kind: String = spec
7249        .get("kind")
7250        .ok_or_else(|| PyValueError::new_err("boundary spec needs a `kind`"))?
7251        .extract::<String>(py)
7252        .map_err(|_| PyValueError::new_err("`kind` must be a string"))?;
7253    let num = |key: &str| -> PyResult<f64> {
7254        spec.get(key)
7255            .ok_or_else(|| PyValueError::new_err(format!("boundary spec missing `{key}`")))?
7256            .extract::<f64>(py)
7257            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7258    };
7259    let b = match kind.as_str() {
7260        "dirichlet" => B::dirichlet(num("value")?),
7261        "sieverts" => B::sieverts(num("solubility")?, num("pressure")?),
7262        "henry" => B::henry(num("solubility")?, num("pressure")?),
7263        "recombination" => B::recombination(num("rate")?),
7264        "zero_flux" => Ok(B::ZeroFlux),
7265        other => {
7266            return Err(PyValueError::new_err(format!(
7267                "unknown boundary kind `{other}` (supported: dirichlet, sieverts, henry, recombination, zero_flux)"
7268            )))
7269        }
7270    };
7271    b.map_err(|e| PyValueError::new_err(e.to_string()))
7272}
7273
7274/// Parse a trap-spec dict into the core [`TrapSpec`].
7275///
7276/// Keys: `k0` [m³/mol/s], `p0` [1/s], `site_density` [mol/m³] (required);
7277/// `e_k`/`e_p` [J/mol] (optional, default 0 = constant rates).
7278fn parse_tritium_trap(
7279    spec: &BTreeMap<String, Py<PyAny>>,
7280    py: Python<'_>,
7281) -> PyResult<nucleide_tritium::TrapSpec> {
7282    let num = |key: &str| -> PyResult<f64> {
7283        spec.get(key)
7284            .ok_or_else(|| PyValueError::new_err(format!("trap spec missing `{key}`")))?
7285            .extract::<f64>(py)
7286            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7287    };
7288    let opt = |key: &str| -> PyResult<f64> {
7289        match spec.get(key) {
7290            None => Ok(0.0),
7291            Some(v) => v
7292                .extract::<f64>(py)
7293                .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number"))),
7294        }
7295    };
7296    nucleide_tritium::TrapSpec::new(
7297        num("k0")?,
7298        opt("e_k")?,
7299        num("p0")?,
7300        opt("e_p")?,
7301        num("site_density")?,
7302    )
7303    .map_err(|e| PyValueError::new_err(e.to_string()))
7304}
7305
7306#[allow(clippy::too_many_arguments)]
7307fn tritium_params(
7308    py: Python<'_>,
7309    length: f64,
7310    cells: usize,
7311    d0: f64,
7312    e_d: f64,
7313    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7314    temperature: Vec<f64>,
7315    source: Option<Vec<f64>>,
7316) -> PyResult<nucleide_tritium::TransportParams> {
7317    let parsed: Vec<nucleide_tritium::TrapSpec> = traps
7318        .iter()
7319        .map(|s| parse_tritium_trap(s, py))
7320        .collect::<PyResult<_>>()?;
7321    nucleide_tritium::TransportParams::new(
7322        length,
7323        cells,
7324        d0,
7325        e_d,
7326        parsed,
7327        temperature,
7328        source.unwrap_or_default(),
7329    )
7330    .map_err(|e| PyValueError::new_err(e.to_string()))
7331}
7332
7333/// Trap-free-style steady state of (T1–T2).
7334///
7335/// Thin wrapper over `nucleide_tritium::steady_state`: `traps` holds one
7336/// spec dict per species (see `parse_tritium_trap`), `temperature` is one
7337/// value (uniform) or one per cell, `source` is None (zero), one value, or
7338/// one per cell, and `left`/`right` are boundary-spec dicts (see
7339/// `parse_tritium_boundary`). Returns a dict with `centres`, `mobile`,
7340/// `trapped` (`[cell][trap]`), `flux_left`, `flux_right`,
7341/// `inventory_mobile`, and `inventory_trapped`.
7342#[pyfunction]
7343#[pyo3(signature = (length, cells, d0, e_d, traps, temperature, source, left, right))]
7344#[allow(clippy::too_many_arguments)]
7345fn tritium_steady(
7346    py: Python<'_>,
7347    length: f64,
7348    cells: usize,
7349    d0: f64,
7350    e_d: f64,
7351    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7352    temperature: Vec<f64>,
7353    source: Option<Vec<f64>>,
7354    left: BTreeMap<String, Py<PyAny>>,
7355    right: BTreeMap<String, Py<PyAny>>,
7356) -> PyResult<Py<PyAny>> {
7357    let params = tritium_params(py, length, cells, d0, e_d, traps, temperature, source)?;
7358    let left = parse_tritium_boundary(&left, py)?;
7359    let right = parse_tritium_boundary(&right, py)?;
7360    let s = nucleide_tritium::steady_state(&params, &left, &right)
7361        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7362    use pyo3::types::PyDict;
7363    let out = PyDict::new(py);
7364    out.set_item("centres", &s.centres).ok();
7365    out.set_item("mobile", &s.mobile).ok();
7366    out.set_item("trapped", &s.trapped).ok();
7367    out.set_item("flux_left", s.flux_left).ok();
7368    out.set_item("flux_right", s.flux_right).ok();
7369    out.set_item("inventory_mobile", s.inventory_mobile).ok();
7370    out.set_item("inventory_trapped", s.inventory_trapped).ok();
7371    Ok(out.into_any().unbind())
7372}
7373
7374/// Solve the (T1–T2) transient over the output grid `t`.
7375///
7376/// Thin wrapper over `nucleide_tritium::solve` with the same slab/trap/BC
7377/// arguments as `tritium_steady` plus the output times `t` [s], the
7378/// optional initial profiles (`mobile0` per cell, `trapped0` as
7379/// `[cell][trap]`; both default to zero), and the solver options
7380/// (`method` is `"crank_nicolson"` (default) or `"backward_euler"`).
7381/// Returns a dict with `times`, `mobile` (`[time][cell]`), `trapped`
7382/// (`[time][cell][trap]`), `flux_left`, and `flux_right`.
7383#[pyfunction]
7384#[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))]
7385#[allow(clippy::too_many_arguments)]
7386fn tritium_transient(
7387    py: Python<'_>,
7388    length: f64,
7389    cells: usize,
7390    d0: f64,
7391    e_d: f64,
7392    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7393    temperature: Vec<f64>,
7394    source: Option<Vec<f64>>,
7395    left: BTreeMap<String, Py<PyAny>>,
7396    right: BTreeMap<String, Py<PyAny>>,
7397    t: Vec<f64>,
7398    mobile0: Option<Vec<f64>>,
7399    trapped0: Option<Vec<Vec<f64>>>,
7400    method: &str,
7401    rtol: f64,
7402    atol: f64,
7403    dt_min: f64,
7404    dt_max: Option<f64>,
7405    max_steps: usize,
7406) -> PyResult<Py<PyAny>> {
7407    use nucleide_tritium::{SolverOptions, Theta};
7408    let params = tritium_params(py, length, cells, d0, e_d, traps, temperature, source)?;
7409    let left = parse_tritium_boundary(&left, py)?;
7410    let right = parse_tritium_boundary(&right, py)?;
7411    let grid =
7412        nucleide_tritium::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
7413    let ntraps = params.traps.len();
7414    let mobile = mobile0.unwrap_or_else(|| vec![0.0; params.cells]);
7415    let trapped = trapped0.unwrap_or_else(|| vec![vec![0.0; ntraps]; params.cells]);
7416    let initial = nucleide_tritium::InitialState::new(&params, mobile, trapped)
7417        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7418    let theta = if method.eq_ignore_ascii_case("crank_nicolson") {
7419        Theta::CrankNicolson
7420    } else if method.eq_ignore_ascii_case("backward_euler") {
7421        Theta::BackwardEuler
7422    } else {
7423        return Err(PyValueError::new_err(format!(
7424            "unknown tritium method `{method}` (supported: crank_nicolson, backward_euler)"
7425        )));
7426    };
7427    let opts = SolverOptions {
7428        theta,
7429        rtol,
7430        atol,
7431        dt_min,
7432        dt_max: dt_max.unwrap_or(f64::INFINITY),
7433        max_steps,
7434    };
7435    let sol = nucleide_tritium::solve(&params, &left, &right, &grid, &initial, &opts)
7436        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7437    use pyo3::types::PyDict;
7438    let out = PyDict::new(py);
7439    out.set_item("times", &sol.times).ok();
7440    out.set_item("mobile", &sol.mobile).ok();
7441    out.set_item("trapped", &sol.trapped).ok();
7442    out.set_item("flux_left", &sol.flux_left).ok();
7443    out.set_item("flux_right", &sol.flux_right).ok();
7444    Ok(out.into_any().unbind())
7445}
7446
7447/// Permeation time lag `t_lag = L²/6D` [s] (G2-lag).
7448#[pyfunction]
7449fn tritium_time_lag(length: f64, diffusivity: f64) -> PyResult<f64> {
7450    nucleide_tritium::time_lag(length, diffusivity)
7451        .map_err(|e| PyValueError::new_err(e.to_string()))
7452}
7453
7454/// Normalized outlet flux `J(L,t)/J_ss` at each time (G2 series).
7455#[pyfunction]
7456fn tritium_breakthrough(diffusivity: f64, length: f64, times: Vec<f64>) -> PyResult<Vec<f64>> {
7457    times
7458        .iter()
7459        .map(|t| {
7460            nucleide_tritium::breakthrough_ratio(diffusivity, length, *t)
7461                .map_err(|e| PyValueError::new_err(e.to_string()))
7462        })
7463        .collect()
7464}
7465
7466/// Oriani effective diffusivity `D_eff = D/(1 + K N)` [m²/s] (G3a).
7467#[pyfunction]
7468fn tritium_oriani(diffusivity: f64, equilibrium_constant: f64, site_density: f64) -> PyResult<f64> {
7469    nucleide_tritium::effective_diffusivity(diffusivity, equilibrium_constant, site_density)
7470        .map_err(|e| PyValueError::new_err(e.to_string()))
7471}
7472
7473/// Langmuir equilibrium load `c_t = N K c/(1 + K c)` [mol/m³] (T2-eq).
7474#[pyfunction]
7475fn tritium_langmuir(site_density: f64, equilibrium_constant: f64, c_mobile: f64) -> PyResult<f64> {
7476    nucleide_tritium::equilibrium_trapped(site_density, equilibrium_constant, c_mobile)
7477        .map_err(|e| PyValueError::new_err(e.to_string()))
7478}
7479
7480/// Irreversible-trap fill `c_t(t) = N(1 − e^{−kct})` [mol/m³] at each time (G3c).
7481#[pyfunction]
7482fn tritium_irreversible_fill(
7483    rate_k: f64,
7484    c_mobile: f64,
7485    site_density: f64,
7486    times: Vec<f64>,
7487) -> PyResult<Vec<f64>> {
7488    times
7489        .iter()
7490        .map(|t| {
7491            nucleide_tritium::irreversible_fill(rate_k, c_mobile, site_density, *t)
7492                .map_err(|e| PyValueError::new_err(e.to_string()))
7493        })
7494        .collect()
7495}
7496
7497/// Sieverts surface concentration `c = K_S sqrt(p)` [mol/m³] (G4).
7498#[pyfunction]
7499fn tritium_sieverts(solubility: f64, pressure: f64) -> PyResult<f64> {
7500    nucleide_tritium::sieverts_concentration(solubility, pressure)
7501        .map_err(|e| PyValueError::new_err(e.to_string()))
7502}
7503
7504/// Recombination rate `K_r = kr0 * exp(-e_r / R / temp)` [m⁴/mol/s] (G5).
7505#[pyfunction]
7506fn tritium_recombination_rate(kr0: f64, e_r: f64, temp: f64) -> PyResult<f64> {
7507    nucleide_tritium::recombination_rate_arrhenius(kr0, e_r, temp)
7508        .map_err(|e| PyValueError::new_err(e.to_string()))
7509}
7510
7511/// Parse a layer-spec dict into the core [`nucleide_tritium::LayerSpec`].
7512///
7513/// Keys: `thickness` [m], `cells`, `D` [m²/s], `solubility` `K_S`
7514/// [mol/m³/Pa¹ᐟ²] (required); `E_D` [J/mol] (optional, default 0 = constant
7515/// diffusivity); `traps` (optional list of trap-spec dicts, default none),
7516/// `temperature` (optional, default [500]), `source` (optional). Internal
7517/// interfaces between consecutive layers are Sieverts conditions in v1
7518/// (`c/K_S` continuous, flux continuous); Henry/recombination interface
7519/// laws are loud core errors, not expressible here.
7520fn parse_tritium_layer(
7521    spec: &BTreeMap<String, Py<PyAny>>,
7522    py: Python<'_>,
7523) -> PyResult<nucleide_tritium::LayerSpec> {
7524    let num = |key: &str| -> PyResult<f64> {
7525        spec.get(key)
7526            .ok_or_else(|| PyValueError::new_err(format!("layer spec missing `{key}`")))?
7527            .extract::<f64>(py)
7528            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7529    };
7530    let opt = |key: &str| -> PyResult<f64> {
7531        match spec.get(key) {
7532            None => Ok(0.0),
7533            Some(v) => v
7534                .extract::<f64>(py)
7535                .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number"))),
7536        }
7537    };
7538    let traps = match spec.get("traps") {
7539        None => Vec::new(),
7540        Some(v) => v
7541            .extract::<Vec<BTreeMap<String, Py<PyAny>>>>(py)
7542            .map_err(|_| PyValueError::new_err("`traps` must be a list of dicts"))?
7543            .iter()
7544            .map(|s| parse_tritium_trap(s, py))
7545            .collect::<PyResult<_>>()?,
7546    };
7547    let temperature: Vec<f64> = match spec.get("temperature") {
7548        None => vec![500.0],
7549        Some(v) => v
7550            .extract::<Vec<f64>>(py)
7551            .map_err(|_| PyValueError::new_err("`temperature` must be a list of numbers"))?,
7552    };
7553    let source: Vec<f64> = match spec.get("source") {
7554        None => Vec::new(),
7555        Some(v) => v
7556            .extract::<Vec<f64>>(py)
7557            .map_err(|_| PyValueError::new_err("`source` must be a list of numbers"))?,
7558    };
7559    let cells: usize = spec
7560        .get("cells")
7561        .ok_or_else(|| PyValueError::new_err("layer spec missing `cells`"))?
7562        .extract::<usize>(py)
7563        .map_err(|_| PyValueError::new_err("`cells` must be an integer"))?;
7564    nucleide_tritium::LayerSpec::new(
7565        num("thickness")?,
7566        cells,
7567        num("D")?,
7568        opt("E_D")?,
7569        num("solubility")?,
7570        traps,
7571        temperature,
7572        source,
7573    )
7574    .map_err(|e| PyValueError::new_err(e.to_string()))
7575}
7576
7577fn tritium_layer_stack(
7578    py: Python<'_>,
7579    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7580) -> PyResult<nucleide_tritium::LayerStack> {
7581    let parsed: Vec<nucleide_tritium::LayerSpec> = layers
7582        .iter()
7583        .map(|s| parse_tritium_layer(s, py))
7584        .collect::<PyResult<_>>()?;
7585    let interfaces = vec![nucleide_tritium::Interface::Sieverts; parsed.len().saturating_sub(1)];
7586    nucleide_tritium::LayerStack::new(parsed, interfaces)
7587        .map_err(|e| PyValueError::new_err(e.to_string()))
7588}
7589
7590/// Trap-free-style steady state of a multi-layer series stack (G7).
7591///
7592/// Thin wrapper over `nucleide_tritium::steady_layers`: `layers` holds one
7593/// spec dict per layer (see `parse_tritium_layer`; internal interfaces are
7594/// Sieverts conditions in v1) and `left`/`right` are boundary-spec dicts
7595/// (see `parse_tritium_boundary`). A one-layer stack reproduces
7596/// `tritium_steady` exactly. Returns a dict with `centres`, `mobile`,
7597/// `trapped` (`[cell][trap]`), `flux_left`, `flux_right`,
7598/// `inventory_mobile`, and `inventory_trapped`.
7599#[pyfunction]
7600fn tritium_layers_steady(
7601    py: Python<'_>,
7602    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7603    left: BTreeMap<String, Py<PyAny>>,
7604    right: BTreeMap<String, Py<PyAny>>,
7605) -> PyResult<Py<PyAny>> {
7606    let stack = tritium_layer_stack(py, layers)?;
7607    let left = parse_tritium_boundary(&left, py)?;
7608    let right = parse_tritium_boundary(&right, py)?;
7609    let s = nucleide_tritium::steady_layers(&stack, &left, &right)
7610        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7611    use pyo3::types::PyDict;
7612    let out = PyDict::new(py);
7613    out.set_item("centres", &s.centres).ok();
7614    out.set_item("mobile", &s.mobile).ok();
7615    out.set_item("trapped", &s.trapped).ok();
7616    out.set_item("flux_left", s.flux_left).ok();
7617    out.set_item("flux_right", s.flux_right).ok();
7618    out.set_item("inventory_mobile", s.inventory_mobile).ok();
7619    out.set_item("inventory_trapped", s.inventory_trapped).ok();
7620    Ok(out.into_any().unbind())
7621}
7622
7623/// Solve the multi-layer (T1–T2) transient over the output grid `t` (G8).
7624///
7625/// Thin wrapper over `nucleide_tritium::solve_layers` with the same layer
7626/// stack and boundary arguments as `tritium_layers_steady` plus the output
7627/// times `t` [s], the optional initial profiles (`mobile0` per cell,
7628/// `trapped0` as `[cell][trap]` matching each layer's trap count; both
7629/// default to zero), and the solver options (`method` is
7630/// `"crank_nicolson"` (default) or `"backward_euler"`). Returns a dict with
7631/// `times`, `mobile` (`[time][cell]`), `trapped` (`[time][cell][trap]`),
7632/// `flux_left`, and `flux_right`.
7633#[pyfunction]
7634#[pyo3(signature = (layers, 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))]
7635#[allow(clippy::too_many_arguments)]
7636fn tritium_layers_transient(
7637    py: Python<'_>,
7638    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7639    left: BTreeMap<String, Py<PyAny>>,
7640    right: BTreeMap<String, Py<PyAny>>,
7641    t: Vec<f64>,
7642    mobile0: Option<Vec<f64>>,
7643    trapped0: Option<Vec<Vec<f64>>>,
7644    method: &str,
7645    rtol: f64,
7646    atol: f64,
7647    dt_min: f64,
7648    dt_max: Option<f64>,
7649    max_steps: usize,
7650) -> PyResult<Py<PyAny>> {
7651    use nucleide_tritium::{SolverOptions, Theta};
7652    let stack = tritium_layer_stack(py, layers)?;
7653    let left = parse_tritium_boundary(&left, py)?;
7654    let right = parse_tritium_boundary(&right, py)?;
7655    let grid =
7656        nucleide_tritium::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
7657    let mobile = mobile0.unwrap_or_else(|| vec![0.0; stack.total_cells()]);
7658    let trapped = trapped0.unwrap_or_else(|| stack.zero_state().trapped);
7659    let initial = nucleide_tritium::InitialState { mobile, trapped };
7660    let theta = if method.eq_ignore_ascii_case("crank_nicolson") {
7661        Theta::CrankNicolson
7662    } else if method.eq_ignore_ascii_case("backward_euler") {
7663        Theta::BackwardEuler
7664    } else {
7665        return Err(PyValueError::new_err(format!(
7666            "unknown tritium method `{method}` (supported: crank_nicolson, backward_euler)"
7667        )));
7668    };
7669    let opts = SolverOptions {
7670        theta,
7671        rtol,
7672        atol,
7673        dt_min,
7674        dt_max: dt_max.unwrap_or(f64::INFINITY),
7675        max_steps,
7676    };
7677    let sol = nucleide_tritium::solve_layers(&stack, &left, &right, &grid, &initial, &opts)
7678        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7679    use pyo3::types::PyDict;
7680    let out = PyDict::new(py);
7681    out.set_item("times", &sol.times).ok();
7682    out.set_item("mobile", &sol.mobile).ok();
7683    out.set_item("trapped", &sol.trapped).ok();
7684    out.set_item("flux_left", &sol.flux_left).ok();
7685    out.set_item("flux_right", &sol.flux_right).ok();
7686    Ok(out.into_any().unbind())
7687}
7688
7689// ---------------------------------------------------------------------------
7690// Spectroscopy (thin glue over `nucleide-spectroscopy`; algorithms stay in core)
7691// ---------------------------------------------------------------------------
7692
7693/// Rectangular smoothing (E1): `m` must be odd and at least 3.
7694#[pyfunction]
7695fn spectroscopy_rect_smooth(counts: Vec<f64>, m: i64) -> PyResult<Vec<f64>> {
7696    let w = usize::try_from(m).map_err(|_| {
7697        PyValueError::new_err(format!("spectroscopy: smoothing width {m} is less than 3"))
7698    })?;
7699    nucleide_spectroscopy::rect_smooth(&counts, w).map_err(|e| PyValueError::new_err(e.to_string()))
7700}
7701
7702/// Five-point smoothing (E2); the first/last two channels are copied.
7703#[pyfunction]
7704fn spectroscopy_five_point_smooth(counts: Vec<f64>) -> PyResult<Vec<f64>> {
7705    nucleide_spectroscopy::five_point_smooth(&counts)
7706        .map_err(|e| PyValueError::new_err(e.to_string()))
7707}
7708
7709/// Background under a peak (E3, `m == 1` only).
7710#[pyfunction]
7711fn spectroscopy_calc_bg(
7712    counts: Vec<f64>,
7713    channels: Vec<f64>,
7714    c1: i64,
7715    c2: i64,
7716    m: i64,
7717) -> PyResult<f64> {
7718    nucleide_spectroscopy::calc_bg(&counts, &channels, c1, c2, m)
7719        .map_err(|e| PyValueError::new_err(e.to_string()))
7720}
7721
7722/// Gross counts between two channels, half-open (E4, excludes `c2`).
7723#[pyfunction]
7724fn spectroscopy_gross_count(
7725    counts: Vec<f64>,
7726    channels: Vec<f64>,
7727    c1: i64,
7728    c2: i64,
7729) -> PyResult<f64> {
7730    nucleide_spectroscopy::gross_count(&counts, &channels, c1, c2)
7731        .map_err(|e| PyValueError::new_err(e.to_string()))
7732}
7733
7734/// Net counts: gross minus background (E5).
7735#[pyfunction]
7736fn spectroscopy_net_counts(
7737    counts: Vec<f64>,
7738    channels: Vec<f64>,
7739    c1: i64,
7740    c2: i64,
7741    m: i64,
7742) -> PyResult<f64> {
7743    nucleide_spectroscopy::net_counts(&counts, &channels, c1, c2, m)
7744        .map_err(|e| PyValueError::new_err(e.to_string()))
7745}
7746
7747/// Energy per channel from the `[a0, a1, a2]` fit (E6).
7748#[pyfunction]
7749fn spectroscopy_energy_bins(channels: Vec<f64>, calib_e_fit: Vec<f64>) -> PyResult<Vec<f64>> {
7750    nucleide_spectroscopy::energy_bins(&channels, &calib_e_fit)
7751        .map_err(|e| PyValueError::new_err(e.to_string()))
7752}
7753
7754/// Detector efficiency at `energy_mev` (E7, energy in MeV, `eff_fit` 1 or 2).
7755#[pyfunction]
7756fn spectroscopy_detector_efficiency(
7757    energy_mev: f64,
7758    eff_coeff: Vec<f64>,
7759    eff_fit: i64,
7760) -> PyResult<f64> {
7761    nucleide_spectroscopy::detector_efficiency(energy_mev, &eff_coeff, eff_fit)
7762        .map_err(|e| PyValueError::new_err(e.to_string()))
7763}
7764
7765/// Efficiency-coefficient fit (E7-fit): log-space weighted least squares over
7766/// caller `(energies_mev, effs, weights)` points with `order + 1` coefficients
7767/// under the `eff_fit` 1 (`(ln E)^j`) or 2 (`(1/E)^j`) basis. Thin wrapper
7768/// over `nucleide-spectroscopy` `fit_efficiency` (which solves through the
7769/// workspace `nucleide-linalg` least-squares kernel).
7770#[pyfunction]
7771#[pyo3(signature = (energies, effs, weights, order, eff_fit=1))]
7772fn spectroscopy_fit_efficiency(
7773    energies: Vec<f64>,
7774    effs: Vec<f64>,
7775    weights: Vec<f64>,
7776    order: usize,
7777    eff_fit: i64,
7778) -> PyResult<Vec<f64>> {
7779    nucleide_spectroscopy::fit_efficiency(&energies, &effs, &weights, order, eff_fit)
7780        .map_err(|e| PyValueError::new_err(e.to_string()))
7781}
7782
7783/// Fetch one caller-supplied atomic constant or raise a `ValueError`.
7784fn atomic_key(atomic: &BTreeMap<String, f64>, key: &str) -> PyResult<f64> {
7785    atomic
7786        .get(key)
7787        .copied()
7788        .ok_or_else(|| PyValueError::new_err(format!("atomic constants missing `{key}`")))
7789}
7790
7791/// X-ray lines (E8) as `[(energy_kev, intensity); Ka1, Ka2, Kb, L]`.
7792///
7793/// `atomic` carries the nine caller-supplied constants (`k_shell_fluor`,
7794/// `l_shell_fluor`, `prob`, `kb_to_ka`, `ka2_to_ka1`, `ka1_en_kev`,
7795/// `ka2_en_kev`, `kb_en_kev`, `l_en_kev`). `None` (or NaN, the upstream
7796/// sentinel) marks a conversion absent. Upstream exposes no combined
7797/// function for this routine — only a material method — so this explicit
7798/// entry point is the documented Nucleide surface.
7799#[pyfunction]
7800#[pyo3(signature = (atomic, k_conv=None, l_conv=None))]
7801fn spectroscopy_xray_lines(
7802    atomic: BTreeMap<String, f64>,
7803    k_conv: Option<f64>,
7804    l_conv: Option<f64>,
7805) -> PyResult<Vec<(f64, f64)>> {
7806    let data = nucleide_spectroscopy::AtomicData {
7807        k_shell_fluor: atomic_key(&atomic, "k_shell_fluor")?,
7808        l_shell_fluor: atomic_key(&atomic, "l_shell_fluor")?,
7809        prob: atomic_key(&atomic, "prob")?,
7810        kb_to_ka: atomic_key(&atomic, "kb_to_ka")?,
7811        ka2_to_ka1: atomic_key(&atomic, "ka2_to_ka1")?,
7812        ka1_en_kev: atomic_key(&atomic, "ka1_en_kev")?,
7813        ka2_en_kev: atomic_key(&atomic, "ka2_en_kev")?,
7814        kb_en_kev: atomic_key(&atomic, "kb_en_kev")?,
7815        l_en_kev: atomic_key(&atomic, "l_en_kev")?,
7816    };
7817    // NaN plays the upstream "conversion absent" sentinel role.
7818    let present = |v: Option<f64>| v.filter(|x| !x.is_nan());
7819    Ok(
7820        nucleide_spectroscopy::xray_lines(&data, present(k_conv), present(l_conv))
7821            .iter()
7822            .map(|l| (l.energy_kev, l.intensity))
7823            .collect(),
7824    )
7825}
7826
7827/// SDEF decay-source card (E9) as `(normalized_bins, card_text)`.
7828///
7829/// `lines` carries caller-supplied `(energy_mev, intensity)` pairs; every
7830/// energy and intensity is an input (no evaluated data is vendored).
7831/// Intensities are merged at duplicate energies, sorted ascending, and
7832/// normalized to probabilities summing to 1.0. The card keeps the upstream
7833/// monoenergetic point-source field order (`POS`, optional `VEC ... DIR=1`,
7834/// `ERG`, `WGT`, `PAR`); one surviving line renders inline `ERG=<E>`, while
7835/// several render the discrete-distribution form `ERG=D1` with paired
7836/// `SI1 L` / `SP1 D` cards. That distribution syntax is parser-verified
7837/// surface only — MCNP sampling semantics are the caller's responsibility.
7838/// `particle` parses through the `nucleide-nuclei` dialect (`"Neutron"`,
7839/// `"Photon"`, `"Electron"`, ...); `version` is 5 or 6 and selects the
7840/// `PAR=` designator.
7841#[pyfunction]
7842#[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))]
7843#[allow(clippy::too_many_arguments)]
7844fn spectroscopy_sdef_decay_source(
7845    lines: Vec<(f64, f64)>,
7846    x: f64,
7847    y: f64,
7848    z: f64,
7849    u: f64,
7850    v: f64,
7851    w: f64,
7852    weight: f64,
7853    particle: &str,
7854    version: u32,
7855) -> PyResult<(Vec<(f64, f64)>, String)> {
7856    let particle = particle
7857        .parse::<nucleide_nuclei::particles::ParticleId>()
7858        .map_err(|e| PyValueError::new_err(format!("spectroscopy: particle {e}")))?;
7859    let source = nucleide_spectroscopy::PointSource {
7860        x,
7861        y,
7862        z,
7863        u,
7864        v,
7865        w,
7866        weight,
7867        particle,
7868    };
7869    nucleide_spectroscopy::sdef_card(&lines, &source, version)
7870        .map_err(|e| PyValueError::new_err(e.to_string()))
7871}
7872
7873/// Render a parsed spectrum as a Python dict.
7874fn spectrum_to_py(
7875    py: Python<'_>,
7876    spec: &nucleide_spectroscopy::GammaSpectrum,
7877) -> PyResult<Py<PyAny>> {
7878    use pyo3::types::PyDict;
7879    let d = PyDict::new(py);
7880    let s = &spec.spectrum;
7881    d.set_item("spec_name", &s.spec_name)?;
7882    d.set_item("start_chan_num", s.start_chan_num)?;
7883    d.set_item("num_channels", s.num_channels)?;
7884    d.set_item("channels", &s.channels)?;
7885    d.set_item("counts", &s.counts)?;
7886    d.set_item("ebin", &s.ebin)?;
7887    d.set_item("real_time", spec.real_time)?;
7888    d.set_item("live_time", spec.live_time)?;
7889    d.set_item("dead_time", spec.dead_time())?;
7890    d.set_item("det_id", &spec.det_id)?;
7891    d.set_item("det_descp", &spec.det_descp)?;
7892    d.set_item("start_date", &spec.start_date)?;
7893    d.set_item("start_time", &spec.start_time)?;
7894    d.set_item("calib_e_fit", &spec.calib_e_fit)?;
7895    d.set_item("calib_fwhm_fit", &spec.calib_fwhm_fit)?;
7896    d.set_item("file_name", &spec.file_name)?;
7897    Ok(d.into_any().unbind())
7898}
7899
7900/// Parse dollar-format `.spe` text (first line must be `$SPEC_ID:`).
7901#[pyfunction]
7902fn spectroscopy_parse_dollar_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
7903    let spec = nucleide_spectroscopy::parse_dollar_spe(text, "")
7904        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7905    spectrum_to_py(py, &spec)
7906}
7907
7908/// Parse plain-format `.spe` text (rejects the `$SPEC_ID:` magic).
7909#[pyfunction]
7910fn spectroscopy_parse_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
7911    let spec = nucleide_spectroscopy::parse_plain_spe(text, "")
7912        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7913    spectrum_to_py(py, &spec)
7914}
7915
7916/// Read a dollar-format `.spe` file.
7917#[pyfunction]
7918fn spectroscopy_read_dollar_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
7919    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
7920    let spec = nucleide_spectroscopy::parse_dollar_spe(&text, path)
7921        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7922    spectrum_to_py(py, &spec)
7923}
7924
7925/// Read a plain-format `.spe` file.
7926#[pyfunction]
7927fn spectroscopy_read_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
7928    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
7929    let spec = nucleide_spectroscopy::parse_plain_spe(&text, path)
7930        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7931    spectrum_to_py(py, &spec)
7932}
7933
7934/// Parse decay-lines interchange TSV text into `(energy_MeV, intensity)`
7935/// pairs (`#` comments and blank lines skipped; E9 normalization stays in
7936/// `sdef_decay_source`).
7937#[pyfunction]
7938fn spectroscopy_parse_lines_tsv(text: &str) -> PyResult<Vec<(f64, f64)>> {
7939    nucleide_spectroscopy::parse_lines_tsv(text).map_err(|e| PyValueError::new_err(e.to_string()))
7940}
7941
7942/// Read a decay-lines interchange TSV file (same grammar as
7943/// `spectroscopy_parse_lines_tsv`).
7944#[pyfunction]
7945fn spectroscopy_read_decay_lines(path: &str) -> PyResult<Vec<(f64, f64)>> {
7946    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
7947    nucleide_spectroscopy::parse_lines_tsv(&text).map_err(|e| PyValueError::new_err(e.to_string()))
7948}
7949
7950// ---------------------------------------------------------------------------
7951// UQ-lite sampling kernel (thin glue over `linalg`; decay-only sub-scope)
7952// ---------------------------------------------------------------------------
7953
7954fn uq_sample_err(e: nucleide_linalg::sample::SampleError) -> PyErr {
7955    PyValueError::new_err(e.to_string())
7956}
7957
7958fn uq_decay_err(e: nucleide_linalg::decay::DecayError) -> PyErr {
7959    PyValueError::new_err(e.to_string())
7960}
7961
7962/// Seeded multivariate-normal draws over a caller-supplied covariance block.
7963///
7964/// Returns a dict with `samples` (list of `n` row lists), `method`
7965/// (`"cholesky"` or `"eigen_clip"`), and the unclipped `min_eigen` /
7966/// `max_eigen` (`None` on the Cholesky path). Same
7967/// `(mean, cov, n, seed)` inputs always yield identical samples. Thin
7968/// wrapper over `nucleide-linalg` `sample`.
7969#[pyfunction]
7970fn uq_sample_mvn(
7971    py: Python<'_>,
7972    mean: Vec<f64>,
7973    cov: Vec<Vec<f64>>,
7974    n: usize,
7975    seed: u64,
7976) -> PyResult<Py<PyAny>> {
7977    use pyo3::types::PyDict;
7978    let set = nucleide_linalg::sample::sample_mvn(&mean, &cov, n, seed).map_err(uq_sample_err)?;
7979    let d = PyDict::new(py);
7980    d.set_item("samples", set.samples)?;
7981    d.set_item("method", set.method.name())?;
7982    match &set.method {
7983        nucleide_linalg::sample::FactorMethod::Cholesky => {
7984            d.set_item("min_eigen", py.None())?;
7985            d.set_item("max_eigen", py.None())?;
7986        }
7987        nucleide_linalg::sample::FactorMethod::EigenClip {
7988            min_eigen,
7989            max_eigen,
7990        } => {
7991            d.set_item("min_eigen", *min_eigen)?;
7992            d.set_item("max_eigen", *max_eigen)?;
7993        }
7994    }
7995    Ok(d.into_any().unbind())
7996}
7997
7998/// Sample mean over draws (one entry per dimension).
7999#[pyfunction]
8000fn uq_sample_mean(samples: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
8001    nucleide_linalg::sample::sample_mean(&samples).map_err(uq_sample_err)
8002}
8003
8004/// Unbiased sample covariance (`1/(n-1)`, matching SANDY `Samples.get_cov`).
8005#[pyfunction]
8006fn uq_sample_cov(samples: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
8007    nucleide_linalg::sample::sample_cov(&samples).map_err(uq_sample_err)
8008}
8009
8010/// Sample mean/covariance convergence diagnostics à la SANDY.
8011///
8012/// Returns a dict with `mean_err_max`, `cov_err_fro`, the echoed
8013/// `mean_tol`/`cov_tol`, and `passed`. Thin wrapper over
8014/// `nucleide-linalg` `sample`.
8015#[pyfunction]
8016fn uq_check_convergence(
8017    py: Python<'_>,
8018    mean: Vec<f64>,
8019    cov: Vec<Vec<f64>>,
8020    samples: Vec<Vec<f64>>,
8021    mean_tol: f64,
8022    cov_tol: f64,
8023) -> PyResult<Py<PyAny>> {
8024    use pyo3::types::PyDict;
8025    let rep = nucleide_linalg::sample::check_convergence(&mean, &cov, &samples, mean_tol, cov_tol)
8026        .map_err(uq_sample_err)?;
8027    let d = PyDict::new(py);
8028    d.set_item("mean_err_max", rep.mean_err_max)?;
8029    d.set_item("cov_err_fro", rep.cov_err_fro)?;
8030    d.set_item("mean_tol", rep.mean_tol)?;
8031    d.set_item("cov_tol", rep.cov_tol)?;
8032    d.set_item("passed", rep.passed)?;
8033    Ok(d.into_any().unbind())
8034}
8035
8036/// Perturb one parent's kept branch fractions with relative deltas,
8037/// preserving the incoming `1 - BR(SF)` deficit by renormalisation.
8038#[pyfunction]
8039fn uq_perturb_branches(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
8040    nucleide_linalg::decay::perturb_branches(&base, &rel).map_err(uq_decay_err)
8041}
8042
8043/// Perturb decay energies under `convention`
8044/// (`"relative"`/`"absolute"`/`"lognormal"`); negative results clamp to zero
8045/// (a no-op for lognormal draws, which stay positive for non-negative bases).
8046#[pyfunction]
8047fn uq_perturb_energies(base: Vec<f64>, delta: Vec<f64>, convention: &str) -> PyResult<Vec<f64>> {
8048    let conv = nucleide_linalg::sample::PerturbConvention::parse(convention)
8049        .map_err(PyValueError::new_err)?;
8050    nucleide_linalg::decay::perturb_energies(&base, &delta, conv).map_err(uq_decay_err)
8051}
8052
8053/// Seeded log-normal draws: `x ~ N(mean_log, cov_log)` via the shared MVN
8054/// factor path and RNG, then `y = exp(x)` elementwise.
8055///
8056/// Returns the same dict shape as [`uq_sample_mvn`]; `mean_log`/`cov_log`
8057/// are log-space MVN parameters (never the moments of `y`). Thin wrapper
8058/// over `nucleide-linalg` `sample`.
8059#[pyfunction]
8060fn uq_sample_lognormal(
8061    py: Python<'_>,
8062    mean_log: Vec<f64>,
8063    cov: Vec<Vec<f64>>,
8064    n: usize,
8065    seed: u64,
8066) -> PyResult<Py<PyAny>> {
8067    use pyo3::types::PyDict;
8068    let set = nucleide_linalg::sample::sample_lognormal(&mean_log, &cov, n, seed)
8069        .map_err(uq_sample_err)?;
8070    let d = PyDict::new(py);
8071    d.set_item("samples", set.samples)?;
8072    d.set_item("method", set.method.name())?;
8073    match &set.method {
8074        nucleide_linalg::sample::FactorMethod::Cholesky => {
8075            d.set_item("min_eigen", py.None())?;
8076            d.set_item("max_eigen", py.None())?;
8077        }
8078        nucleide_linalg::sample::FactorMethod::EigenClip {
8079            min_eigen,
8080            max_eigen,
8081        } => {
8082            d.set_item("min_eigen", *min_eigen)?;
8083            d.set_item("max_eigen", *max_eigen)?;
8084        }
8085    }
8086    Ok(d.into_any().unbind())
8087}
8088
8089/// Seeded Latin-hypercube draws over a caller-supplied covariance block.
8090///
8091/// Stratified `U(0,1)` draws (one jittered draw per stratum per dimension)
8092/// through the hand-rolled inverse-normal CDF, then the shared MVN factor
8093/// path and `x = μ + Bz` application. Returns the same dict shape as
8094/// [`uq_sample_mvn`]. Thin wrapper over `nucleide-linalg` `sample`.
8095#[pyfunction]
8096fn uq_sample_lhs(
8097    py: Python<'_>,
8098    mean: Vec<f64>,
8099    cov: Vec<Vec<f64>>,
8100    n: usize,
8101    seed: u64,
8102) -> PyResult<Py<PyAny>> {
8103    use pyo3::types::PyDict;
8104    let set = nucleide_linalg::sample::sample_lhs(&mean, &cov, n, seed).map_err(uq_sample_err)?;
8105    let d = PyDict::new(py);
8106    d.set_item("samples", set.samples)?;
8107    d.set_item("method", set.method.name())?;
8108    match &set.method {
8109        nucleide_linalg::sample::FactorMethod::Cholesky => {
8110            d.set_item("min_eigen", py.None())?;
8111            d.set_item("max_eigen", py.None())?;
8112        }
8113        nucleide_linalg::sample::FactorMethod::EigenClip {
8114            min_eigen,
8115            max_eigen,
8116        } => {
8117            d.set_item("min_eigen", *min_eigen)?;
8118            d.set_item("max_eigen", *max_eigen)?;
8119        }
8120    }
8121    Ok(d.into_any().unbind())
8122}
8123
8124/// Closed-form log-normal mean `E[y_i] = exp(mu_i + C_ii/2)` over the
8125/// log-space `(mean_log, cov)` parameters.
8126#[pyfunction]
8127fn uq_lognormal_mean(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
8128    nucleide_linalg::sample::lognormal_mean(&mean_log, &cov).map_err(uq_sample_err)
8129}
8130
8131/// Closed-form log-normal covariance
8132/// `Cov(y_i, y_j) = exp(mu_i + mu_j + (C_ii + C_jj)/2) (exp(C_ij) - 1)`.
8133#[pyfunction]
8134fn uq_lognormal_cov(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
8135    nucleide_linalg::sample::lognormal_cov(&mean_log, &cov).map_err(uq_sample_err)
8136}
8137
8138/// Passthrough copy of a perturbation vector (finiteness-checked).
8139#[pyfunction]
8140fn uq_passthrough(delta: Vec<f64>) -> PyResult<Vec<f64>> {
8141    nucleide_linalg::decay::passthrough(&delta).map_err(uq_decay_err)
8142}
8143
8144/// Fission-yield perturbation over a caller-supplied block (same deficit
8145/// discipline as `perturb_branches`, preserving the incoming sum).
8146#[pyfunction]
8147fn uq_perturb_fission_yields(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
8148    nucleide_linalg::decay::perturb_fission_yields(&base, &rel).map_err(uq_decay_err)
8149}
8150
8151// ---------------------------------------------------------------------------
8152// Thin reader facade bundle over existing Rust (no new math/data)
8153// ---------------------------------------------------------------------------
8154
8155fn parse_projectile(flag: &str) -> PyResult<nucleide_nuclei::rxname::Projectile> {
8156    flag.parse::<nucleide_nuclei::rxname::Projectile>()
8157        .map_err(|e| PyValueError::new_err(e.to_string()))
8158}
8159
8160fn resolve_rx_id(spec: &Bound<'_, PyAny>) -> PyResult<u32> {
8161    if let Ok(id) = spec.extract::<u32>() {
8162        return Ok(id);
8163    }
8164    if let Ok(s) = spec.extract::<&str>() {
8165        return nucleide_nuclei::rxname::name_to_id(s)
8166            .map_err(|e| PyValueError::new_err(e.to_string()));
8167    }
8168    Err(PyTypeError::new_err(
8169        "expected reaction id (int) or name (str)",
8170    ))
8171}
8172
8173/// Short `"(z,a)"`-style label for a reaction id ("" when unknown).
8174#[pyfunction]
8175fn rxname_label(id: u32) -> &'static str {
8176    nucleide_nuclei::rxname::label(id)
8177}
8178
8179/// Long documentation string for a reaction id ("" when unknown).
8180#[pyfunction]
8181fn rxname_doc(id: u32) -> &'static str {
8182    nucleide_nuclei::rxname::doc(id)
8183}
8184
8185/// Registry row for a reaction id as {id, name, mt, label, doc}, or None.
8186#[pyfunction]
8187fn rxname_reaction(py: Python<'_>, id: u32) -> PyResult<Option<Py<PyAny>>> {
8188    use pyo3::types::PyDict;
8189    Ok(nucleide_nuclei::rxname::reaction(id).map(|r| {
8190        let d = PyDict::new(py);
8191        d.set_item("id", r.id).ok();
8192        d.set_item("name", r.name).ok();
8193        d.set_item("mt", r.mt).ok();
8194        d.set_item("label", r.label).ok();
8195        d.set_item("doc", r.doc).ok();
8196        d.into_any().unbind()
8197    }))
8198}
8199
8200/// Reaction channel connecting `from_nucid` to `to_nucid` under `projectile`.
8201#[pyfunction]
8202#[pyo3(signature = (from_nucid, to_nucid, projectile="n"))]
8203fn rxname_id_from_nucdelta(from_nucid: u32, to_nucid: u32, projectile: &str) -> PyResult<u32> {
8204    let p = parse_projectile(projectile)?;
8205    nucleide_nuclei::rxname::id_from_nucdelta(from_nucid, to_nucid, p)
8206        .map_err(|e| PyValueError::new_err(e.to_string()))
8207}
8208
8209/// Daughter nuclide (GNDS name) when `parent` undergoes `rx` under `projectile`.
8210#[pyfunction]
8211#[pyo3(signature = (parent, rx, projectile="n"))]
8212fn rxname_child(parent: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
8213    let p = parse_projectile(projectile)?;
8214    let rx = resolve_rx_id(rx)?;
8215    let parent_id = NuclideId::from_name(parent)
8216        .map_err(|e| PyValueError::new_err(format!("`{parent}`: {e}")))?;
8217    nucleide_nuclei::rxname::child(parent_id, rx, p)
8218        .map(|id| id.to_name())
8219        .map_err(|e| PyValueError::new_err(e.to_string()))
8220}
8221
8222/// Parent nuclide (GNDS name) whose `rx` under `projectile` yields `child`.
8223#[pyfunction]
8224#[pyo3(signature = (child, rx, projectile="n"))]
8225fn rxname_parent(child: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
8226    let p = parse_projectile(projectile)?;
8227    let rx = resolve_rx_id(rx)?;
8228    let child_id = NuclideId::from_name(child)
8229        .map_err(|e| PyValueError::new_err(format!("`{child}`: {e}")))?;
8230    nucleide_nuclei::rxname::parent(child_id, rx, p)
8231        .map(|id| id.to_name())
8232        .map_err(|e| PyValueError::new_err(e.to_string()))
8233}
8234
8235/// True when `spec` names a particle or a nuclide (hydrogen or heavy ion).
8236#[pyfunction]
8237fn particle_is_valid(spec: &str) -> bool {
8238    nucleide_nuclei::particles::is_valid(spec)
8239}
8240
8241/// True when `n` is a registered PDC number.
8242#[pyfunction]
8243fn particle_is_valid_pdc(n: i32) -> bool {
8244    nucleide_nuclei::particles::is_valid_pdc(n)
8245}
8246
8247/// True when `spec` is ground-state hydrogen.
8248#[pyfunction]
8249fn particle_is_hydrogen(spec: &str) -> bool {
8250    nucleide_nuclei::particles::is_hydrogen(spec)
8251}
8252
8253/// True when `spec` is a nuclide heavier than ground-state hydrogen.
8254#[pyfunction]
8255fn particle_is_heavy_ion(spec: &str) -> bool {
8256    nucleide_nuclei::particles::is_heavy_ion(spec)
8257}
8258
8259/// Gut-uptake fraction `f1` for ingestion rows, or None (source default EPA).
8260#[pyfunction]
8261#[pyo3(signature = (name, source="EPA"))]
8262fn dose_f1(name: &str, source: &str) -> PyResult<Option<f64>> {
8263    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
8264    let s = parse_dose_source(source)?;
8265    Ok(nucleide_nuclei::data::dose_f1_by_name(name, s))
8266}
8267
8268/// Lung-clearance class for inhalation rows, or None (source default EPA).
8269#[pyfunction]
8270#[pyo3(signature = (name, source="EPA"))]
8271fn dose_lung_model(name: &str, source: &str) -> PyResult<Option<char>> {
8272    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
8273    let s = parse_dose_source(source)?;
8274    Ok(nucleide_nuclei::data::dose_lung_model_by_name(name, s))
8275}
8276
8277/// Canonical element symbol for a bare-symbol comp key, or None.
8278fn bare_element_z(name: &str) -> Option<u32> {
8279    let t = name.trim();
8280    if t.is_empty() {
8281        return None;
8282    }
8283    let mut chars = t.chars();
8284    let first = chars.next()?.to_uppercase().next()?;
8285    let rest: String = chars.collect::<String>().to_lowercase();
8286    let canon = format!("{first}{rest}");
8287    nucleide_nuclei::element_z(&canon)
8288}
8289
8290fn mat_from_comp_elements(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
8291    let mut mat = nucleide_material::Material::new();
8292    for (name, grams) in &comp {
8293        let id = match NuclideId::from_name(name) {
8294            Ok(id) => id,
8295            Err(_) => match bare_element_z(name) {
8296                Some(z) => NuclideId::from_nucid(z * 10_000_000),
8297                None => {
8298                    return Err(PyValueError::new_err(format!(
8299                        "`{name}`: unknown nuclide or element"
8300                    )));
8301                }
8302            },
8303        };
8304        mat.add_nuclide(id, *grams);
8305    }
8306    Ok(mat)
8307}
8308
8309fn mat_to_comp_elements(mat: &nucleide_material::Material) -> BTreeMap<String, f64> {
8310    let mut out = BTreeMap::new();
8311    for (&id, &grams) in &mat.comp {
8312        let key = if id.a() == 0 && id.state() == 0 {
8313            nucleide_nuclei::element_symbol(id.z())
8314                .unwrap_or("X")
8315                .to_string()
8316        } else {
8317            id.to_name()
8318        };
8319        *out.entry(key).or_insert(0.0) += grams;
8320    }
8321    out
8322}
8323
8324/// Mix streams weighted by relative mass amounts (thin wrapper over
8325/// `Material::mix_by_mass`). Bare element symbols map to natural-element
8326/// placeholders; collapsed/elemental keys round-trip as symbols.
8327#[pyfunction]
8328fn mix_by_mass(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
8329    let mats: Vec<nucleide_material::Material> = parts
8330        .iter()
8331        .map(|(comp, _)| mat_from_comp_elements(comp.clone()))
8332        .collect::<PyResult<_>>()?;
8333    let refs: Vec<(&nucleide_material::Material, f64)> =
8334        mats.iter().zip(parts.iter().map(|(_, w)| *w)).collect();
8335    let out = nucleide_material::Material::mix_by_mass(&refs)
8336        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8337    Ok(mat_to_comp_elements(&out))
8338}
8339
8340/// Mix streams weighted by relative volumes, converting through each
8341/// stream's density (thin wrapper over `Material::mix_by_volume`).
8342/// `parts` holds `(comp, volume, density)` triples.
8343#[pyfunction]
8344fn mix_by_volume(parts: Vec<(BTreeMap<String, f64>, f64, f64)>) -> PyResult<BTreeMap<String, f64>> {
8345    let mut mats: Vec<nucleide_material::Material> = Vec::with_capacity(parts.len());
8346    for (comp, _, density) in &parts {
8347        let mut m = mat_from_comp_elements(comp.clone())?;
8348        m.set_density(Some(*density));
8349        mats.push(m);
8350    }
8351    let refs: Vec<(&nucleide_material::Material, f64)> =
8352        mats.iter().zip(parts.iter().map(|(_, v, _)| *v)).collect();
8353    let out = nucleide_material::Material::mix_by_volume(&refs)
8354        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8355    Ok(mat_to_comp_elements(&out))
8356}
8357
8358/// Specific activity of a composition in Bq/g (AME2020 + chain decays).
8359#[pyfunction]
8360fn specific_activity(comp: BTreeMap<String, f64>) -> PyResult<f64> {
8361    let mat = mat_from_comp_elements(comp)?;
8362    let analytics = nucleide_material::Analytics {
8363        masses: &nucleide_material::Ame2020,
8364        decays: &nucleide_material::ChainDecays,
8365    };
8366    mat.specific_activity(&analytics)
8367        .map_err(|e| PyValueError::new_err(e.to_string()))
8368}
8369
8370/// Serialize a `<materials>` document bundling named materials.
8371/// `entries` holds `(name, comp, density)` triples; `cross_sections`
8372/// sets the root attribute when given.
8373#[pyfunction]
8374#[pyo3(signature = (entries, cross_sections=None))]
8375fn materials_doc_to_xml(
8376    entries: Vec<(String, BTreeMap<String, f64>, f64)>,
8377    cross_sections: Option<String>,
8378) -> PyResult<String> {
8379    let mut doc = nucleide_material::MaterialsDoc::new();
8380    if let Some(path) = cross_sections {
8381        doc = doc.cross_sections(path);
8382    }
8383    for (name, comp, density) in entries {
8384        let mut mat = mat_from_comp_elements(comp)?;
8385        mat.set_density(Some(density));
8386        doc = doc.push(name, mat);
8387    }
8388    doc.to_xml()
8389        .map_err(|e| PyValueError::new_err(e.to_string()))
8390}
8391
8392/// Replace natural-element placeholders with isotopic breakdowns (AME2020 +
8393/// natural abundances). Bare element symbols are placeholders; nuclide
8394/// names pass through untouched.
8395#[pyfunction]
8396fn expand_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
8397    let mut mat = mat_from_comp_elements(comp)?;
8398    mat.expand_elements(
8399        &nucleide_material::Ame2020,
8400        &nucleide_material::NaturalAbundances,
8401    )
8402    .map_err(|e| PyValueError::new_err(e.to_string()))?;
8403    Ok(mat_to_comp_elements(&mat))
8404}
8405
8406/// Fold every nuclide into its element placeholder (bare-symbol keys).
8407#[pyfunction]
8408fn collapse_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
8409    let mat = mat_from_comp_elements(comp)?;
8410    Ok(mat_to_comp_elements(&mat.collapse_elements()))
8411}
8412
8413fn parse_fluka_nuc(spec: &str) -> PyResult<nucleide_fluka_io::material::FlukaNuc> {
8414    use nucleide_fluka_io::material::FlukaNuc;
8415    if let Ok(id) = NuclideId::from_name(spec) {
8416        return Ok(FlukaNuc::Nuclide(id));
8417    }
8418    if let Some(z) = bare_element_z(spec) {
8419        return Ok(FlukaNuc::Element(z));
8420    }
8421    if let Ok(z) = spec.trim().parse::<u32>() {
8422        if nucleide_nuclei::element_symbol(z).is_some() {
8423            return Ok(FlukaNuc::Element(z));
8424        }
8425    }
8426    Err(PyValueError::new_err(format!(
8427        "`{spec}`: unknown nuclide or element"
8428    )))
8429}
8430
8431/// Render the MATERIAL record for an elemental nuclide ("", when builtin).
8432#[pyfunction]
8433fn fluka_material_str(fid: u32, nuc: &str, density: f64) -> PyResult<String> {
8434    let parsed = parse_fluka_nuc(nuc)?;
8435    nucleide_fluka_io::material::material_str(fid, parsed, density)
8436        .map_err(|e| PyValueError::new_err(e.to_string()))
8437}
8438
8439/// Render MATERIAL + COMPOUND records for a compound.
8440/// `frac_type` is "mass" (default) or "atom"; `components` holds
8441/// `(nuclide-or-element, fraction)` pairs.
8442#[pyfunction]
8443#[pyo3(signature = (fid, compound_name, density, frac_type="mass", components=None))]
8444fn fluka_compound_str(
8445    fid: u32,
8446    compound_name: &str,
8447    density: f64,
8448    frac_type: &str,
8449    components: Option<Vec<(String, f64)>>,
8450) -> PyResult<String> {
8451    use nucleide_fluka_io::material::{Component, FracType};
8452    let frac = match frac_type.trim().to_ascii_lowercase().as_str() {
8453        "mass" => FracType::Mass,
8454        "atom" => FracType::Atom,
8455        other => {
8456            return Err(PyValueError::new_err(format!(
8457                "frac_type must be mass|atom, got `{other}`"
8458            )));
8459        }
8460    };
8461    let pairs = components.unwrap_or_default();
8462    let comps: Vec<Component> = pairs
8463        .iter()
8464        .map(|(nuc, frac)| parse_fluka_nuc(nuc).map(|n| Component::new(n, *frac)))
8465        .collect::<PyResult<_>>()?;
8466    nucleide_fluka_io::material::compound_str(fid, compound_name, density, frac, &comps)
8467        .map_err(|e| PyValueError::new_err(e.to_string()))
8468}
8469
8470/// Sorted built-in FLUKA material names.
8471#[pyfunction]
8472fn fluka_builtin_set() -> Vec<String> {
8473    let mut out: Vec<String> = nucleide_fluka_io::material::builtin_set()
8474        .into_iter()
8475        .map(str::to_string)
8476        .collect();
8477    out.sort();
8478    out
8479}
8480
8481/// Validate an ALARA deck's cross-references (parse + `validate`).
8482#[pyfunction]
8483fn alara_validate_deck(text: &str) -> PyResult<()> {
8484    let deck = nucleide_alara_io::AlaraDeck::parse(text)
8485        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8486    deck.validate()
8487        .map_err(|e| PyValueError::new_err(e.to_string()))
8488}
8489
8490/// Reject an unknown ALARA block keyword (`line` is 1-based).
8491#[pyfunction]
8492fn alara_check_block(block: &str, line: usize) -> PyResult<()> {
8493    nucleide_alara_io::AlaraDeck::check_block(block, line)
8494        .map_err(|e| PyValueError::new_err(e.to_string()))
8495}
8496
8497/// Sum of an ALARA group-flux spectrum over its groups.
8498#[pyfunction]
8499fn alara_flux_total(name: &str, text: &str) -> PyResult<f64> {
8500    nucleide_alara_io::FluxSpec::parse(name, text)
8501        .map(|f| f.total())
8502        .map_err(|e| PyValueError::new_err(e.to_string()))
8503}
8504
8505/// Number of groups in an ALARA group-flux spectrum.
8506#[pyfunction]
8507fn alara_flux_len(name: &str, text: &str) -> PyResult<usize> {
8508    nucleide_alara_io::FluxSpec::parse(name, text)
8509        .map(|f| f.len())
8510        .map_err(|e| PyValueError::new_err(e.to_string()))
8511}
8512
8513/// Keep only the `total` aggregate rows of an ALARA/FISPACT response frame.
8514#[pyfunction]
8515fn alara_output_totals(
8516    py: Python<'_>,
8517    text: &str,
8518    run_lbl: &str,
8519) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
8520    let owned_text = text.to_owned();
8521    let owned_lbl = run_lbl.to_owned();
8522    let frame = py
8523        .detach(move || {
8524            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
8525                .map(|f| f.totals())
8526        })
8527        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8528    Ok(frame
8529        .rows
8530        .iter()
8531        .map(|r| fispact_row_to_map(py, r))
8532        .collect())
8533}
8534
8535/// Sum of `value` over SpecificActivity rows of a response frame.
8536#[pyfunction]
8537fn alara_output_total_activity(text: &str, run_lbl: &str) -> PyResult<f64> {
8538    nucleide_alara_io::output::ResponseFrame::parse(text, run_lbl)
8539        .map(|f| f.total_activity())
8540        .map_err(|e| PyValueError::new_err(e.to_string()))
8541}
8542
8543/// Sum over every group and strength of a `.photonSrc` listing.
8544#[pyfunction]
8545fn alara_photon_total_strength(text: &str) -> PyResult<f64> {
8546    nucleide_alara_io::PhotonSource::from_str(text)
8547        .map(|p| p.total_strength())
8548        .map_err(|e| PyValueError::new_err(e.to_string()))
8549}
8550
8551/// Total schedule time in seconds over a deck's expanded flat steps.
8552#[pyfunction]
8553#[pyo3(signature = (deck_text, top=None))]
8554fn alara_schedule_total_time(deck_text: &str, top: Option<&str>) -> PyResult<f64> {
8555    let owned = deck_text.to_owned();
8556    let owned_top = top.map(str::to_owned);
8557    let steps =
8558        expand_deck_schedules(&owned, owned_top.as_deref()).map_err(PyValueError::new_err)?;
8559    Ok(nucleide_alara_io::schedule::total_time(&steps))
8560}
8561
8562/// Vendored EU 2013/59/Euratom Annex VII Table A clearance levels.
8563///
8564/// Returns a dict mapping nuclide names (GNDS spelling, e.g. `"H-3"`,
8565/// `"Co-60"`) to activity-concentration clearance levels in Bq/g (numerically
8566/// identical to the directive's kBq/kg). Official legal text transcribed from
8567/// EUR-Lex CELEX:32013L0059 (Annex VII Table A, accessed 2026-09-15),
8568/// reusable with attribution per Decision (EU) 2011/833. Screening default
8569/// only — see `nucleide-alara-io` `clearance` for the unit-basis contract.
8570#[pyfunction]
8571fn alara_clearance_eu_table() -> BTreeMap<String, f64> {
8572    nucleide_alara_io::ClearanceTable::eu_annex_vii()
8573        .iter()
8574        .map(|(nuc, limit)| (nucleide_nuclei::dialects::serpent(nuc), limit))
8575        .collect()
8576}
8577
8578/// Resolve a caller-supplied inventory/limits dict key to a `NuclideId`.
8579fn clearance_key(key: &str) -> PyResult<NuclideId> {
8580    nucleide_nuclei::dialects::normalize_nuclide_name(key)
8581        .map_err(|e| PyValueError::new_err(format!("bad nuclide name `{key}`: {e}")))
8582}
8583
8584/// Build `(NuclideId, f64)` pairs from a `{name: value}` dict.
8585fn clearance_pairs(map: &BTreeMap<String, f64>, what: &str) -> PyResult<Vec<(NuclideId, f64)>> {
8586    map.iter()
8587        .map(|(name, value)| Ok((clearance_key(name)?, *value)))
8588        .collect::<PyResult<_>>()
8589        .map_err(|e| PyValueError::new_err(format!("{what}: {e}")))
8590}
8591
8592/// Build a caller-supplied clearance table from a `{name: limit_Bq_per_g}` dict.
8593fn clearance_table_from(
8594    map: &BTreeMap<String, f64>,
8595) -> PyResult<nucleide_alara_io::ClearanceTable> {
8596    let mut table = nucleide_alara_io::ClearanceTable::new();
8597    for (nuc, limit) in clearance_pairs(map, "limits")? {
8598        table
8599            .insert(nuc, limit)
8600            .map_err(|e| PyValueError::new_err(e.to_string()))?;
8601    }
8602    Ok(table)
8603}
8604
8605/// Clearance index CI = sum_i A_i / CL_i over a parsed inventory.
8606///
8607/// `inventory` maps nuclide names to activities; `limits` maps nuclide names
8608/// to clearance levels (a dict, or None for the vendored EU 2013/59/Euratom
8609/// Annex VII Table A default in Bq/g). Activities and limits must share one
8610/// unit basis (Bq/g against the default table). Every inventory nuclide must
8611/// have a limit entry; negative/non-finite activities raise `ValueError`.
8612#[pyfunction]
8613#[pyo3(signature = (inventory, limits=None))]
8614fn alara_clearance_index(
8615    inventory: BTreeMap<String, f64>,
8616    limits: Option<BTreeMap<String, f64>>,
8617) -> PyResult<f64> {
8618    let table = match limits {
8619        Some(map) => clearance_table_from(&map)?,
8620        None => nucleide_alara_io::ClearanceTable::eu_annex_vii(),
8621    };
8622    let pairs = clearance_pairs(&inventory, "inventory")?;
8623    nucleide_alara_io::clearance_index(&pairs, &table)
8624        .map_err(|e| PyValueError::new_err(e.to_string()))
8625}
8626
8627/// Sum-of-fractions screening over a parsed inventory.
8628///
8629/// Same inputs as `alara_clearance_index`. Returns a dict with `sum`
8630/// (sum_i A_i / CL_i), `class` ("satisfied" when the sum does not exceed 1,
8631/// boundary included; "exceeded" otherwise), `max_fraction`, and
8632/// `max_nuclide` (dominant contributor, or None for an empty inventory).
8633/// RS-G-1.7 §5 rule referenced by
8634/// designation; screening arithmetic, not a compliance decision.
8635#[pyfunction]
8636#[pyo3(signature = (inventory, limits=None))]
8637fn alara_sum_of_fractions(
8638    py: Python<'_>,
8639    inventory: BTreeMap<String, f64>,
8640    limits: Option<BTreeMap<String, f64>>,
8641) -> PyResult<BTreeMap<String, Py<PyAny>>> {
8642    let table = match limits {
8643        Some(map) => clearance_table_from(&map)?,
8644        None => nucleide_alara_io::ClearanceTable::eu_annex_vii(),
8645    };
8646    let pairs = clearance_pairs(&inventory, "inventory")?;
8647    let out = nucleide_alara_io::sum_of_fractions(&pairs, &table)
8648        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8649    let mut d = BTreeMap::new();
8650    d.insert(
8651        "sum".to_string(),
8652        out.sum.into_pyobject(py).unwrap().unbind().into_any(),
8653    );
8654    d.insert(
8655        "class".to_string(),
8656        out.class
8657            .to_string()
8658            .into_pyobject(py)
8659            .unwrap()
8660            .unbind()
8661            .into_any(),
8662    );
8663    d.insert(
8664        "max_fraction".to_string(),
8665        out.max_fraction
8666            .into_pyobject(py)
8667            .unwrap()
8668            .unbind()
8669            .into_any(),
8670    );
8671    d.insert(
8672        "max_nuclide".to_string(),
8673        out.max_nuclide
8674            .map(nucleide_nuclei::dialects::serpent)
8675            .into_pyobject(py)
8676            .unwrap()
8677            .unbind()
8678            .into_any(),
8679    );
8680    Ok(d)
8681}
8682
8683/// Find a TAPE6 record by nuclide name, or None.
8684#[pyfunction]
8685fn origen_tape6_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
8686    use pyo3::types::PyDict;
8687    let owned = text.to_owned();
8688    let query = nuclide.to_owned();
8689    let found = py
8690        .detach(move || nucleide_origen_io::Tape6::parse(&owned).map(|t| t.find(&query).cloned()))
8691        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8692    Ok(found.map(|r| {
8693        let d = PyDict::new(py);
8694        d.set_item("nuclide", &r.nuclide).ok();
8695        d.set_item("grams", r.grams).ok();
8696        d.set_item("activity_bq", r.activity_bq).ok();
8697        d.into_any().unbind()
8698    }))
8699}
8700
8701/// Total TAPE6 inventory activity in becquerel.
8702#[pyfunction]
8703fn origen_tape6_total_activity(text: &str) -> PyResult<f64> {
8704    nucleide_origen_io::Tape6::parse(text)
8705        .map(|t| t.total_activity())
8706        .map_err(|e| PyValueError::new_err(e.to_string()))
8707}
8708
8709/// Find a TAPE9 decay entry by nuclide name, or None.
8710#[pyfunction]
8711fn origen_tape9_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
8712    use pyo3::types::PyDict;
8713    let owned = text.to_owned();
8714    let query = nuclide.to_owned();
8715    let found = py
8716        .detach(move || {
8717            nucleide_origen_io::Tape9Entry::parse(&owned)
8718                .map(|entries| nucleide_origen_io::Tape9Entry::find(&entries, &query).cloned())
8719        })
8720        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8721    Ok(found.map(|e| {
8722        let d = PyDict::new(py);
8723        d.set_item("nuclide", &e.nuclide).ok();
8724        d.set_item("decay_const", e.decay_const).ok();
8725        d.into_any().unbind()
8726    }))
8727}
8728
8729/// Number of spatial points in an RTFLUX/ATFLUX/RZFLUX file.
8730#[pyfunction]
8731#[pyo3(signature = (text, kind="rtflux"))]
8732fn cccc_rtflux_npoints(text: &str, kind: &str) -> PyResult<usize> {
8733    let flux_kind = parse_flux_kind(kind)?;
8734    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
8735        .map(|f| f.npoints())
8736        .map_err(|e| PyValueError::new_err(e.to_string()))
8737}
8738
8739/// Flux vector for point `i`, or None when out of range.
8740#[pyfunction]
8741#[pyo3(signature = (text, kind="rtflux", index=0))]
8742fn cccc_rtflux_point(text: &str, kind: &str, index: usize) -> PyResult<Option<Vec<f64>>> {
8743    let flux_kind = parse_flux_kind(kind)?;
8744    let flux = nucleide_cccc_io::FluxFile::parse(flux_kind, text)
8745        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8746    Ok(flux.point(index).map(<[f64]>::to_vec))
8747}
8748
8749/// Sum of all flux values in an RTFLUX/ATFLUX/RZFLUX file.
8750#[pyfunction]
8751#[pyo3(signature = (text, kind="rtflux"))]
8752fn cccc_rtflux_total(text: &str, kind: &str) -> PyResult<f64> {
8753    let flux_kind = parse_flux_kind(kind)?;
8754    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
8755        .map(|f| f.total())
8756        .map_err(|e| PyValueError::new_err(e.to_string()))
8757}
8758
8759fn parse_flux_kind(kind: &str) -> PyResult<nucleide_cccc_io::rtflux::FluxKind> {
8760    match kind.to_ascii_lowercase().as_str() {
8761        "rtflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rtflux),
8762        "atflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Atflux),
8763        "rzflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rzflux),
8764        other => Err(PyValueError::new_err(format!(
8765            "kind must be rtflux|atflux|rzflux, got `{other}`"
8766        ))),
8767    }
8768}
8769
8770/// Find an ISOTXS nuclide by label, or None.
8771#[pyfunction]
8772fn cccc_isotxs_find(py: Python<'_>, text: &str, label: &str) -> PyResult<Option<Py<PyAny>>> {
8773    use pyo3::types::PyDict;
8774    let owned = text.to_owned();
8775    let query = label.to_owned();
8776    let found = py
8777        .detach(move || {
8778            nucleide_cccc_io::IsotxsLib::parse(&owned).map(|lib| lib.find(&query).cloned())
8779        })
8780        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8781    Ok(found.map(|n| {
8782        let d = PyDict::new(py);
8783        d.set_item("label", &n.label).ok();
8784        d.set_item("zaid", &n.zaid).ok();
8785        d.set_item("groups", n.groups).ok();
8786        d.set_item("total_xs", n.total_xs.clone()).ok();
8787        d.into_any().unbind()
8788    }))
8789}
8790
8791/// Number of nuclides in an ISOTXS library.
8792#[pyfunction]
8793fn cccc_isotxs_len(text: &str) -> PyResult<usize> {
8794    nucleide_cccc_io::IsotxsLib::parse(text)
8795        .map(|lib| lib.len())
8796        .map_err(|e| PyValueError::new_err(e.to_string()))
8797}
8798
8799/// Identify a FISPACT-II output by its `.fis` suffix convention.
8800#[pyfunction]
8801fn fispact_is_output(path: &str) -> bool {
8802    nucleide_fispact_io::is_fispact_output(path)
8803}
8804
8805/// Product-per-feed mass ratio for assays `x_feed`, `x_prod`, `x_tail`.
8806#[pyfunction]
8807fn enrichment_prod_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8808    nucleide_enrichment::prod_per_feed(x_feed, x_prod, x_tail)
8809}
8810
8811/// Tails-per-feed mass ratio.
8812#[pyfunction]
8813fn enrichment_tail_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8814    nucleide_enrichment::tail_per_feed(x_feed, x_prod, x_tail)
8815}
8816
8817/// Tails-per-product mass ratio.
8818#[pyfunction]
8819fn enrichment_tail_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8820    nucleide_enrichment::tail_per_prod(x_feed, x_prod, x_tail)
8821}
8822
8823/// Feed-per-product mass ratio.
8824#[pyfunction]
8825fn enrichment_feed_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8826    nucleide_enrichment::feed_per_prod(x_feed, x_prod, x_tail)
8827}
8828
8829/// Feed-per-tails mass ratio.
8830#[pyfunction]
8831fn enrichment_feed_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8832    nucleide_enrichment::feed_per_tail(x_feed, x_prod, x_tail)
8833}
8834
8835/// Product-per-tails mass ratio.
8836#[pyfunction]
8837fn enrichment_prod_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
8838    nucleide_enrichment::prod_per_tail(x_feed, x_prod, x_tail)
8839}
8840
8841/// Stage separation factor for a component of mass `m_i`.
8842#[pyfunction]
8843#[allow(non_snake_case)]
8844fn enrichment_alphastar_i(alpha: f64, Mstar: f64, M_i: f64) -> f64 {
8845    nucleide_enrichment::alphastar_i(alpha, Mstar, M_i)
8846}
8847
8848/// Validated delayed-neutron data from OpenMC IFP kinetics data.
8849///
8850/// OpenMC's IFP estimator reports effective delayed fractions (`betas`)
8851/// and the generation time (`lambda_gen`) but no precursor decay
8852/// constants: the caller supplies `lambdas` from the same data library.
8853/// Returns {betas, lambdas, lambda_gen, beta_total, groups}.
8854#[pyfunction]
8855fn kinetics_from_ifp(
8856    py: Python<'_>,
8857    betas: Vec<f64>,
8858    lambda_gen: f64,
8859    lambdas: Vec<f64>,
8860) -> PyResult<Py<PyAny>> {
8861    use pyo3::types::PyDict;
8862    let params = nucleide_kinetics::KineticParams::from_ifp(betas, lambda_gen, lambdas)
8863        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8864    let d = PyDict::new(py);
8865    d.set_item("betas", params.betas()).ok();
8866    d.set_item("lambdas", params.lambdas()).ok();
8867    d.set_item("lambda_gen", params.lambda_gen()).ok();
8868    d.set_item("beta_total", params.beta_total()).ok();
8869    d.set_item("groups", params.groups()).ok();
8870    Ok(d.into_any().unbind())
8871}
8872
8873/// Run MAGIC with explicit array selection and parameters.
8874/// `selection` is "total" (default) or "per_group".
8875#[pyfunction]
8876#[pyo3(signature = (tally, selection="total", tolerance=0.5, null_value=0.0))]
8877fn magic_with(
8878    tally: &PyMeshTally,
8879    selection: &str,
8880    tolerance: f64,
8881    null_value: f64,
8882) -> PyResult<PyMagicOutput> {
8883    let sel = match selection.trim().to_ascii_lowercase().as_str() {
8884        "total" => nucleide_vr_tools::magic::MagicSelection::Total,
8885        "per_group" | "pergroup" | "per-group" => {
8886            nucleide_vr_tools::magic::MagicSelection::PerGroup
8887        }
8888        other => {
8889            return Err(PyValueError::new_err(format!(
8890                "selection must be total|per_group, got `{other}`"
8891            )));
8892        }
8893    };
8894    let params = nucleide_vr_tools::magic::MagicParams {
8895        tolerance,
8896        null_value,
8897    };
8898    nucleide_vr_tools::magic::magic_with(&tally.inner, sel, params)
8899        .map(|inner| PyMagicOutput { inner })
8900        .map_err(|e| PyValueError::new_err(e.to_string()))
8901}
8902
8903/// Emit MAGIC weight windows as an OpenMC `settings.xml` fragment (`<mesh>`
8904/// + `<weight_windows>` elements to paste inside the existing `<settings>`
8905/// root). Returns `{"xml": str, "notes": list[str]}`.
8906#[pyfunction]
8907#[pyo3(signature = (tally, output, mesh_id=1, window_id=1, upper_bound_ratio=5.0, survival_ratio=3.0, max_split=10, weight_cutoff=1e-38))]
8908#[allow(clippy::too_many_arguments)]
8909fn emit_openmc_weight_windows(
8910    py: Python<'_>,
8911    tally: &PyMeshTally,
8912    output: &PyMagicOutput,
8913    mesh_id: u32,
8914    window_id: u32,
8915    upper_bound_ratio: f64,
8916    survival_ratio: f64,
8917    max_split: u32,
8918    weight_cutoff: f64,
8919) -> PyResult<Py<PyAny>> {
8920    use pyo3::types::PyDict;
8921    let options = nucleide_vr_tools::windows::OpenMcOptions {
8922        mesh_id,
8923        window_id,
8924        upper_bound_ratio,
8925        survival_ratio,
8926        max_split,
8927        weight_cutoff,
8928    };
8929    let out = nucleide_vr_tools::windows::emit_openmc_weight_windows(
8930        &output.inner,
8931        &tally.inner,
8932        &options,
8933    )
8934    .map_err(|e| PyValueError::new_err(e.to_string()))?;
8935    let d = PyDict::new(py);
8936    d.set_item("xml", out.xml)?;
8937    d.set_item("notes", out.notes)?;
8938    Ok(d.into_any().unbind())
8939}
8940
8941/// Emit MAGIC weight windows as a Serpent-readable weight-window file in the
8942/// MCNP WWINP text spelling (`wwin <name> wf "<file>" 2`). Returns
8943/// `{"text": str, "card": str, "notes": list[str]}`.
8944#[pyfunction]
8945#[pyo3(signature = (tally, output, name="ww1", file="wwindows.wwd"))]
8946fn emit_serpent_wwin(
8947    py: Python<'_>,
8948    tally: &PyMeshTally,
8949    output: &PyMagicOutput,
8950    name: &str,
8951    file: &str,
8952) -> PyResult<Py<PyAny>> {
8953    use pyo3::types::PyDict;
8954    let out =
8955        nucleide_vr_tools::windows::emit_serpent_wwin(&output.inner, &tally.inner, name, file)
8956            .map_err(|e| PyValueError::new_err(e.to_string()))?;
8957    let d = PyDict::new(py);
8958    d.set_item("text", out.text)?;
8959    d.set_item("card", out.card)?;
8960    d.set_item("notes", out.notes)?;
8961    Ok(d.into_any().unbind())
8962}
8963
8964/// Check one `stat:sum:<key>:<24-char value>` MCPL header comment.
8965#[pyfunction]
8966fn mcpl_statsum_validate(comment: &str) -> PyResult<String> {
8967    nucleide_mcpl_io::statsum_validate(comment)
8968        .map(str::to_string)
8969        .map_err(PyValueError::new_err)
8970}
8971
8972/// Build a well-formed `stat:sum:` MCPL header comment.
8973#[pyfunction]
8974fn mcpl_statsum_comment(key: &str, value: f64) -> PyResult<String> {
8975    nucleide_mcpl_io::statsum_comment(key, value).map_err(|e| PyValueError::new_err(e.to_string()))
8976}
8977
8978/// Python module entry point.
8979#[pymodule]
8980fn _internal(m: &Bound<'_, PyModule>) -> PyResult<()> {
8981    m.add_function(wrap_pyfunction!(version, m)?)?;
8982    m.add_function(wrap_pyfunction!(from_zaid, m)?)?;
8983    m.add_function(wrap_pyfunction!(atomic_mass, m)?)?;
8984    m.add_function(wrap_pyfunction!(natural_abundance, m)?)?;
8985    m.add_function(wrap_pyfunction!(rxname_id, m)?)?;
8986    m.add_function(wrap_pyfunction!(rxname_name, m)?)?;
8987    m.add_function(wrap_pyfunction!(rxname_mt, m)?)?;
8988    m.add_function(wrap_pyfunction!(rxname_label, m)?)?;
8989    m.add_function(wrap_pyfunction!(rxname_doc, m)?)?;
8990    m.add_function(wrap_pyfunction!(rxname_reaction, m)?)?;
8991    m.add_function(wrap_pyfunction!(rxname_id_from_nucdelta, m)?)?;
8992    m.add_function(wrap_pyfunction!(rxname_child, m)?)?;
8993    m.add_function(wrap_pyfunction!(rxname_parent, m)?)?;
8994    m.add_function(wrap_pyfunction!(particle_is_valid, m)?)?;
8995    m.add_function(wrap_pyfunction!(particle_is_valid_pdc, m)?)?;
8996    m.add_function(wrap_pyfunction!(particle_is_hydrogen, m)?)?;
8997    m.add_function(wrap_pyfunction!(particle_is_heavy_ion, m)?)?;
8998    m.add_function(wrap_pyfunction!(dose_f1, m)?)?;
8999    m.add_function(wrap_pyfunction!(dose_lung_model, m)?)?;
9000    m.add_function(wrap_pyfunction!(read_xsdir, m)?)?;
9001    m.add_function(wrap_pyfunction!(read_meshtal, m)?)?;
9002    m.add_function(wrap_pyfunction!(read_wwinp, m)?)?;
9003    m.add_function(wrap_pyfunction!(read_mctal, m)?)?;
9004    m.add_function(wrap_pyfunction!(read_ssw, m)?)?;
9005    m.add_function(wrap_pyfunction!(read_ptrac, m)?)?;
9006    m.add_function(wrap_pyfunction!(read_mcpl, m)?)?;
9007    m.add_function(wrap_pyfunction!(write_mcpl, m)?)?;
9008    m.add_function(wrap_pyfunction!(ssw2mcpl, m)?)?;
9009    m.add_function(wrap_pyfunction!(mcpl2ssw, m)?)?;
9010    m.add_function(wrap_pyfunction!(merge_mcpl, m)?)?;
9011    m.add_function(wrap_pyfunction!(extract_mcpl, m)?)?;
9012    m.add_function(wrap_pyfunction!(mcpl_stats, m)?)?;
9013    m.add_function(wrap_pyfunction!(repair_mcpl, m)?)?;
9014    m.add_function(wrap_pyfunction!(read_endl, m)?)?;
9015    m.add_function(wrap_pyfunction!(endl_endftod, m)?)?;
9016    m.add_function(wrap_pyfunction!(combine_ssw_files, m)?)?;
9017    m.add_function(wrap_pyfunction!(read_chain, m)?)?;
9018    m.add_function(wrap_pyfunction!(build_depletion_system, m)?)?;
9019    m.add_function(wrap_pyfunction!(deplete, m)?)?;
9020    m.add_function(wrap_pyfunction!(deplete_series, m)?)?;
9021    m.add_function(wrap_pyfunction!(simple_xs, m)?)?;
9022    m.add_function(wrap_pyfunction!(scattering_length, m)?)?;
9023    m.add_function(wrap_pyfunction!(decay_energy, m)?)?;
9024    m.add_function(wrap_pyfunction!(decay_branches, m)?)?;
9025    m.add_function(wrap_pyfunction!(decay_branch_fraction, m)?)?;
9026    m.add_function(wrap_pyfunction!(fission_yields, m)?)?;
9027    m.add_function(wrap_pyfunction!(fission_yield, m)?)?;
9028    m.add_function(wrap_pyfunction!(normalize_nuclide, m)?)?;
9029    m.add_function(wrap_pyfunction!(decay_heat, m)?)?;
9030    m.add_function(wrap_pyfunction!(dose_factor, m)?)?;
9031    m.add_function(wrap_pyfunction!(dose_per_g, m)?)?;
9032    m.add_function(wrap_pyfunction!(parse_fgr15_table, m)?)?;
9033    m.add_function(wrap_pyfunction!(fgr15_age_index, m)?)?;
9034    m.add_function(wrap_pyfunction!(mix_by_mass, m)?)?;
9035    m.add_function(wrap_pyfunction!(mix_by_volume, m)?)?;
9036    m.add_function(wrap_pyfunction!(specific_activity, m)?)?;
9037    m.add_function(wrap_pyfunction!(materials_doc_to_xml, m)?)?;
9038    m.add_function(wrap_pyfunction!(expand_elements, m)?)?;
9039    m.add_function(wrap_pyfunction!(collapse_elements, m)?)?;
9040    m.add_function(wrap_pyfunction!(read_serpent, m)?)?;
9041    m.add_function(wrap_pyfunction!(read_usrbin, m)?)?;
9042    m.add_function(wrap_pyfunction!(fluka_material_str, m)?)?;
9043    m.add_function(wrap_pyfunction!(fluka_compound_str, m)?)?;
9044    m.add_function(wrap_pyfunction!(fluka_builtin_set, m)?)?;
9045    m.add_function(wrap_pyfunction!(magic, m)?)?;
9046    m.add_function(wrap_pyfunction!(magic_with, m)?)?;
9047    m.add_function(wrap_pyfunction!(emit_openmc_weight_windows, m)?)?;
9048    m.add_function(wrap_pyfunction!(emit_serpent_wwin, m)?)?;
9049    m.add_function(wrap_pyfunction!(write_ssw, m)?)?;
9050    m.add_function(wrap_pyfunction!(mesh_to_geom, m)?)?;
9051    m.add_function(wrap_pyfunction!(half_life, m)?)?;
9052    m.add_function(wrap_pyfunction!(decay_constant, m)?)?;
9053    m.add_function(wrap_pyfunction!(q_value_capture, m)?)?;
9054    m.add_function(wrap_pyfunction!(q_value_alpha, m)?)?;
9055    m.add_function(wrap_pyfunction!(read_inp, m)?)?;
9056    m.add_function(wrap_pyfunction!(from_formula, m)?)?;
9057    m.add_function(wrap_pyfunction!(activity, m)?)?;
9058    m.add_function(wrap_pyfunction!(to_xml, m)?)?;
9059    m.add_function(wrap_pyfunction!(alara_parse_deck, m)?)?;
9060    m.add_function(wrap_pyfunction!(alara_parse_flux, m)?)?;
9061    m.add_function(wrap_pyfunction!(alara_parse_output, m)?)?;
9062    m.add_function(wrap_pyfunction!(alara_expand_schedule, m)?)?;
9063    m.add_function(wrap_pyfunction!(alara_validate_deck, m)?)?;
9064    m.add_function(wrap_pyfunction!(alara_check_block, m)?)?;
9065    m.add_function(wrap_pyfunction!(alara_flux_total, m)?)?;
9066    m.add_function(wrap_pyfunction!(alara_flux_len, m)?)?;
9067    m.add_function(wrap_pyfunction!(alara_output_totals, m)?)?;
9068    m.add_function(wrap_pyfunction!(alara_output_total_activity, m)?)?;
9069    m.add_function(wrap_pyfunction!(alara_photon_total_strength, m)?)?;
9070    m.add_function(wrap_pyfunction!(alara_schedule_total_time, m)?)?;
9071    m.add_function(wrap_pyfunction!(alara_clearance_eu_table, m)?)?;
9072    m.add_function(wrap_pyfunction!(alara_clearance_index, m)?)?;
9073    m.add_function(wrap_pyfunction!(alara_sum_of_fractions, m)?)?;
9074    m.add_function(wrap_pyfunction!(isotxs_parse, m)?)?;
9075    m.add_function(wrap_pyfunction!(rtflux_parse, m)?)?;
9076    m.add_function(wrap_pyfunction!(cccc_rtflux_npoints, m)?)?;
9077    m.add_function(wrap_pyfunction!(cccc_rtflux_point, m)?)?;
9078    m.add_function(wrap_pyfunction!(cccc_rtflux_total, m)?)?;
9079    m.add_function(wrap_pyfunction!(cccc_isotxs_find, m)?)?;
9080    m.add_function(wrap_pyfunction!(cccc_isotxs_len, m)?)?;
9081    m.add_function(wrap_pyfunction!(partisn_render, m)?)?;
9082    m.add_function(wrap_pyfunction!(partisn_validate, m)?)?;
9083    m.add_function(wrap_pyfunction!(fispact_parse_output, m)?)?;
9084    m.add_function(wrap_pyfunction!(fispact_parse_clearance, m)?)?;
9085    m.add_function(wrap_pyfunction!(fispact_is_output, m)?)?;
9086    m.add_function(wrap_pyfunction!(origen_parse_tape5, m)?)?;
9087    m.add_function(wrap_pyfunction!(origen_parse_tape6, m)?)?;
9088    m.add_function(wrap_pyfunction!(origen_parse_tape9, m)?)?;
9089    m.add_function(wrap_pyfunction!(origen_tape6_find, m)?)?;
9090    m.add_function(wrap_pyfunction!(origen_tape6_total_activity, m)?)?;
9091    m.add_function(wrap_pyfunction!(origen_tape9_find, m)?)?;
9092    m.add_function(wrap_pyfunction!(r2s_from_deck, m)?)?;
9093    m.add_function(wrap_pyfunction!(r2s_from_snapshot, m)?)?;
9094    m.add_function(wrap_pyfunction!(r2s_validate, m)?)?;
9095    m.add_function(wrap_pyfunction!(r2s_expand, m)?)?;
9096    m.add_function(wrap_pyfunction!(r2s_assemble, m)?)?;
9097    m.add_function(wrap_pyfunction!(r2s_tag_zone_strength, m)?)?;
9098    m.add_function(wrap_pyfunction!(r2s_photon_group_sums, m)?)?;
9099    m.add_function(wrap_pyfunction!(r2s_snapshot_inventory, m)?)?;
9100    m.add_function(wrap_pyfunction!(r2s_expand_sweep, m)?)?;
9101    m.add_function(wrap_pyfunction!(kinetics_solve, m)?)?;
9102    m.add_function(wrap_pyfunction!(kinetics_equilibrium, m)?)?;
9103    m.add_function(wrap_pyfunction!(kinetics_initial_rate, m)?)?;
9104    m.add_function(wrap_pyfunction!(kinetics_inhour_rho, m)?)?;
9105    m.add_function(wrap_pyfunction!(kinetics_stable_period, m)?)?;
9106    m.add_function(wrap_pyfunction!(kinetics_prompt_jump, m)?)?;
9107    m.add_function(wrap_pyfunction!(kinetics_from_ifp, m)?)?;
9108    m.add_function(wrap_pyfunction!(unfold_sandii, m)?)?;
9109    m.add_function(wrap_pyfunction!(unfold_forward_fold, m)?)?;
9110    m.add_function(wrap_pyfunction!(plasma_source_particles, m)?)?;
9111    m.add_function(wrap_pyfunction!(plasma_source_emit_cards, m)?)?;
9112    m.add_function(wrap_pyfunction!(plasma_source_spectrum_moments, m)?)?;
9113    m.add_function(wrap_pyfunction!(plasma_source_reactivity, m)?)?;
9114    m.add_function(wrap_pyfunction!(damage_nrt_dpa, m)?)?;
9115    m.add_function(wrap_pyfunction!(damage_arc_dpa, m)?)?;
9116    m.add_function(wrap_pyfunction!(damage_gas_appm, m)?)?;
9117    m.add_function(wrap_pyfunction!(damage_he_dpa_ratio, m)?)?;
9118    m.add_function(wrap_pyfunction!(damage_lindhard_partition, m)?)?;
9119    m.add_function(wrap_pyfunction!(damage_damage_energy, m)?)?;
9120    m.add_function(wrap_pyfunction!(damage_nrt_displacements, m)?)?;
9121    m.add_function(wrap_pyfunction!(damage_arc_efficiency, m)?)?;
9122    m.add_function(wrap_pyfunction!(damage_arc_displacements, m)?)?;
9123    m.add_function(wrap_pyfunction!(damage_fold_uq, m)?)?;
9124    m.add_function(wrap_pyfunction!(tritium_steady, m)?)?;
9125    m.add_function(wrap_pyfunction!(tritium_transient, m)?)?;
9126    m.add_function(wrap_pyfunction!(tritium_time_lag, m)?)?;
9127    m.add_function(wrap_pyfunction!(tritium_breakthrough, m)?)?;
9128    m.add_function(wrap_pyfunction!(tritium_oriani, m)?)?;
9129    m.add_function(wrap_pyfunction!(tritium_langmuir, m)?)?;
9130    m.add_function(wrap_pyfunction!(tritium_irreversible_fill, m)?)?;
9131    m.add_function(wrap_pyfunction!(tritium_sieverts, m)?)?;
9132    m.add_function(wrap_pyfunction!(tritium_recombination_rate, m)?)?;
9133    m.add_function(wrap_pyfunction!(tritium_layers_steady, m)?)?;
9134    m.add_function(wrap_pyfunction!(tritium_layers_transient, m)?)?;
9135    m.add_function(wrap_pyfunction!(spectroscopy_rect_smooth, m)?)?;
9136    m.add_function(wrap_pyfunction!(spectroscopy_five_point_smooth, m)?)?;
9137    m.add_function(wrap_pyfunction!(spectroscopy_calc_bg, m)?)?;
9138    m.add_function(wrap_pyfunction!(spectroscopy_gross_count, m)?)?;
9139    m.add_function(wrap_pyfunction!(spectroscopy_net_counts, m)?)?;
9140    m.add_function(wrap_pyfunction!(spectroscopy_energy_bins, m)?)?;
9141    m.add_function(wrap_pyfunction!(spectroscopy_detector_efficiency, m)?)?;
9142    m.add_function(wrap_pyfunction!(spectroscopy_fit_efficiency, m)?)?;
9143    m.add_function(wrap_pyfunction!(spectroscopy_xray_lines, m)?)?;
9144    m.add_function(wrap_pyfunction!(spectroscopy_sdef_decay_source, m)?)?;
9145    m.add_function(wrap_pyfunction!(spectroscopy_parse_dollar_spe, m)?)?;
9146    m.add_function(wrap_pyfunction!(spectroscopy_parse_spe, m)?)?;
9147    m.add_function(wrap_pyfunction!(spectroscopy_read_dollar_spe, m)?)?;
9148    m.add_function(wrap_pyfunction!(spectroscopy_read_spe, m)?)?;
9149    m.add_function(wrap_pyfunction!(spectroscopy_parse_lines_tsv, m)?)?;
9150    m.add_function(wrap_pyfunction!(spectroscopy_read_decay_lines, m)?)?;
9151    m.add_function(wrap_pyfunction!(uq_sample_mvn, m)?)?;
9152    m.add_function(wrap_pyfunction!(uq_sample_lhs, m)?)?;
9153    m.add_function(wrap_pyfunction!(uq_sample_lognormal, m)?)?;
9154    m.add_function(wrap_pyfunction!(uq_lognormal_mean, m)?)?;
9155    m.add_function(wrap_pyfunction!(uq_lognormal_cov, m)?)?;
9156    m.add_function(wrap_pyfunction!(uq_sample_mean, m)?)?;
9157    m.add_function(wrap_pyfunction!(uq_sample_cov, m)?)?;
9158    m.add_function(wrap_pyfunction!(uq_check_convergence, m)?)?;
9159    m.add_function(wrap_pyfunction!(uq_perturb_branches, m)?)?;
9160    m.add_function(wrap_pyfunction!(uq_perturb_energies, m)?)?;
9161    m.add_function(wrap_pyfunction!(uq_passthrough, m)?)?;
9162    m.add_function(wrap_pyfunction!(uq_perturb_fission_yields, m)?)?;
9163    m.add_function(wrap_pyfunction!(parse_deck, m)?)?;
9164    m.add_function(wrap_pyfunction!(read_deck, m)?)?;
9165    m.add_function(wrap_pyfunction!(parse_sdef, m)?)?;
9166    m.add_function(wrap_pyfunction!(parse_csg_to_openmc, m)?)?;
9167    m.add_function(wrap_pyfunction!(read_csg_to_openmc, m)?)?;
9168    m.add_function(wrap_pyfunction!(parse_csg_to_serpent, m)?)?;
9169    m.add_function(wrap_pyfunction!(read_csg_to_serpent, m)?)?;
9170    m.add_function(wrap_pyfunction!(parse_csg_to_phits, m)?)?;
9171    m.add_function(wrap_pyfunction!(read_csg_to_phits, m)?)?;
9172    m.add_function(wrap_pyfunction!(parse_csg_to_gdml, m)?)?;
9173    m.add_function(wrap_pyfunction!(read_csg_to_gdml, m)?)?;
9174    m.add_function(wrap_pyfunction!(cumulative_decays, m)?)?;
9175    m.add_function(wrap_pyfunction!(progeny, m)?)?;
9176    m.add_function(wrap_pyfunction!(branching_fraction, m)?)?;
9177    m.add_function(wrap_pyfunction!(decay_mode, m)?)?;
9178    m.add_function(wrap_pyfunction!(chain_edges, m)?)?;
9179    m.add_function(wrap_pyfunction!(armi_to_nucid, m)?)?;
9180    m.add_function(wrap_pyfunction!(nucid_to_armi, m)?)?;
9181    m.add_function(wrap_pyfunction!(mcc3_to_nucid, m)?)?;
9182    m.add_function(wrap_pyfunction!(check_labels, m)?)?;
9183    m.add_function(wrap_pyfunction!(audit_material, m)?)?;
9184    m.add_function(wrap_pyfunction!(separate_material, m)?)?;
9185    m.add_function(wrap_pyfunction!(blend_material, m)?)?;
9186    m.add_function(wrap_pyfunction!(enrichment_value_func, m)?)?;
9187    m.add_function(wrap_pyfunction!(enrichment_swu_per_feed, m)?)?;
9188    m.add_function(wrap_pyfunction!(enrichment_swu_per_prod, m)?)?;
9189    m.add_function(wrap_pyfunction!(enrichment_swu_per_tail, m)?)?;
9190    m.add_function(wrap_pyfunction!(enrichment_prod_per_feed, m)?)?;
9191    m.add_function(wrap_pyfunction!(enrichment_tail_per_feed, m)?)?;
9192    m.add_function(wrap_pyfunction!(enrichment_tail_per_prod, m)?)?;
9193    m.add_function(wrap_pyfunction!(enrichment_feed_per_prod, m)?)?;
9194    m.add_function(wrap_pyfunction!(enrichment_feed_per_tail, m)?)?;
9195    m.add_function(wrap_pyfunction!(enrichment_prod_per_tail, m)?)?;
9196    m.add_function(wrap_pyfunction!(enrichment_alphastar_i, m)?)?;
9197    m.add_function(wrap_pyfunction!(mcpl_statsum_validate, m)?)?;
9198    m.add_function(wrap_pyfunction!(mcpl_statsum_comment, m)?)?;
9199    m.add_class::<PyCusum>()?;
9200    m.add_function(wrap_pyfunction!(emit_cards, m)?)?;
9201    m.add_function(wrap_pyfunction!(emit_drift_table, m)?)?;
9202    m.add_function(wrap_pyfunction!(emit_armi_cards, m)?)?;
9203    m.add_function(wrap_pyfunction!(emit_armi_drift_table, m)?)?;
9204    m.add_class::<PyNuclide>()?;
9205    m.add_class::<PyParticle>()?;
9206    m.add_class::<PyXsdir>()?;
9207    m.add_class::<PyXsdirTable>()?;
9208    m.add_class::<PyMeshtal>()?;
9209    m.add_class::<PyMeshTally>()?;
9210    m.add_class::<PyWwinp>()?;
9211    m.add_class::<PyMctal>()?;
9212    m.add_class::<PySurfSrc>()?;
9213    m.add_class::<PyPtracFile>()?;
9214    m.add_class::<PyMcplFile>()?;
9215    m.add_class::<PyEndlLibrary>()?;
9216    m.add_class::<PyChain>()?;
9217    m.add_class::<PyDepletionSystem>()?;
9218    m.add_class::<PyUsrbinTally>()?;
9219    m.add_class::<PyMagicOutput>()?;
9220    m.add_class::<PyAliasTable>()?;
9221    m.add_class::<PyMeshSourceSampler>()?;
9222    m.add_class::<PyKdeSampler>()?;
9223    m.add_class::<PyCascade>()?;
9224    m.add_class::<PyMaterialsCompendium>()?;
9225    m.add_class::<PyDeckProblem>()?;
9226    m.add_class::<PyInventory>()?;
9227    Ok(())
9228}