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/// Parse IRDFF-II g725 member text into the foil-activation response pack.
4806///
4807/// Thin wrapper over `nucleide_nuclei::irdff::parse_g725`: parses the
4808/// `MF=3` sections of the named v1 foil reactions from `IRDFF-II.g725`
4809/// text (fetched and hash-pinned by `nucleide.data.fetch_irdff`) into
4810/// caller-ready response rows over the SAND-II 725-group structure.
4811/// `reactions` selects a subset of registry names (`au197_ng`,
4812/// `in115_ng`, `u235_nf`, `u238_nf`, `fe56_np`, `ni58_np`, `al27_na`,
4813/// `na23_n2n`); `None` parses the full v1 pack (a full-range row must be
4814/// included to anchor the group structure). Returns
4815/// `{"groups": [726 eV bounds], "reactions": [name], "response":
4816/// [[sigma per group]]}` — the response rows feed `unfold_sandii`
4817/// unchanged. Structural problems, missing/duplicate sections,
4818/// off-structure energies, and row-shape violations are loud errors.
4819/// Nothing is vendored: the text comes from the runtime-fetched,
4820/// SHA-256-pinned official IAEA zip.
4821#[pyfunction]
4822#[pyo3(signature = (text, reactions=None))]
4823fn parse_irdff_g725<'py>(
4824    py: Python<'py>,
4825    text: &str,
4826    reactions: Option<Vec<String>>,
4827) -> PyResult<pyo3::Bound<'py, pyo3::types::PyDict>> {
4828    use nucleide_nuclei::irdff::{parse_g725, V1_REACTIONS};
4829    let wanted: Vec<nucleide_nuclei::irdff::IrdffReaction> = match &reactions {
4830        None => V1_REACTIONS.to_vec(),
4831        Some(names) => {
4832            let mut out = Vec::with_capacity(names.len());
4833            for name in names {
4834                match V1_REACTIONS.iter().find(|r| r.name == name) {
4835                    Some(r) => out.push(*r),
4836                    None => {
4837                        let supported = V1_REACTIONS
4838                            .iter()
4839                            .map(|r| r.name)
4840                            .collect::<Vec<_>>()
4841                            .join(", ");
4842                        return Err(PyValueError::new_err(format!(
4843                            "unknown IRDFF-II reaction `{name}` (supported: {supported})"
4844                        )));
4845                    }
4846                }
4847            }
4848            out
4849        }
4850    };
4851    let pack = parse_g725(text, &wanted).map_err(|e| PyValueError::new_err(e.to_string()))?;
4852    let out = pyo3::types::PyDict::new(py);
4853    out.set_item("groups", pack.bounds().to_vec())?;
4854    out.set_item(
4855        "reactions",
4856        pack.rows()
4857            .iter()
4858            .map(|r| r.reaction().name)
4859            .collect::<Vec<_>>(),
4860    )?;
4861    out.set_item("response", pack.response())?;
4862    Ok(out)
4863}
4864
4865/// Total dose per gram of a composition dict ({nuclide name: grams}).
4866///
4867/// Thin wrapper over `Material::total_dose_per_g` (Ame2020 masses,
4868/// ENDF/B-VIII.0 decay constants, HNF-5636/PyNE dose factors). Pathway is one
4869/// of `air`/`soil`/`ingest`/`inhale`; source is `EPA`/`DOE`/`GENII` (default
4870/// `EPA`). Units follow the table: air `mrem/h per g per m^3`, soil
4871/// `mrem/h per g per m^2`, ingest/inhale `mrem per g`. Screening-level only —
4872/// not for safety decisions. Stable nuclides (known mass, no decay constant)
4873/// contribute 0 without a dose-factor lookup. Errors when a nuclide lacks
4874/// mass data, or a radioactive nuclide lacks dose data (including `-1`
4875/// GENII/DOE air sentinels).
4876#[pyfunction]
4877#[pyo3(signature = (comp, pathway, source="EPA"))]
4878fn dose_per_g(comp: BTreeMap<String, f64>, pathway: &str, source: &str) -> PyResult<f64> {
4879    let mat = comp_to_material(comp)?;
4880    let analytics = nucleide_material::Analytics {
4881        masses: &nucleide_material::Ame2020,
4882        decays: &nucleide_material::ChainDecays,
4883    };
4884    let p = parse_dose_pathway(pathway)?;
4885    let s = parse_dose_source(source)?;
4886    mat.total_dose_per_g(&analytics, &nucleide_material::DoseFactors, p, s)
4887        .map_err(|e| PyValueError::new_err(e.to_string()))
4888}
4889
4890/// Split a composition dict into product and tails dicts by per-nuclide
4891/// separation efficiency.
4892///
4893/// `comp` maps nuclide names to grams; `effs` maps nuclide names to
4894/// efficiencies in `[0, 1]` (unlisted nuclides go entirely to tails).
4895/// Returns `(product, tails)` with per-nuclide mass conserved. Thin wrapper
4896/// over `Material::separate`.
4897#[pyfunction]
4898#[allow(clippy::type_complexity)]
4899fn separate_material(
4900    comp: BTreeMap<String, f64>,
4901    effs: BTreeMap<String, f64>,
4902) -> PyResult<(BTreeMap<String, f64>, BTreeMap<String, f64>)> {
4903    let mat = comp_to_material(comp)?;
4904    let mut table = Vec::with_capacity(effs.len());
4905    for (name, eff) in &effs {
4906        let id = NuclideId::from_name(name)
4907            .map_err(|e| PyValueError::new_err(format!("`{name}`: {e}")))?;
4908        table.push((id, *eff));
4909    }
4910    let (product, tails) = mat
4911        .separate(&table)
4912        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4913    let named =
4914        |m: nucleide_material::Material| m.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect();
4915    Ok((named(product), named(tails)))
4916}
4917
4918/// Blend composition dicts at fixed ratios with explicit normalization.
4919///
4920/// `parts` holds `(comp, ratio)` pairs; ratios are normalized by their sum
4921/// and the output is the weighted average. Errors on empty, all-zero, or
4922/// negative ratios (never a silent uniform split). Thin wrapper over
4923/// `Material::blend`.
4924#[pyfunction]
4925fn blend_material(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
4926    let mats: Vec<nucleide_material::Material> = parts
4927        .iter()
4928        .map(|(comp, _)| comp_to_material(comp.clone()))
4929        .collect::<PyResult<_>>()?;
4930    let refs: Vec<(&nucleide_material::Material, f64)> =
4931        mats.iter().zip(parts.iter().map(|(_, r)| *r)).collect();
4932    let out = nucleide_material::Material::blend(&refs)
4933        .map_err(|e| PyValueError::new_err(e.to_string()))?;
4934    Ok(out.comp.iter().map(|(id, g)| (id.to_name(), *g)).collect())
4935}
4936
4937/// One-sided upper Page CUSUM change detector with Welford statistics.
4938///
4939/// Thin stateful wrapper over `nucleide_material::Cusum`: `update(x)`
4940/// feeds one observation and returns the alarm status; `status()` reads it
4941/// without consuming input; `statistic()` reads the CUSUM value;
4942/// `reset()` drops all observations (tuning kept). Non-finite inputs to
4943/// `update` are ignored.
4944#[pyclass(name = "Cusum")]
4945struct PyCusum {
4946    inner: nucleide_material::Cusum,
4947}
4948
4949#[pymethods]
4950impl PyCusum {
4951    /// Build a detector (`ref_shift_k = 0.5`, `alarm_h = 4.0`,
4952    /// `startup = 10` by default).
4953    #[new]
4954    #[pyo3(signature = (ref_shift_k=0.5, alarm_h=4.0, startup=10))]
4955    fn new(ref_shift_k: f64, alarm_h: f64, startup: usize) -> PyResult<Self> {
4956        nucleide_material::Cusum::new(ref_shift_k, alarm_h, startup)
4957            .map(|inner| Self { inner })
4958            .map_err(|e| PyValueError::new_err(e.to_string()))
4959    }
4960
4961    /// Feed one observation; returns the resulting alarm status.
4962    fn update(&mut self, x: f64) -> bool {
4963        self.inner.update(x)
4964    }
4965
4966    /// Whether the detector is currently alarmed.
4967    fn status(&self) -> bool {
4968        self.inner.status()
4969    }
4970
4971    /// Current CUSUM statistic (`>= 0`).
4972    fn statistic(&self) -> f64 {
4973        self.inner.statistic()
4974    }
4975
4976    /// Running observation count.
4977    fn count(&self) -> usize {
4978        self.inner.count()
4979    }
4980
4981    /// Running mean of the observations seen so far.
4982    fn mean(&self) -> f64 {
4983        self.inner.mean()
4984    }
4985
4986    /// Running sample variance (`0` with fewer than 2 points).
4987    fn variance(&self) -> f64 {
4988        self.inner.variance()
4989    }
4990
4991    /// Running sample standard deviation.
4992    fn std(&self) -> f64 {
4993        self.inner.std()
4994    }
4995
4996    /// Drop all observations; tuning parameters are kept.
4997    fn reset(&mut self) {
4998        self.inner.reset();
4999    }
5000}
5001
5002// ---------------------------------------------------------------------------
5003// 0.3.0 Tier 1: deck round-trip, decay inventories, ARMI dialects, checks
5004// ---------------------------------------------------------------------------
5005
5006/// A parsed MCNP input deck with format-preserving write-back.
5007#[pyclass(name = "DeckProblem")]
5008struct PyDeckProblem {
5009    inner: std::sync::Mutex<nucleide_mcnp_io::problem::DeckProblem>,
5010}
5011
5012fn deck_cell_dict(cell: &nucleide_mcnp_io::cell::CellCard) -> BTreeMap<String, String> {
5013    let mut d = BTreeMap::new();
5014    d.insert("num".to_string(), cell.num.to_string());
5015    d.insert("mat".to_string(), cell.mat.to_string());
5016    d.insert(
5017        "dens".to_string(),
5018        cell.dens.map(|v| v.to_string()).unwrap_or_default(),
5019    );
5020    d.insert("geom".to_string(), cell.geom.render());
5021    d.insert("params".to_string(), cell.params.join(" "));
5022    d
5023}
5024
5025#[pymethods]
5026impl PyDeckProblem {
5027    /// Parse a deck from text.
5028    #[staticmethod]
5029    fn loads(text: &str) -> PyResult<Self> {
5030        nucleide_mcnp_io::problem::parse_deck(text)
5031            .map(|inner| Self {
5032                inner: std::sync::Mutex::new(inner),
5033            })
5034            .map_err(|e| PyValueError::new_err(e.to_string()))
5035    }
5036
5037    /// Message (first) line.
5038    #[getter]
5039    fn message(&self) -> PyResult<String> {
5040        Ok(self
5041            .inner
5042            .lock()
5043            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5044            .message
5045            .clone())
5046    }
5047
5048    /// Title card (second line).
5049    #[getter]
5050    fn title(&self) -> PyResult<String> {
5051        Ok(self
5052            .inner
5053            .lock()
5054            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5055            .title
5056            .clone())
5057    }
5058
5059    /// Cell cards as `{num, mat, dens, geom, params}` dicts (`dens` is `""`
5060    /// for void cells).
5061    #[getter]
5062    fn cells(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5063        Ok(self
5064            .inner
5065            .lock()
5066            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5067            .cells
5068            .iter()
5069            .map(deck_cell_dict)
5070            .collect())
5071    }
5072
5073    /// Surface cards as `{num, reflecting, transform, periodic, kind, coeffs}`
5074    /// dicts (`transform`/`periodic` are `""` when absent).
5075    #[getter]
5076    fn surfs(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5077        Ok(self
5078            .inner
5079            .lock()
5080            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5081            .surfs
5082            .iter()
5083            .map(|s| {
5084                let mut d = BTreeMap::new();
5085                d.insert("num".to_string(), s.num.to_string());
5086                d.insert("reflecting".to_string(), s.reflecting.to_string());
5087                d.insert(
5088                    "transform".to_string(),
5089                    s.transform.map(|v| v.to_string()).unwrap_or_default(),
5090                );
5091                d.insert(
5092                    "periodic".to_string(),
5093                    s.periodic.map(|v| v.to_string()).unwrap_or_default(),
5094                );
5095                d.insert("kind".to_string(), s.kind.keyword().to_string());
5096                d.insert(
5097                    "coeffs".to_string(),
5098                    s.coeffs
5099                        .iter()
5100                        .map(|v| v.to_string())
5101                        .collect::<Vec<_>>()
5102                        .join(" "),
5103                );
5104                d
5105            })
5106            .collect())
5107    }
5108
5109    /// Material numbers in file order.
5110    #[getter]
5111    fn material_numbers(&self) -> PyResult<Vec<u32>> {
5112        Ok(self
5113            .inner
5114            .lock()
5115            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5116            .materials
5117            .iter()
5118            .map(|m| m.number)
5119            .collect())
5120    }
5121
5122    /// Data-card names in file order (`MODE`, `M1`, `KCODE`, ...).
5123    #[getter]
5124    fn data_names(&self) -> PyResult<Vec<String>> {
5125        Ok(self
5126            .inner
5127            .lock()
5128            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5129            .data
5130            .iter()
5131            .map(|d| d.name.clone())
5132            .collect())
5133    }
5134
5135    /// Serialize back to MCNP input text (byte-identical when unedited).
5136    fn dumps(&self) -> PyResult<String> {
5137        let guard = self
5138            .inner
5139            .lock()
5140            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
5141        Ok(nucleide_mcnp_io::problem::write_deck(&guard))
5142    }
5143
5144    /// Set a cell's density (re-renders that card canonically).
5145    fn set_cell_density(&self, cell: u32, dens: f64) -> PyResult<()> {
5146        self.inner
5147            .lock()
5148            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5149            .set_cell_density(cell, dens)
5150            .map_err(|e| PyValueError::new_err(e.to_string()))
5151    }
5152
5153    /// Set a cell's material number (re-renders that card canonically).
5154    fn set_cell_material(&self, cell: u32, mat: u32) -> PyResult<()> {
5155        self.inner
5156            .lock()
5157            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5158            .set_cell_material(cell, mat)
5159            .map_err(|e| PyValueError::new_err(e.to_string()))
5160    }
5161
5162    /// Typed `MODE` card as `{particles}` (`particles` is space-joined).
5163    #[getter]
5164    fn mode(&self) -> PyResult<BTreeMap<String, String>> {
5165        let mode = self
5166            .inner
5167            .lock()
5168            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5169            .mode()
5170            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5171        let mut d = BTreeMap::new();
5172        d.insert("particles".to_string(), mode.particles.join(" "));
5173        Ok(d)
5174    }
5175
5176    /// Typed `TRn` cards as `{number, displacement, rotation, in_degrees,
5177    /// main_to_aux, hidden}` dicts (vectors are space-joined).
5178    #[getter]
5179    fn transforms(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5180        let transforms = self
5181            .inner
5182            .lock()
5183            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5184            .transforms()
5185            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5186        Ok(transforms
5187            .iter()
5188            .map(|t| {
5189                let mut d = BTreeMap::new();
5190                d.insert("number".to_string(), t.number.to_string());
5191                d.insert(
5192                    "displacement".to_string(),
5193                    t.displacement
5194                        .iter()
5195                        .map(|v| v.to_string())
5196                        .collect::<Vec<_>>()
5197                        .join(" "),
5198                );
5199                d.insert(
5200                    "rotation".to_string(),
5201                    t.rotation
5202                        .iter()
5203                        .map(|v| v.to_string())
5204                        .collect::<Vec<_>>()
5205                        .join(" "),
5206                );
5207                d.insert("in_degrees".to_string(), t.is_in_degrees.to_string());
5208                d.insert("main_to_aux".to_string(), t.is_main_to_aux.to_string());
5209                d.insert("hidden".to_string(), t.hidden.to_string());
5210                d
5211            })
5212            .collect())
5213    }
5214
5215    /// Auto-created universes as `{number, cells, not_truncated}` dicts
5216    /// (cell lists are space-joined).
5217    #[getter]
5218    fn universes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5219        let universes = self
5220            .inner
5221            .lock()
5222            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5223            .universes()
5224            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5225        Ok(universes
5226            .iter()
5227            .map(|u| {
5228                let mut d = BTreeMap::new();
5229                d.insert("number".to_string(), u.number.to_string());
5230                d.insert(
5231                    "cells".to_string(),
5232                    u.cells
5233                        .iter()
5234                        .map(|v| v.to_string())
5235                        .collect::<Vec<_>>()
5236                        .join(" "),
5237                );
5238                d.insert(
5239                    "not_truncated".to_string(),
5240                    u.not_truncated
5241                        .iter()
5242                        .map(|v| v.to_string())
5243                        .collect::<Vec<_>>()
5244                        .join(" "),
5245                );
5246                d
5247            })
5248            .collect())
5249    }
5250
5251    /// Cell `LAT` assignments as `{cell, lattice}` dicts.
5252    #[getter]
5253    fn lattices(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5254        let lattices = self
5255            .inner
5256            .lock()
5257            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5258            .lattices()
5259            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5260        Ok(lattices
5261            .iter()
5262            .map(|l| {
5263                let mut d = BTreeMap::new();
5264                d.insert("cell".to_string(), l.cell.to_string());
5265                d.insert("lattice".to_string(), l.lattice.to_string());
5266                d
5267            })
5268            .collect())
5269    }
5270
5271    /// Cell `FILL` assignments as `{cell, kind, universe, min_index,
5272    /// max_index, universes, transform, hidden_transform, in_degrees}` dicts
5273    /// (`kind` is `single` or `matrix`; matrix empties render as `-`).
5274    #[getter]
5275    fn fills(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5276        use nucleide_mcnp_io::semantic::{FillTarget, FillTransform};
5277        let fills = self
5278            .inner
5279            .lock()
5280            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5281            .fills()
5282            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5283        Ok(fills
5284            .iter()
5285            .map(|f| {
5286                let mut d = BTreeMap::new();
5287                d.insert("cell".to_string(), f.cell.to_string());
5288                match &f.target {
5289                    FillTarget::Single(u) => {
5290                        d.insert("kind".to_string(), "single".to_string());
5291                        d.insert("universe".to_string(), u.to_string());
5292                        d.insert("min_index".to_string(), String::new());
5293                        d.insert("max_index".to_string(), String::new());
5294                        d.insert("universes".to_string(), String::new());
5295                    }
5296                    FillTarget::Matrix {
5297                        min_index,
5298                        max_index,
5299                        universes,
5300                    } => {
5301                        d.insert("kind".to_string(), "matrix".to_string());
5302                        d.insert("universe".to_string(), String::new());
5303                        d.insert(
5304                            "min_index".to_string(),
5305                            min_index
5306                                .iter()
5307                                .map(|v| v.to_string())
5308                                .collect::<Vec<_>>()
5309                                .join(" "),
5310                        );
5311                        d.insert(
5312                            "max_index".to_string(),
5313                            max_index
5314                                .iter()
5315                                .map(|v| v.to_string())
5316                                .collect::<Vec<_>>()
5317                                .join(" "),
5318                        );
5319                        d.insert(
5320                            "universes".to_string(),
5321                            universes
5322                                .iter()
5323                                .map(|u| {
5324                                    u.map(|v| v.to_string()).unwrap_or_else(|| "-".to_string())
5325                                })
5326                                .collect::<Vec<_>>()
5327                                .join(" "),
5328                        );
5329                    }
5330                }
5331                match &f.transform {
5332                    None => {
5333                        d.insert("transform".to_string(), String::new());
5334                        d.insert("hidden_transform".to_string(), String::new());
5335                    }
5336                    Some(FillTransform::Reference(n)) => {
5337                        d.insert("transform".to_string(), n.to_string());
5338                        d.insert("hidden_transform".to_string(), String::new());
5339                    }
5340                    Some(FillTransform::Hidden(t)) => {
5341                        d.insert("transform".to_string(), String::new());
5342                        let mut coords: Vec<String> =
5343                            t.displacement.iter().map(|v| v.to_string()).collect();
5344                        coords.extend(t.rotation.iter().map(|v| v.to_string()));
5345                        d.insert("hidden_transform".to_string(), coords.join(" "));
5346                    }
5347                }
5348                d.insert("in_degrees".to_string(), f.in_degrees.to_string());
5349                d
5350            })
5351            .collect())
5352    }
5353
5354    /// Cell importance entries as `{cell, particle, value}` dicts.
5355    #[getter]
5356    fn importances(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5357        let importances = self
5358            .inner
5359            .lock()
5360            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5361            .importances()
5362            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5363        Ok(importances
5364            .iter()
5365            .map(|v| {
5366                let mut d = BTreeMap::new();
5367                d.insert("cell".to_string(), v.cell.to_string());
5368                d.insert("particle".to_string(), v.particle.clone());
5369                d.insert("value".to_string(), v.value.to_string());
5370                d
5371            })
5372            .collect())
5373    }
5374
5375    /// Manual cell volumes as `{cell, volume}` dicts.
5376    #[getter]
5377    fn volumes(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5378        let volumes = self
5379            .inner
5380            .lock()
5381            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5382            .volumes()
5383            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5384        Ok(volumes
5385            .iter()
5386            .map(|v| {
5387                let mut d = BTreeMap::new();
5388                d.insert("cell".to_string(), v.cell.to_string());
5389                d.insert("volume".to_string(), v.volume.to_string());
5390                d
5391            })
5392            .collect())
5393    }
5394
5395    /// Typed tallies as `{number, type, particles, entries, fm, e_bins}` dicts
5396    /// (lists are space-joined, absent groups are `""`).
5397    #[getter]
5398    fn tallies(&self) -> PyResult<Vec<BTreeMap<String, String>>> {
5399        let tallies = self
5400            .inner
5401            .lock()
5402            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5403            .tallies()
5404            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5405        Ok(tallies
5406            .iter()
5407            .map(|t| {
5408                let mut d = BTreeMap::new();
5409                d.insert("number".to_string(), t.number.to_string());
5410                d.insert("type".to_string(), t.tally_type.to_string());
5411                d.insert("particles".to_string(), t.particles.join(","));
5412                d.insert("entries".to_string(), t.entries.join(" "));
5413                d.insert("fm".to_string(), t.fm.clone().unwrap_or_default().join(" "));
5414                d.insert(
5415                    "e_bins".to_string(),
5416                    t.e_bins.clone().unwrap_or_default().join(" "),
5417                );
5418                d
5419            })
5420            .collect())
5421    }
5422
5423    /// Typed `SDEF` fixed-source card as a dict (`None` when the deck has no
5424    /// `SDEF` card). See [`parse_sdef`] for the dict shape.
5425    #[getter]
5426    fn sdef(&self, py: Python<'_>) -> PyResult<Option<Py<PyAny>>> {
5427        let guard = self
5428            .inner
5429            .lock()
5430            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?;
5431        let sdef = guard
5432            .sdef()
5433            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5434        sdef.map(|s| sdef_to_py(py, &s)).transpose()
5435    }
5436
5437    /// Validate every L3 semantic rule (duplicate numbers, dangling links,
5438    /// redundant definitions, write-time state, lattice/fill cross-checks).
5439    fn validate(&self) -> PyResult<()> {
5440        self.inner
5441            .lock()
5442            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5443            .validate()
5444            .map_err(|e| PyValueError::new_err(e.to_string()))
5445    }
5446
5447    /// Non-fatal validation notes (particle/mode mismatches).
5448    fn validation_notes(&self) -> PyResult<Vec<String>> {
5449        Ok(self
5450            .inner
5451            .lock()
5452            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5453            .validation_notes())
5454    }
5455
5456    /// Set the `MODE` card particles (re-renders that card canonically).
5457    fn set_mode(&self, particles: Vec<String>) -> PyResult<()> {
5458        self.inner
5459            .lock()
5460            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5461            .set_mode(particles)
5462            .map_err(|e| PyValueError::new_err(e.to_string()))
5463    }
5464
5465    /// Set a cell's universe (`not_truncated` writes `U=-n`).
5466    fn set_cell_universe(&self, cell: u32, universe: u32, not_truncated: bool) -> PyResult<()> {
5467        self.inner
5468            .lock()
5469            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5470            .set_cell_universe(cell, universe, not_truncated)
5471            .map_err(|e| PyValueError::new_err(e.to_string()))
5472    }
5473
5474    /// Set (`1`/`2`) or clear (`None`) a cell's lattice.
5475    fn set_cell_lattice(&self, cell: u32, lattice: Option<u8>) -> PyResult<()> {
5476        self.inner
5477            .lock()
5478            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5479            .set_cell_lattice(cell, lattice)
5480            .map_err(|e| PyValueError::new_err(e.to_string()))
5481    }
5482
5483    /// Set a cell's fill to a single universe.
5484    fn set_cell_fill(&self, cell: u32, universe: u32) -> PyResult<()> {
5485        self.inner
5486            .lock()
5487            .map_err(|_| PyValueError::new_err("deck lock poisoned"))?
5488            .set_cell_fill(cell, universe)
5489            .map_err(|e| PyValueError::new_err(e.to_string()))
5490    }
5491}
5492
5493/// Parse an MCNP input deck file into a [`PyDeckProblem`].
5494#[pyfunction]
5495fn read_deck(path: &str) -> PyResult<PyDeckProblem> {
5496    nucleide_mcnp_io::problem::parse_deck_file(path)
5497        .map(|inner| PyDeckProblem {
5498            inner: std::sync::Mutex::new(inner),
5499        })
5500        .map_err(|e| PyValueError::new_err(e.to_string()))
5501}
5502
5503/// Parse MCNP input deck text into a [`PyDeckProblem`].
5504#[pyfunction]
5505fn parse_deck(text: &str) -> PyResult<PyDeckProblem> {
5506    PyDeckProblem::loads(text)
5507}
5508
5509/// Render a typed `SDEF` source as a Python dict.
5510///
5511/// Shape: `{pos, cell, surf, vec, dir, erg, nrm, par, wgt, tme}` (canonical
5512/// strings, `""` when absent; `Dn` references render as `D<n>`),
5513/// `ignored` (verbatim out-of-subset keyword tokens), `distributions`
5514/// (`[{number, si_option, si, sp_option, sp, sb_option, sb}]`, value lists
5515/// space-joined, absent `SPn`/`SBn` groups render as `""`), and `card` (the
5516/// canonical re-emission, so `parse_sdef(d["card"])["card"] == d["card"]`).
5517fn sdef_to_py(py: Python<'_>, sdef: &nucleide_mcnp_io::sdef::SdefProblem) -> PyResult<Py<PyAny>> {
5518    use pyo3::types::PyDict;
5519    let opt3 = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<[f64; 3]>>| {
5520        v.as_ref().map(|r| r.render()).unwrap_or_default()
5521    };
5522    let opt1 = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<f64>>| {
5523        v.as_ref().map(|r| r.render()).unwrap_or_default()
5524    };
5525    let optu = |v: &Option<nucleide_mcnp_io::sdef::SdefRef<u32>>| {
5526        v.as_ref().map(|r| r.render()).unwrap_or_default()
5527    };
5528    let d = PyDict::new(py);
5529    d.set_item("pos", opt3(&sdef.card.pos))?;
5530    d.set_item("cell", optu(&sdef.card.cell))?;
5531    d.set_item("surf", optu(&sdef.card.surf))?;
5532    d.set_item("vec", opt3(&sdef.card.vec))?;
5533    d.set_item("dir", opt1(&sdef.card.dir))?;
5534    d.set_item("axs", opt3(&sdef.card.axs))?;
5535    d.set_item("rad", opt1(&sdef.card.rad))?;
5536    d.set_item("ext", opt1(&sdef.card.ext))?;
5537    d.set_item("erg", opt1(&sdef.card.erg))?;
5538    d.set_item("nrm", opt1(&sdef.card.nrm))?;
5539    d.set_item(
5540        "par",
5541        sdef.card
5542            .par
5543            .as_ref()
5544            .map(|r| r.render())
5545            .unwrap_or_default(),
5546    )?;
5547    d.set_item("wgt", opt1(&sdef.card.wgt))?;
5548    d.set_item("tme", opt1(&sdef.card.tme))?;
5549    d.set_item("ignored", sdef.card.ignored.clone())?;
5550    let dists: Vec<Py<PyAny>> = sdef
5551        .dists
5552        .iter()
5553        .map(|dist| {
5554            let m = PyDict::new(py);
5555            m.set_item("number", dist.number.to_string())?;
5556            m.set_item("si_option", "L")?;
5557            m.set_item("si", dist.si_text())?;
5558            m.set_item(
5559                "sp_option",
5560                dist.sp.as_ref().map(|_| "D").unwrap_or_default(),
5561            )?;
5562            m.set_item("sp", dist.sp_text())?;
5563            m.set_item(
5564                "sb_option",
5565                dist.sb.as_ref().map(|_| "D").unwrap_or_default(),
5566            )?;
5567            m.set_item("sb", dist.sb_text())?;
5568            Ok(m.into_any().unbind())
5569        })
5570        .collect::<PyResult<Vec<_>>>()?;
5571    d.set_item("distributions", dists)?;
5572    d.set_item("card", sdef.emit())?;
5573    Ok(d.into_any().unbind())
5574}
5575
5576/// Parse standalone `SDEF` card text (plus `SI`/`SP`/`SB` cards, e.g. the
5577/// decay-source emitter's output) into the [`sdef_to_py`] dict shape.
5578/// Raises `ValueError` when no `SDEF` card is present or any validation rule
5579/// fails (duplicate cards, non-discrete distribution forms, dangling `Dn`
5580/// references, orphan `SPn`/`SBn` cards, entry-count mismatches).
5581#[pyfunction]
5582fn parse_sdef(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
5583    let sdef = nucleide_mcnp_io::sdef::parse_sdef_text(text)
5584        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5585    sdef_to_py(py, &sdef)
5586}
5587
5588/// Translate one deck's CSG to OpenMC `geometry.xml`.
5589///
5590/// Returns `(xml, drift)` where `drift` is `[{scope, target, action,
5591/// reason}]` (all strings; `target` is the cell/surface number, `"0"` for
5592/// deck scope). Scoped v1: surfaces, cells, and a material stub only;
5593/// transforms, universes, tallies, and sources raise `ValueError`.
5594fn csg_to_openmc_inner(
5595    deck: &nucleide_mcnp_io::problem::DeckProblem,
5596) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5597    let (xml, table) = nucleide_csg_xlate::deck_csg_to_openmc_xml(deck)
5598        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5599    Ok((
5600        xml,
5601        table
5602            .entries
5603            .into_iter()
5604            .map(|e| {
5605                let mut d = BTreeMap::new();
5606                d.insert("scope".to_string(), e.scope.to_string());
5607                d.insert("target".to_string(), e.target.to_string());
5608                d.insert("action".to_string(), e.action);
5609                d.insert("reason".to_string(), e.reason);
5610                d
5611            })
5612            .collect(),
5613    ))
5614}
5615
5616/// Translate MCNP deck text to OpenMC `geometry.xml` plus drift report.
5617/// See [`csg_to_openmc_inner`].
5618#[pyfunction]
5619fn parse_csg_to_openmc(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5620    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5621        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5622    csg_to_openmc_inner(&deck)
5623}
5624
5625/// Translate an MCNP deck file to OpenMC `geometry.xml` plus drift report.
5626/// See [`csg_to_openmc_inner`].
5627#[pyfunction]
5628fn read_csg_to_openmc(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5629    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5630        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5631    csg_to_openmc_inner(&deck)
5632}
5633
5634/// Translate one deck's CSG to Serpent input (`surf`/`cell` cards).
5635///
5636/// Returns `(text, drift)` with the same drift shape as
5637/// [`csg_to_openmc_inner`]. Same v2 scope (surfaces, cells, nested
5638/// universes, material-name stub); reflecting and periodic boundaries
5639/// raise `ValueError` (no verified Serpent mapping).
5640fn csg_to_serpent_inner(
5641    deck: &nucleide_mcnp_io::problem::DeckProblem,
5642) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5643    let (text, table) = nucleide_csg_xlate::deck_csg_to_serpent_input(deck)
5644        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5645    Ok((
5646        text,
5647        table
5648            .entries
5649            .into_iter()
5650            .map(|e| {
5651                let mut d = BTreeMap::new();
5652                d.insert("scope".to_string(), e.scope.to_string());
5653                d.insert("target".to_string(), e.target.to_string());
5654                d.insert("action".to_string(), e.action);
5655                d.insert("reason".to_string(), e.reason);
5656                d
5657            })
5658            .collect(),
5659    ))
5660}
5661
5662/// Translate MCNP deck text to Serpent input plus drift report.
5663/// See [`csg_to_serpent_inner`].
5664#[pyfunction]
5665fn parse_csg_to_serpent(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5666    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5667        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5668    csg_to_serpent_inner(&deck)
5669}
5670
5671/// Translate an MCNP deck file to Serpent input plus drift report.
5672/// See [`csg_to_serpent_inner`].
5673#[pyfunction]
5674fn read_csg_to_serpent(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5675    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5676        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5677    csg_to_serpent_inner(&deck)
5678}
5679
5680/// Translate one deck's CSG to PHITS `[Surface]`/`[Cell]` sections.
5681///
5682/// Returns `(text, drift)` with the same drift shape as
5683/// [`csg_to_openmc_inner`]. Same v2 scope with PHITS-native spellings
5684/// (verbatim surface symbols, native `#` complement, `U=`/`FILL=` params,
5685/// `*` reflective surfaces, outer-void `-1` heuristic); periodic pointers
5686/// raise `ValueError` (no PHITS spelling).
5687fn csg_to_phits_inner(
5688    deck: &nucleide_mcnp_io::problem::DeckProblem,
5689) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5690    let (text, table) = nucleide_csg_xlate::deck_csg_to_phits_input(deck)
5691        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5692    Ok((
5693        text,
5694        table
5695            .entries
5696            .into_iter()
5697            .map(|e| {
5698                let mut d = BTreeMap::new();
5699                d.insert("scope".to_string(), e.scope.to_string());
5700                d.insert("target".to_string(), e.target.to_string());
5701                d.insert("action".to_string(), e.action);
5702                d.insert("reason".to_string(), e.reason);
5703                d
5704            })
5705            .collect(),
5706    ))
5707}
5708
5709/// Translate MCNP deck text to PHITS sections plus drift report.
5710/// See [`csg_to_phits_inner`].
5711#[pyfunction]
5712fn parse_csg_to_phits(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5713    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5714        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5715    csg_to_phits_inner(&deck)
5716}
5717
5718/// Translate an MCNP deck file to PHITS sections plus drift report.
5719/// See [`csg_to_phits_inner`].
5720#[pyfunction]
5721fn read_csg_to_phits(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5722    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5723        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5724    csg_to_phits_inner(&deck)
5725}
5726
5727/// Translate one deck's CSG to a GDML (Geant4) document.
5728///
5729/// Returns `(xml, drift)` with the same drift shape as
5730/// [`csg_to_openmc_inner`]. Same v3 scope: surfaces, cells, nested
5731/// universes (as `<assembly>` volumes), rectangular `LAT=1` lattices
5732/// (expanded to per-element placements), and `mat_<n>` material stubs the
5733/// caller replaces; reflecting and periodic boundaries raise `ValueError`
5734/// (no GDML spelling).
5735fn csg_to_gdml_inner(
5736    deck: &nucleide_mcnp_io::problem::DeckProblem,
5737) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5738    let (xml, table) = nucleide_csg_xlate::deck_csg_to_gdml(deck)
5739        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5740    Ok((
5741        xml,
5742        table
5743            .entries
5744            .into_iter()
5745            .map(|e| {
5746                let mut d = BTreeMap::new();
5747                d.insert("scope".to_string(), e.scope.to_string());
5748                d.insert("target".to_string(), e.target.to_string());
5749                d.insert("action".to_string(), e.action);
5750                d.insert("reason".to_string(), e.reason);
5751                d
5752            })
5753            .collect(),
5754    ))
5755}
5756
5757/// Translate MCNP deck text to a GDML document plus drift report.
5758/// See [`csg_to_gdml_inner`].
5759#[pyfunction]
5760fn parse_csg_to_gdml(text: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5761    let deck = nucleide_mcnp_io::problem::parse_deck(text)
5762        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5763    csg_to_gdml_inner(&deck)
5764}
5765
5766/// Translate an MCNP deck file to a GDML document plus drift report.
5767/// See [`csg_to_gdml_inner`].
5768#[pyfunction]
5769fn read_csg_to_gdml(path: &str) -> PyResult<(String, Vec<BTreeMap<String, String>>)> {
5770    let deck = nucleide_mcnp_io::problem::parse_deck_file(path)
5771        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5772    csg_to_gdml_inner(&deck)
5773}
5774
5775/// A unit-aware decay inventory over a depletion chain.
5776#[pyclass(name = "Inventory")]
5777struct PyInventory {
5778    chain: std::sync::Arc<nucleide_depletion::Chain>,
5779    atoms: BTreeMap<String, f64>,
5780}
5781
5782fn inventory_sys(
5783    chain: &nucleide_depletion::Chain,
5784    rates: &RateMap,
5785) -> PyResult<nucleide_depletion::DepletionSystem> {
5786    let rs = split_rates(rates, chain)?;
5787    nucleide_depletion::DepletionSystem::build(chain.clone(), &rs)
5788        .map_err(|e| PyValueError::new_err(e.to_string()))
5789}
5790
5791fn parse_quantity_unit(unit: &str) -> PyResult<nucleide_depletion::QuantityUnit> {
5792    nucleide_depletion::QuantityUnit::from_str(unit)
5793        .map_err(|e| PyValueError::new_err(format!("{e:?}")))
5794}
5795
5796#[pymethods]
5797impl PyInventory {
5798    /// Build from quantities in `units` (atom counts, `Bq`/`Ci` activity,
5799    /// `g`/`kg` mass, `mol`, ... — see `QuantityUnit`).
5800    #[new]
5801    #[pyo3(signature = (chain, comp, units="atoms"))]
5802    fn new(chain: &PyChain, comp: BTreeMap<String, f64>, units: &str) -> PyResult<Self> {
5803        let unit = parse_quantity_unit(units)?;
5804        let sys = inventory_sys(&chain.inner, &BTreeMap::new())?;
5805        let inv = nucleide_depletion::DecayInventory::from_units(&comp, unit, &sys)
5806            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5807        Ok(Self {
5808            chain: chain.inner.clone(),
5809            atoms: inv.atoms,
5810        })
5811    }
5812
5813    /// Atom counts by nuclide name.
5814    fn numbers(&self) -> BTreeMap<String, f64> {
5815        self.atoms.clone()
5816    }
5817
5818    /// Decay over `dt` in `time_unit` (`s`, `m`, `h`, `d`, `y`); optional
5819    /// one-group `rates` (`"Name:reaction"` keys), CRAM `order`, and solver
5820    /// `method` (`"cram16"`, `"cram48"`, `"bateman"`, `"bateman_hp"`,
5821    /// default `"cram48"` — an explicitly non-default `method` overrides
5822    /// `order`). Unlike the decay-only core inventory, this honors `rates`;
5823    /// a Bateman `method` with live rates falls back to CRAM-48.
5824    #[pyo3(signature = (dt, time_unit="s", rates=None, order=48, method="cram48"))]
5825    fn decay(
5826        &self,
5827        dt: f64,
5828        time_unit: &str,
5829        rates: Option<RateMap>,
5830        order: u8,
5831        method: &str,
5832    ) -> PyResult<Self> {
5833        let method = resolve_method(order, method)?;
5834        let unit = nucleide_depletion::inventory::time_unit_from_str(time_unit)
5835            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5836        let seconds = dt * unit.as_seconds();
5837        let empty = BTreeMap::new();
5838        let step_rates = rates.as_ref().unwrap_or(&empty);
5839        let template = inventory_sys(&self.chain, step_rates)?;
5840        // Route through the core series: predictor over one step equals the
5841        // single-kernel solve, and rates/method stay honored.
5842        let steps = vec![nucleide_depletion::Step::new(
5843            seconds,
5844            split_rates(step_rates, &self.chain)?,
5845        )];
5846        let series = nucleide_depletion::integrate_with_method(
5847            &template,
5848            &chain_vec(&self.chain, &self.atoms)?,
5849            &steps,
5850            nucleide_depletion::Integrator::Predictor,
5851            method,
5852        )
5853        .map_err(|e| PyValueError::new_err(e.to_string()))?;
5854        let names: Vec<String> = self.chain.nuclides.iter().map(|n| n.name.clone()).collect();
5855        let atoms = names
5856            .iter()
5857            .zip(series.atoms.last().cloned().unwrap_or_default())
5858            .map(|(n, v)| (n.clone(), v))
5859            .collect();
5860        Ok(Self {
5861            chain: self.chain.clone(),
5862            atoms,
5863        })
5864    }
5865
5866    /// Activity per nuclide in `units`.
5867    fn activities(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5868        let unit = parse_quantity_unit(units)?;
5869        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5870        let inv = nucleide_depletion::DecayInventory {
5871            atoms: self.atoms.clone(),
5872        };
5873        inv.activities(&sys, unit)
5874            .map_err(|e| PyValueError::new_err(e.to_string()))
5875    }
5876
5877    /// Mass per nuclide in `units`.
5878    fn masses(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5879        let unit = parse_quantity_unit(units)?;
5880        let inv = nucleide_depletion::DecayInventory {
5881            atoms: self.atoms.clone(),
5882        };
5883        inv.masses(unit)
5884            .map_err(|e| PyValueError::new_err(e.to_string()))
5885    }
5886
5887    /// Moles per nuclide in `units`.
5888    fn moles(&self, units: &str) -> PyResult<BTreeMap<String, f64>> {
5889        let unit = parse_quantity_unit(units)?;
5890        let inv = nucleide_depletion::DecayInventory {
5891            atoms: self.atoms.clone(),
5892        };
5893        inv.moles(unit)
5894            .map_err(|e| PyValueError::new_err(e.to_string()))
5895    }
5896
5897    /// Activity fractions by nuclide name.
5898    fn activity_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5899        let sys = inventory_sys(&self.chain, &BTreeMap::new())?;
5900        let inv = nucleide_depletion::DecayInventory {
5901            atoms: self.atoms.clone(),
5902        };
5903        inv.activity_fractions(&sys)
5904            .map_err(|e| PyValueError::new_err(e.to_string()))
5905    }
5906
5907    /// Mass fractions by nuclide name.
5908    fn mass_fractions(&self) -> PyResult<BTreeMap<String, f64>> {
5909        let inv = nucleide_depletion::DecayInventory {
5910            atoms: self.atoms.clone(),
5911        };
5912        inv.mass_fractions()
5913            .map_err(|e| PyValueError::new_err(e.to_string()))
5914    }
5915
5916    /// Mole fractions by nuclide name.
5917    fn mole_fractions(&self) -> BTreeMap<String, f64> {
5918        nucleide_depletion::DecayInventory {
5919            atoms: self.atoms.clone(),
5920        }
5921        .mole_fractions()
5922    }
5923
5924    /// Human-readable half-lives (`"3.2 d"`, `"stable"`, `"unknown"`).
5925    fn half_lives_readable(&self) -> BTreeMap<String, String> {
5926        nucleide_depletion::DecayInventory {
5927            atoms: self.atoms.clone(),
5928        }
5929        .half_lives_readable()
5930    }
5931
5932    /// Add two inventories (atom counts sum).
5933    fn add(&self, other: &Self) -> Self {
5934        let a = nucleide_depletion::DecayInventory {
5935            atoms: self.atoms.clone(),
5936        };
5937        let b = nucleide_depletion::DecayInventory {
5938            atoms: other.atoms.clone(),
5939        };
5940        Self {
5941            chain: self.chain.clone(),
5942            atoms: a.add(&b).atoms,
5943        }
5944    }
5945
5946    /// Subtract (clamped at zero).
5947    fn sub(&self, other: &Self) -> Self {
5948        let a = nucleide_depletion::DecayInventory {
5949            atoms: self.atoms.clone(),
5950        };
5951        let b = nucleide_depletion::DecayInventory {
5952            atoms: other.atoms.clone(),
5953        };
5954        Self {
5955            chain: self.chain.clone(),
5956            atoms: a.sub(&b).atoms,
5957        }
5958    }
5959
5960    /// Scale by a scalar.
5961    fn mul(&self, scalar: f64) -> Self {
5962        let a = nucleide_depletion::DecayInventory {
5963            atoms: self.atoms.clone(),
5964        };
5965        Self {
5966            chain: self.chain.clone(),
5967            atoms: a.mul(scalar).atoms,
5968        }
5969    }
5970
5971    /// Divide by a scalar.
5972    fn div(&self, scalar: f64) -> Self {
5973        let a = nucleide_depletion::DecayInventory {
5974            atoms: self.atoms.clone(),
5975        };
5976        Self {
5977            chain: self.chain.clone(),
5978            atoms: a.div(scalar).atoms,
5979        }
5980    }
5981
5982    /// Serialize as `nuclide,atoms` CSV rows.
5983    fn to_csv(&self) -> String {
5984        nucleide_depletion::DecayInventory {
5985            atoms: self.atoms.clone(),
5986        }
5987        .to_csv()
5988    }
5989
5990    /// Parse `to_csv` output back into an inventory over `chain`.
5991    #[staticmethod]
5992    fn from_csv(chain: &PyChain, text: &str) -> PyResult<Self> {
5993        // Validate names against the chain (core from_csv is chain-free).
5994        let inv = nucleide_depletion::DecayInventory::from_csv(text)
5995            .map_err(|e| PyValueError::new_err(e.to_string()))?;
5996        for name in inv.atoms.keys() {
5997            if chain.inner.index_of(name).is_none() {
5998                return Err(PyValueError::new_err(format!(
5999                    "unknown nuclide `{name}` for this chain"
6000                )));
6001            }
6002        }
6003        Ok(Self {
6004            chain: chain.inner.clone(),
6005            atoms: inv.atoms,
6006        })
6007    }
6008}
6009
6010/// Atom vector in chain order for an inventory map (unknown names error).
6011fn chain_vec(
6012    chain: &nucleide_depletion::Chain,
6013    atoms: &BTreeMap<String, f64>,
6014) -> PyResult<Vec<f64>> {
6015    let mut vec = vec![0.0; chain.len()];
6016    for (name, value) in atoms {
6017        let idx = chain.index_of(name).ok_or_else(|| {
6018            PyValueError::new_err(format!("unknown nuclide `{name}` for this chain"))
6019        })?;
6020        vec[idx] = *value;
6021    }
6022    Ok(vec)
6023}
6024
6025/// Time-integrated decays per nuclide over one step (chain order → names).
6026#[pyfunction]
6027#[pyo3(signature = (chain, n0, dt, rates=None))]
6028fn cumulative_decays(
6029    chain: &PyChain,
6030    n0: BTreeMap<String, f64>,
6031    dt: f64,
6032    rates: Option<RateMap>,
6033) -> PyResult<BTreeMap<String, f64>> {
6034    let empty = BTreeMap::new();
6035    let sys = inventory_sys(&chain.inner, rates.as_ref().unwrap_or(&empty))?;
6036    let vec = chain_vec(&chain.inner, &n0)?;
6037    let out = nucleide_depletion::cumulative_decays(&sys, &vec, dt)
6038        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6039    Ok(chain
6040        .inner
6041        .nuclides
6042        .iter()
6043        .zip(out)
6044        .map(|(nuc, v)| (nuc.name.clone(), v))
6045        .collect())
6046}
6047
6048/// `(child, branching_ratio, decay_mode)` triples for a chain nuclide.
6049#[pyfunction]
6050fn progeny(chain: &PyChain, name: &str) -> Vec<(String, f64, String)> {
6051    nucleide_depletion::progeny(&chain.inner, name)
6052}
6053
6054/// Branching fraction from parent to child, if the decay exists.
6055#[pyfunction]
6056fn branching_fraction(chain: &PyChain, parent: &str, child: &str) -> Option<f64> {
6057    nucleide_depletion::branching_fraction(&chain.inner, parent, child)
6058}
6059
6060/// Decay-mode label from parent to child, if the decay exists.
6061#[pyfunction]
6062fn decay_mode(chain: &PyChain, parent: &str, child: &str) -> Option<String> {
6063    nucleide_depletion::decay_mode(&chain.inner, parent, child)
6064}
6065
6066/// `(parent, child, branching_ratio, decay_mode)` edges of a chain.
6067#[pyfunction]
6068fn chain_edges(chain: &PyChain) -> Vec<(String, String, f64, String)> {
6069    nucleide_depletion::chain_edges(&chain.inner)
6070}
6071
6072/// Parse an ARMI nuclide label (`nU235`, `92235`, ...) into a [`PyNuclide`].
6073#[pyfunction]
6074fn armi_to_nucid(name: &str) -> PyResult<PyNuclide> {
6075    nucleide_nuclei::armi::armi_name_to_nucid(name)
6076        .map(|inner| PyNuclide { inner })
6077        .map_err(|e| PyValueError::new_err(e.to_string()))
6078}
6079
6080/// Render a nuclide in ARMI database-label form.
6081#[pyfunction]
6082fn nucid_to_armi(nuclide: &PyNuclide) -> String {
6083    nucleide_nuclei::armi::nucid_to_armi_label(nuclide.inner)
6084}
6085
6086/// Parse an MCC3-style nuclide label into a [`PyNuclide`].
6087#[pyfunction]
6088fn mcc3_to_nucid(name: &str) -> PyResult<PyNuclide> {
6089    nucleide_nuclei::armi::mcc3_to_nucid(name)
6090        .map(|inner| PyNuclide { inner })
6091        .map_err(|e| PyValueError::new_err(e.to_string()))
6092}
6093
6094/// Truncated-label collisions in a composition at DIF3D/MC2 widths.
6095///
6096/// `comp` maps nuclide names to grams; `widths` defaults to `[6, 8]`.
6097/// Returns `[{truncated, width, members}]`.
6098#[pyfunction]
6099#[pyo3(signature = (comp, widths=None))]
6100fn check_labels(
6101    comp: BTreeMap<String, f64>,
6102    widths: Option<Vec<usize>>,
6103) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6104    let mat = comp_to_material(comp)?;
6105    let widths = widths.unwrap_or_else(|| nucleide_material::DEFAULT_WIDTHS.to_vec());
6106    let collisions = nucleide_material::check_labels(&mat, &widths);
6107    Python::attach(|py| {
6108        Ok(collisions
6109            .into_iter()
6110            .map(|c| {
6111                let mut d = BTreeMap::new();
6112                d.insert(
6113                    "truncated".to_string(),
6114                    c.truncated.into_pyobject(py).unwrap().unbind().into_any(),
6115                );
6116                d.insert(
6117                    "width".to_string(),
6118                    c.width.into_pyobject(py).unwrap().unbind().into_any(),
6119                );
6120                let members: Vec<String> = c.members.iter().map(|id| id.to_name()).collect();
6121                d.insert(
6122                    "members".to_string(),
6123                    members.into_pyobject(py).unwrap().unbind().into_any(),
6124                );
6125                d
6126            })
6127            .collect())
6128    })
6129}
6130
6131/// Conservation audit of a composition: `[{kind, detail}]` (empty = clean).
6132#[pyfunction]
6133fn audit_material(comp: BTreeMap<String, f64>) -> PyResult<Vec<BTreeMap<String, String>>> {
6134    let mat = comp_to_material(comp)?;
6135    Ok(nucleide_material::audit(&mat, &nucleide_material::Ame2020)
6136        .into_iter()
6137        .map(|issue| {
6138            let mut d = BTreeMap::new();
6139            d.insert("kind".to_string(), format!("{:?}", issue.kind));
6140            d.insert("detail".to_string(), issue.detail);
6141            d
6142        })
6143        .collect())
6144}
6145
6146/// Emit one composition through all five code dialects (MCNP, Serpent, FLUKA,
6147/// ALARA, PARTISN). Returns `{code: card_text}`.
6148///
6149/// `comp` maps nuclide names to grams; `density` is mass density [g/cm³] for
6150/// dialects that need one (falls back to none — Serpent/FLUKA/PARTISN error
6151/// without it).
6152#[pyfunction]
6153#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6154#[allow(clippy::too_many_arguments)]
6155fn emit_cards(
6156    comp: BTreeMap<String, f64>,
6157    name: &str,
6158    density: Option<f64>,
6159    mcnp_number: u32,
6160    xs_suffix: &str,
6161    serpent_lib: &str,
6162    fluka_fid: u32,
6163    partisn_zone: u32,
6164) -> PyResult<BTreeMap<String, String>> {
6165    let (emitted, _) = emit_drift_inner(
6166        comp,
6167        name,
6168        density,
6169        mcnp_number,
6170        xs_suffix,
6171        serpent_lib,
6172        fluka_fid,
6173        partisn_zone,
6174    )?;
6175    Ok(emitted
6176        .into_iter()
6177        .map(|e| (e.code.to_string(), e.text))
6178        .collect())
6179}
6180
6181/// Mass-drift report for one composition across all five code dialects.
6182/// Returns `[{code, mass_in, mass_out, rel_drift, dropped: [{nuclide, mass,
6183/// reason}], reparsed}]`.
6184#[pyfunction]
6185#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6186#[allow(clippy::too_many_arguments)]
6187fn emit_drift_table(
6188    comp: BTreeMap<String, f64>,
6189    name: &str,
6190    density: Option<f64>,
6191    mcnp_number: u32,
6192    xs_suffix: &str,
6193    serpent_lib: &str,
6194    fluka_fid: u32,
6195    partisn_zone: u32,
6196) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6197    let (_, table) = emit_drift_inner(
6198        comp,
6199        name,
6200        density,
6201        mcnp_number,
6202        xs_suffix,
6203        serpent_lib,
6204        fluka_fid,
6205        partisn_zone,
6206    )?;
6207    drift_table_to_py(table)
6208}
6209
6210fn drift_table_to_py(
6211    table: nucleide_emit::DriftTable,
6212) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6213    Python::attach(|py| {
6214        Ok(table
6215            .rows
6216            .into_iter()
6217            .map(|r| {
6218                let mut d = BTreeMap::new();
6219                d.insert(
6220                    "code".to_string(),
6221                    r.code
6222                        .to_string()
6223                        .into_pyobject(py)
6224                        .unwrap()
6225                        .unbind()
6226                        .into_any(),
6227                );
6228                d.insert(
6229                    "mass_in".to_string(),
6230                    r.mass_in.into_pyobject(py).unwrap().unbind().into_any(),
6231                );
6232                d.insert(
6233                    "mass_out".to_string(),
6234                    r.mass_out.into_pyobject(py).unwrap().unbind().into_any(),
6235                );
6236                d.insert(
6237                    "rel_drift".to_string(),
6238                    r.rel_drift.into_pyobject(py).unwrap().unbind().into_any(),
6239                );
6240                let dropped: Vec<BTreeMap<String, Py<PyAny>>> = r
6241                    .dropped
6242                    .into_iter()
6243                    .map(|x| {
6244                        let mut dd = BTreeMap::new();
6245                        dd.insert(
6246                            "nuclide".to_string(),
6247                            x.id.to_name()
6248                                .into_pyobject(py)
6249                                .unwrap()
6250                                .unbind()
6251                                .into_any(),
6252                        );
6253                        dd.insert(
6254                            "mass".to_string(),
6255                            x.mass.into_pyobject(py).unwrap().unbind().into_any(),
6256                        );
6257                        dd.insert(
6258                            "reason".to_string(),
6259                            x.reason.into_pyobject(py).unwrap().unbind().into_any(),
6260                        );
6261                        dd
6262                    })
6263                    .collect();
6264                d.insert(
6265                    "dropped".to_string(),
6266                    dropped.into_pyobject(py).unwrap().unbind().into_any(),
6267                );
6268                d.insert(
6269                    "reparsed".to_string(),
6270                    pyo3::types::PyBool::new(py, r.reparsed)
6271                        .to_owned()
6272                        .into_any()
6273                        .unbind(),
6274                );
6275                d
6276            })
6277            .collect())
6278    })
6279}
6280
6281#[allow(clippy::too_many_arguments)]
6282fn emit_drift_inner(
6283    comp: BTreeMap<String, f64>,
6284    name: &str,
6285    density: Option<f64>,
6286    mcnp_number: u32,
6287    xs_suffix: &str,
6288    serpent_lib: &str,
6289    fluka_fid: u32,
6290    partisn_zone: u32,
6291) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6292    let mut mat = comp_to_material(comp)?;
6293    mat.set_density(density);
6294    emit_drift_with_mat(
6295        mat,
6296        name,
6297        mcnp_number,
6298        xs_suffix,
6299        serpent_lib,
6300        fluka_fid,
6301        partisn_zone,
6302    )
6303}
6304
6305#[allow(clippy::too_many_arguments)]
6306fn emit_drift_with_mat(
6307    mat: nucleide_material::Material,
6308    name: &str,
6309    mcnp_number: u32,
6310    xs_suffix: &str,
6311    serpent_lib: &str,
6312    fluka_fid: u32,
6313    partisn_zone: u32,
6314) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6315    let mut opts = nucleide_emit::EmitOptions::new(name);
6316    opts.mcnp_number = mcnp_number;
6317    opts.xs_suffix = xs_suffix.to_string();
6318    opts.serpent_lib = serpent_lib.to_string();
6319    opts.fluka_fid = fluka_fid;
6320    opts.partisn_zone = partisn_zone;
6321    nucleide_emit::emit_drift(&mat, &opts).map_err(|e| PyValueError::new_err(e.to_string()))
6322}
6323
6324#[allow(clippy::too_many_arguments)]
6325fn emit_armi_drift_inner(
6326    comp: BTreeMap<String, f64>,
6327    name: &str,
6328    density: Option<f64>,
6329    mcnp_number: u32,
6330    xs_suffix: &str,
6331    serpent_lib: &str,
6332    fluka_fid: u32,
6333    partisn_zone: u32,
6334) -> PyResult<(Vec<nucleide_emit::Emitted>, nucleide_emit::DriftTable)> {
6335    // `from_armi_mass_fracs` sets the density exactly like `emit_drift_inner`
6336    // (`set_density(density)`), so the material is emission-ready here.
6337    let mat = nucleide_emit::armi::from_armi_mass_fracs(comp, density)
6338        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6339    emit_drift_with_mat(
6340        mat,
6341        name,
6342        mcnp_number,
6343        xs_suffix,
6344        serpent_lib,
6345        fluka_fid,
6346        partisn_zone,
6347    )
6348}
6349
6350/// Emit one ARMI-keyed composition through all five code dialects (MCNP,
6351/// Serpent, FLUKA, ALARA, PARTISN). Returns `{code: card_text}`.
6352///
6353/// `comp` maps ARMI nuclide keys (`nU235`, `92235`, `U-2355`, ...) to grams;
6354/// keys resolve via `nucleide_emit::armi::from_armi_mass_fracs` (elemental
6355/// keys, bare `AM242`, and negative/non-finite masses are `ValueError`s).
6356/// `density` is the hot mass density [g/cm³] for dialects that need one.
6357#[pyfunction]
6358#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6359#[allow(clippy::too_many_arguments)]
6360fn emit_armi_cards(
6361    comp: BTreeMap<String, f64>,
6362    name: &str,
6363    density: Option<f64>,
6364    mcnp_number: u32,
6365    xs_suffix: &str,
6366    serpent_lib: &str,
6367    fluka_fid: u32,
6368    partisn_zone: u32,
6369) -> PyResult<BTreeMap<String, String>> {
6370    let (emitted, _) = emit_armi_drift_inner(
6371        comp,
6372        name,
6373        density,
6374        mcnp_number,
6375        xs_suffix,
6376        serpent_lib,
6377        fluka_fid,
6378        partisn_zone,
6379    )?;
6380    Ok(emitted
6381        .into_iter()
6382        .map(|e| (e.code.to_string(), e.text))
6383        .collect())
6384}
6385
6386/// Mass-drift report for one ARMI-keyed composition across all five code
6387/// dialects. Returns `[{code, mass_in, mass_out, rel_drift, dropped:
6388/// [{nuclide, mass, reason}], reparsed}]`.
6389#[pyfunction]
6390#[pyo3(signature = (comp, name, density=None, mcnp_number=1, xs_suffix="80c", serpent_lib="03c", fluka_fid=1, partisn_zone=1))]
6391#[allow(clippy::too_many_arguments)]
6392fn emit_armi_drift_table(
6393    comp: BTreeMap<String, f64>,
6394    name: &str,
6395    density: Option<f64>,
6396    mcnp_number: u32,
6397    xs_suffix: &str,
6398    serpent_lib: &str,
6399    fluka_fid: u32,
6400    partisn_zone: u32,
6401) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
6402    let (_, table) = emit_armi_drift_inner(
6403        comp,
6404        name,
6405        density,
6406        mcnp_number,
6407        xs_suffix,
6408        serpent_lib,
6409        fluka_fid,
6410        partisn_zone,
6411    )?;
6412    drift_table_to_py(table)
6413}
6414
6415// ---------------------------------------------------------------------------
6416// Point kinetics (thin glue over `nucleide-kinetics`; solver stays in core)
6417// ---------------------------------------------------------------------------
6418
6419/// Parse a reactivity-spec dict into the core [`Reactivity`].
6420///
6421/// `kind` selects the schedule (`"constant"`, `"step"`, `"impulse"`,
6422/// `"ramp"`, `"polyline"`); all reactivities are in Δk and all times in
6423/// seconds. Keys per kind: constant (`rho`); step (`t_step`, `rho_init`,
6424/// `rho_final`); impulse (`t_start`, `t_end`, `rho_init`, `rho_max`); ramp
6425/// (`t_start`, `t_end`, `rho_init`, `rho_rise`, `rho_final`); polyline
6426/// (`times`, `values`).
6427fn parse_reactivity(
6428    spec: &BTreeMap<String, Py<PyAny>>,
6429    py: Python<'_>,
6430) -> PyResult<nucleide_kinetics::Reactivity> {
6431    use nucleide_kinetics::Reactivity as R;
6432    let kind: String = get_str(spec, py, "kind", "reactivity spec needs a `kind`")?;
6433    let num = |key: &str| -> PyResult<f64> { get_num(spec, py, key) };
6434    let vec = |key: &str| -> PyResult<Vec<f64>> { get_vec(spec, py, key) };
6435    let r = match kind.as_str() {
6436        "constant" => R::Constant { rho: num("rho")? },
6437        "step" => R::Step {
6438            t_step: num("t_step")?,
6439            rho_init: num("rho_init")?,
6440            rho_final: num("rho_final")?,
6441        },
6442        "impulse" => R::Impulse {
6443            t_start: num("t_start")?,
6444            t_end: num("t_end")?,
6445            rho_init: num("rho_init")?,
6446            rho_max: num("rho_max")?,
6447        },
6448        "ramp" => R::Ramp {
6449            t_start: num("t_start")?,
6450            t_end: num("t_end")?,
6451            rho_init: num("rho_init")?,
6452            rho_rise: num("rho_rise")?,
6453            rho_final: num("rho_final")?,
6454        },
6455        "polyline" => R::Polyline {
6456            times: vec("times")?,
6457            values: vec("values")?,
6458        },
6459        other => {
6460            return Err(PyValueError::new_err(format!(
6461                "unknown reactivity kind `{other}` (supported: constant, step, impulse, ramp, polyline)"
6462            )))
6463        }
6464    };
6465    r.validate()
6466        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6467    Ok(r)
6468}
6469
6470fn get_str(
6471    spec: &BTreeMap<String, Py<PyAny>>,
6472    py: Python<'_>,
6473    key: &str,
6474    missing: &str,
6475) -> PyResult<String> {
6476    spec.get(key)
6477        .ok_or_else(|| PyValueError::new_err(missing.to_string()))?
6478        .extract::<String>(py)
6479        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a string")))
6480}
6481
6482fn get_num(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<f64> {
6483    spec.get(key)
6484        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
6485        .extract::<f64>(py)
6486        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6487}
6488
6489fn get_vec(spec: &BTreeMap<String, Py<PyAny>>, py: Python<'_>, key: &str) -> PyResult<Vec<f64>> {
6490    spec.get(key)
6491        .ok_or_else(|| PyValueError::new_err(format!("reactivity spec missing `{key}`")))?
6492        .extract::<Vec<f64>>(py)
6493        .map_err(|_| PyValueError::new_err(format!("`{key}` must be a list of numbers")))
6494}
6495
6496fn kinetics_params(
6497    betas: Vec<f64>,
6498    lambdas: Vec<f64>,
6499    lambda_gen: f64,
6500) -> PyResult<nucleide_kinetics::KineticParams> {
6501    nucleide_kinetics::KineticParams::new(betas, lambdas, lambda_gen)
6502        .map_err(|e| PyValueError::new_err(e.to_string()))
6503}
6504
6505/// Solve a prescribed-reactivity point-kinetics transient.
6506///
6507/// Thin wrapper over `nucleide_kinetics::solve`: `betas`/`lambdas`/`Lambda`
6508/// carry the delayed-neutron data (see `KineticParams::from_ifp` for the
6509/// OpenMC provenance note — decay constants are caller-supplied), `rho` is
6510/// a spec dict (see `parse_reactivity`), `t` the output grid in seconds,
6511/// `n0` the initial neutron level, `C0` the optional initial precursors
6512/// (defaults to equilibrium). `method` is `"trapezoidal"` (default) or
6513/// `"backward_euler"`. Returns a dict with `times`, `n`, `C`
6514/// (`[time][group]`), and the echo of the initial state (`n0`, `C0`).
6515#[pyfunction]
6516#[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))]
6517#[allow(clippy::too_many_arguments)]
6518fn kinetics_solve(
6519    py: Python<'_>,
6520    betas: Vec<f64>,
6521    lambdas: Vec<f64>,
6522    lambda_gen: f64,
6523    rho: BTreeMap<String, Py<PyAny>>,
6524    t: Vec<f64>,
6525    n0: f64,
6526    c0: Option<Vec<f64>>,
6527    method: &str,
6528    rtol: f64,
6529    atol: f64,
6530    dt_min: f64,
6531    dt_max: Option<f64>,
6532    max_steps: usize,
6533) -> PyResult<Py<PyAny>> {
6534    use nucleide_kinetics::{Method as M, SolverOptions};
6535    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6536    let rho = parse_reactivity(&rho, py)?;
6537    let grid =
6538        nucleide_kinetics::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
6539    let state = nucleide_kinetics::State::new(&params, n0, c0)
6540        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6541    let method = if method.eq_ignore_ascii_case("trapezoidal") {
6542        M::Trapezoidal
6543    } else if method.eq_ignore_ascii_case("backward_euler") {
6544        M::BackwardEuler
6545    } else {
6546        return Err(PyValueError::new_err(format!(
6547            "unknown kinetics method `{method}` (supported: trapezoidal, backward_euler)"
6548        )));
6549    };
6550    let opts = SolverOptions {
6551        method,
6552        rtol,
6553        atol,
6554        dt_min,
6555        dt_max: dt_max.unwrap_or(f64::INFINITY),
6556        max_steps,
6557    };
6558    let sol = nucleide_kinetics::solve(&params, &rho, &grid, &state, &opts)
6559        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6560    use pyo3::types::PyDict;
6561    let out = PyDict::new(py);
6562    out.set_item("times", &sol.times).ok();
6563    out.set_item("n", &sol.n).ok();
6564    out.set_item("C", &sol.c).ok();
6565    out.set_item("n0", sol.initial.n0).ok();
6566    out.set_item("C0", &sol.initial.c0).ok();
6567    Ok(out.into_any().unbind())
6568}
6569
6570/// Equilibrium precursor populations `C_i = beta_i/(lambda_i*Lambda)*n0`.
6571#[pyfunction]
6572fn kinetics_equilibrium(
6573    betas: Vec<f64>,
6574    lambdas: Vec<f64>,
6575    lambda_gen: f64,
6576    n0: f64,
6577) -> PyResult<Vec<f64>> {
6578    kinetics_params(betas, lambdas, lambda_gen)?
6579        .equilibrium_precursors(n0)
6580        .map_err(|e| PyValueError::new_err(e.to_string()))
6581}
6582
6583/// Initial rate `dn/dt` at `t = 0` for the given schedule and initials.
6584#[pyfunction]
6585#[pyo3(signature = (betas, lambdas, lambda_gen, rho, n0, c0=None))]
6586fn kinetics_initial_rate(
6587    py: Python<'_>,
6588    betas: Vec<f64>,
6589    lambdas: Vec<f64>,
6590    lambda_gen: f64,
6591    rho: BTreeMap<String, Py<PyAny>>,
6592    n0: f64,
6593    c0: Option<Vec<f64>>,
6594) -> PyResult<f64> {
6595    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6596    let rho = parse_reactivity(&rho, py)?;
6597    let state = nucleide_kinetics::State::new(&params, n0, c0)
6598        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6599    Ok(nucleide_kinetics::solve::initial_rate(
6600        &params, &rho, &state,
6601    ))
6602}
6603
6604/// Inhour right-hand side `rho(omega)` [Δk] for the given data.
6605#[pyfunction]
6606fn kinetics_inhour_rho(
6607    betas: Vec<f64>,
6608    lambdas: Vec<f64>,
6609    lambda_gen: f64,
6610    omega: f64,
6611) -> PyResult<f64> {
6612    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6613    nucleide_kinetics::rho_of_omega(&params, omega)
6614        .map_err(|e| PyValueError::new_err(e.to_string()))
6615}
6616
6617/// Stable period `T = 1/omega` [s] at reactivity `rho` [Δk] (`0 < rho < beta`).
6618#[pyfunction]
6619fn kinetics_stable_period(
6620    betas: Vec<f64>,
6621    lambdas: Vec<f64>,
6622    lambda_gen: f64,
6623    rho: f64,
6624) -> PyResult<f64> {
6625    let params = kinetics_params(betas, lambdas, lambda_gen)?;
6626    nucleide_kinetics::stable_period(&params, rho).map_err(|e| PyValueError::new_err(e.to_string()))
6627}
6628
6629/// Prompt-jump estimate `n_before*(beta - rho_before)/(beta - rho_after)`.
6630///
6631/// Needs `rho_after < beta_total`; `beta_total` is the caller's total
6632/// delayed fraction (pass `sum(betas)`).
6633#[pyfunction]
6634fn kinetics_prompt_jump(
6635    n_before: f64,
6636    rho_before: f64,
6637    rho_after: f64,
6638    beta_total: f64,
6639) -> PyResult<f64> {
6640    nucleide_kinetics::prompt_jump(n_before, rho_before, rho_after, beta_total)
6641        .map_err(|e| PyValueError::new_err(e.to_string()))
6642}
6643
6644// ---------------------------------------------------------------------------
6645// Neutron spectrum unfolding (thin glue over `nucleide-unfold`; model stays in core)
6646// ---------------------------------------------------------------------------
6647
6648/// SAND-II iterative spectral adjustment (McElroy et al., AFWL-TR-67-41, 1967).
6649///
6650/// Thin wrapper over `nucleide_unfold::sandii::unfold`: `response` holds one
6651/// row per detector/reaction (all rows one value per energy group),
6652/// `rates` the measured rate per detector, and `guess` one strictly positive
6653/// value per energy group. `tolerance` is the largest per-group relative
6654/// change between successive adjustments the run converges under (strictly
6655/// below); `max_iterations` is the explicit adjustment cap — exhausting it
6656/// raises a `ValueError` (non-convergence is a hard fail, never a silent
6657/// partial spectrum). Returns a dict with `spectrum`, the folded `rates`,
6658/// per-detector `rate_factors` (measured/folded), `iterations`, the echo of
6659/// `tolerance`, and the final `max_rel_change`.
6660#[pyfunction]
6661#[pyo3(signature = (response, rates, guess, tolerance=1e-3, max_iterations=200))]
6662fn unfold_sandii(
6663    py: Python<'_>,
6664    response: Vec<Vec<f64>>,
6665    rates: Vec<f64>,
6666    guess: Vec<f64>,
6667    tolerance: f64,
6668    max_iterations: usize,
6669) -> PyResult<Py<PyAny>> {
6670    let sol = nucleide_unfold::sandii::unfold(&response, &rates, &guess, tolerance, max_iterations)
6671        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6672    use pyo3::types::PyDict;
6673    let out = PyDict::new(py);
6674    out.set_item("spectrum", &sol.spectrum).ok();
6675    out.set_item("rates", &sol.rates).ok();
6676    out.set_item("rate_factors", &sol.rate_factors).ok();
6677    out.set_item("iterations", sol.iterations).ok();
6678    out.set_item("tolerance", sol.tolerance).ok();
6679    out.set_item("max_rel_change", sol.max_rel_change).ok();
6680    Ok(out.into_any().unbind())
6681}
6682
6683/// STAYSL-class damped least-squares spectral adjustment (Perey, ORNL/TM-6062,
6684/// 1977).
6685///
6686/// Thin wrapper over `nucleide_unfold::staysl::unfold`: `response` holds one
6687/// row per detector/reaction (all rows one value per energy group), `rates`
6688/// the measured rate per detector, `sigmas` one strictly positive measurement
6689/// sigma per detector (the weight is `1/σ²`), and `guess` one strictly
6690/// positive value per energy group (the prior the damping pulls toward).
6691/// `tolerance` is the largest per-group relative change between successive
6692/// solves the run converges under (strictly below); `max_iterations` is the
6693/// explicit cycle cap and `damping` the Tikhonov pull toward the guess —
6694/// exhausting the cap raises a `ValueError` (non-convergence is a hard fail,
6695/// never a silent partial spectrum). Groups the solve drives non-positive are
6696/// pinned to zero and the system is re-solved on the reduced set. Returns a
6697/// dict with `spectrum`, the folded `rates`, per-detector `rate_factors`
6698/// (measured/folded), `iterations`, the echo of `tolerance`, and the final
6699/// `max_rel_change`.
6700#[pyfunction]
6701#[pyo3(signature = (response, rates, sigmas, guess, tolerance=1e-3, max_iterations=200, damping=1e-3))]
6702#[allow(clippy::too_many_arguments)]
6703fn unfold_staysl(
6704    py: Python<'_>,
6705    response: Vec<Vec<f64>>,
6706    rates: Vec<f64>,
6707    sigmas: Vec<f64>,
6708    guess: Vec<f64>,
6709    tolerance: f64,
6710    max_iterations: usize,
6711    damping: f64,
6712) -> PyResult<Py<PyAny>> {
6713    let sol = nucleide_unfold::staysl::unfold(
6714        &response,
6715        &rates,
6716        &sigmas,
6717        &guess,
6718        tolerance,
6719        max_iterations,
6720        damping,
6721    )
6722    .map_err(|e| PyValueError::new_err(e.to_string()))?;
6723    use pyo3::types::PyDict;
6724    let out = PyDict::new(py);
6725    out.set_item("spectrum", &sol.spectrum).ok();
6726    out.set_item("rates", &sol.rates).ok();
6727    out.set_item("rate_factors", &sol.rate_factors).ok();
6728    out.set_item("iterations", sol.iterations).ok();
6729    out.set_item("tolerance", sol.tolerance).ok();
6730    out.set_item("max_rel_change", sol.max_rel_change).ok();
6731    Ok(out.into_any().unbind())
6732}
6733
6734/// GRAVEL chi-square-weighted spectral adjustment (Matzke, PTB-N-19, 1994).
6735///
6736/// Thin wrapper over `nucleide_unfold::gravel::unfold`: `response` holds one
6737/// row per detector/reaction (all rows one value per energy group), `rates`
6738/// the measured rate per detector, `sigmas` one strictly positive measurement
6739/// sigma per detector (the per-detector weight factor is `N_i²/σ_i²`, so a
6740/// precisely measured rate pulls harder than a sloppy one), and `guess` one
6741/// strictly positive value per energy group. `tolerance` is the largest
6742/// per-group relative change between successive adjustments the run converges
6743/// under (strictly below); `max_iterations` is the explicit adjustment cap —
6744/// exhausting it raises a `ValueError` (non-convergence is a hard fail, never
6745/// a silent partial spectrum). Zero measurements carry zero weight and are
6746/// simply not fitted (unlike SAND-II they pin nothing to zero). Returns a
6747/// dict with `spectrum`, the folded `rates`, per-detector `rate_factors`
6748/// (measured/folded), `iterations`, the echo of `tolerance`, and the final
6749/// `max_rel_change`.
6750#[pyfunction]
6751#[pyo3(signature = (response, rates, sigmas, guess, tolerance=1e-3, max_iterations=200))]
6752fn unfold_gravel(
6753    py: Python<'_>,
6754    response: Vec<Vec<f64>>,
6755    rates: Vec<f64>,
6756    sigmas: Vec<f64>,
6757    guess: Vec<f64>,
6758    tolerance: f64,
6759    max_iterations: usize,
6760) -> PyResult<Py<PyAny>> {
6761    let sol = nucleide_unfold::gravel::unfold(
6762        &response,
6763        &rates,
6764        &sigmas,
6765        &guess,
6766        tolerance,
6767        max_iterations,
6768    )
6769    .map_err(|e| PyValueError::new_err(e.to_string()))?;
6770    use pyo3::types::PyDict;
6771    let out = PyDict::new(py);
6772    out.set_item("spectrum", &sol.spectrum).ok();
6773    out.set_item("rates", &sol.rates).ok();
6774    out.set_item("rate_factors", &sol.rate_factors).ok();
6775    out.set_item("iterations", sol.iterations).ok();
6776    out.set_item("tolerance", sol.tolerance).ok();
6777    out.set_item("max_rel_change", sol.max_rel_change).ok();
6778    Ok(out.into_any().unbind())
6779}
6780
6781/// Forward operator: fold a spectrum through a response matrix (one rate per
6782/// detector row). This is the map the unfolding adjusts against — also the
6783/// natural way to synthesize round-trip rates from a known spectrum.
6784#[pyfunction]
6785fn unfold_forward_fold(response: Vec<Vec<f64>>, spectrum: Vec<f64>) -> PyResult<Vec<f64>> {
6786    nucleide_unfold::forward_fold(&response, &spectrum)
6787        .map_err(|e| PyValueError::new_err(e.to_string()))
6788}
6789
6790// ---------------------------------------------------------------------------
6791// Tokamak fusion sources (thin glue over `nucleide-plasma-source`; model stays in core)
6792// ---------------------------------------------------------------------------
6793
6794/// Parse a reaction name (`"dt"`, `"dd"`, case/separator-insensitive).
6795fn parse_plasma_reaction(name: &str) -> PyResult<nucleide_plasma_source::FusionReaction> {
6796    use nucleide_plasma_source::FusionReaction as R;
6797    match name
6798        .to_ascii_lowercase()
6799        .replace(['-', '_', ' '], "")
6800        .as_str()
6801    {
6802        "dt" => Ok(R::Dt),
6803        "dd" => Ok(R::Dd),
6804        other => Err(PyValueError::new_err(format!(
6805            "unknown fusion reaction `{other}` (supported: dt, dd)"
6806        ))),
6807    }
6808}
6809
6810/// A parsed source spec: ring/point or parametric plasma.
6811enum PyPlasmaSource {
6812    Basic(nucleide_plasma_source::PlasmaSourceConfig),
6813    Parametric(nucleide_plasma_source::ParametricPlasmaConfig),
6814}
6815
6816/// Parse a source-spec dict into a ring/point [`PlasmaSourceConfig`].
6817fn parse_plasma_basic_spec(
6818    spec: &BTreeMap<String, Py<PyAny>>,
6819    py: Python<'_>,
6820    kind: &str,
6821) -> PyResult<nucleide_plasma_source::PlasmaSourceConfig> {
6822    use nucleide_plasma_source as ps;
6823    let num = |key: &str| -> PyResult<f64> {
6824        spec.get(key)
6825            .ok_or_else(|| PyValueError::new_err(format!("source spec missing `{key}`")))?
6826            .extract::<f64>(py)
6827            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6828    };
6829    let reaction = parse_plasma_reaction(&get_str(
6830        spec,
6831        py,
6832        "reaction",
6833        "source spec missing `reaction`",
6834    )?)?;
6835    let model = match kind {
6836        "point" => {
6837            let position: Vec<f64> = spec
6838                .get("position")
6839                .ok_or_else(|| PyValueError::new_err("point source needs `position` [cm]"))?
6840                .extract::<Vec<f64>>(py)
6841                .map_err(|_| PyValueError::new_err("`position` must be a list of numbers"))?;
6842            if position.len() != 3 {
6843                return Err(PyValueError::new_err(
6844                    "`position` must have exactly three entries",
6845                ));
6846            }
6847            ps::SourceModel::Point(ps::PointSource {
6848                x_cm: position[0],
6849                y_cm: position[1],
6850                z_cm: position[2],
6851            })
6852        }
6853        "ring" => ps::SourceModel::Ring(ps::RingSource {
6854            radius_cm: num("radius")?,
6855            height_cm: num("height")?,
6856        }),
6857        other => {
6858            return Err(PyValueError::new_err(format!(
6859                "unknown source kind `{other}` (supported: point, ring, parametric)"
6860            )))
6861        }
6862    };
6863    let mut config = ps::PlasmaSourceConfig {
6864        model,
6865        reaction,
6866        ion_temperature_kev: num("ion_temperature_kev")?,
6867        weight: 1.0,
6868    };
6869    if let Some(weight) = spec.get("weight") {
6870        let weight = weight
6871            .extract::<f64>(py)
6872            .map_err(|_| PyValueError::new_err("`weight` must be a number"))?;
6873        config = config.with_weight(weight);
6874    }
6875    config
6876        .validate()
6877        .map_err(|e| PyValueError::new_err(e.to_string()))?;
6878    Ok(config)
6879}
6880
6881/// Parse the parametric-plasma keys into a [`ParametricPlasmaConfig`].
6882///
6883/// Keys: `major_radius`, `minor_radius`, `elongation`, `triangularity`,
6884/// `shafranov_factor` (cm except the dimensionless shape factors), `mode`
6885/// (`"L"`/`"H"`/`"A"`), `pedestal_radius` (cm), the
6886/// `ion_density_{centre,peaking_factor,pedestal,separatrix}` parameters
6887/// (m⁻³ and dimensionless), and the
6888/// `ion_temperature_{centre,peaking_factor,beta,pedestal,separatrix}`
6889/// parameters (keV and dimensionless). Profiles are caller inputs.
6890///
6891/// Fuel: the `reaction` key (`"dt"`/`"dd"`) selects the landed single-fuel
6892/// kernels, or the `fuel` dict `{"D": f_D, "T": f_T}` (openmc-plasma-source
6893/// spelling) selects the two-branch D/T mixture at a shared ion
6894/// temperature — then `reaction` is optional and unused. Non-finite,
6895/// negative, or non-summing fractions are loud errors. Toroidal sectors
6896/// (`start_angle`/`rotation_angle`) are the documented loud boundary.
6897fn parse_plasma_parametric_spec(
6898    spec: &BTreeMap<String, Py<PyAny>>,
6899    py: Python<'_>,
6900) -> PyResult<nucleide_plasma_source::ParametricPlasmaConfig> {
6901    use nucleide_plasma_source as ps;
6902    let num = |key: &str| -> PyResult<f64> {
6903        spec.get(key)
6904            .ok_or_else(|| PyValueError::new_err(format!("parametric spec missing `{key}`")))?
6905            .extract::<f64>(py)
6906            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
6907    };
6908    for key in ["start_angle", "rotation_angle"] {
6909        if spec.contains_key(key) {
6910            return Err(PyValueError::new_err(format!(
6911                "plasma-source: not yet supported: `{key}` (sectors need a \
6912                 toroidal-angle distribution — outside the parametric model)"
6913            )));
6914        }
6915    }
6916    // Fuel mixture dict, upstream spelling: fuel={"D": f_D, "T": f_T}. Both
6917    // keys are required; FuelMixture::new carries the loud fraction errors.
6918    let fuel_mixture = match spec.get("fuel") {
6919        Some(value) => {
6920            let fractions: BTreeMap<String, f64> = value.extract(py).map_err(|_| {
6921                PyValueError::new_err(
6922                    "`fuel` must be a dict of fractions like {\"D\": 0.7, \"T\": 0.3}",
6923                )
6924            })?;
6925            for key in fractions.keys() {
6926                if !matches!(key.as_str(), "D" | "T") {
6927                    return Err(PyValueError::new_err(format!(
6928                        "unsupported fuel fraction `{key}` (supported keys: D, T)"
6929                    )));
6930                }
6931            }
6932            let missing = |key: &str| {
6933                PyValueError::new_err(format!(
6934                    "`fuel` dict needs both `D` and `T` fractions (missing `{key}`)"
6935                ))
6936            };
6937            let f_d = fractions.get("D").copied().ok_or_else(|| missing("D"))?;
6938            let f_t = fractions.get("T").copied().ok_or_else(|| missing("T"))?;
6939            Some(ps::FuelMixture::new(f_d, f_t).map_err(|e| PyValueError::new_err(e.to_string()))?)
6940        }
6941        None => None,
6942    };
6943    let mode = ps::ProfileMode::parse(&get_str(
6944        spec,
6945        py,
6946        "mode",
6947        "parametric spec missing `mode`",
6948    )?)
6949    .map_err(|e| PyValueError::new_err(e.to_string()))?;
6950    // `reaction` stays required for single-fuel specs; a fuel dict fully
6951    // determines the model, so it becomes an optional (unused) placeholder.
6952    let fuel = match spec.get("reaction") {
6953        Some(_) => parse_plasma_reaction(&get_str(
6954            spec,
6955            py,
6956            "reaction",
6957            "parametric spec missing `reaction`",
6958        )?)?,
6959        None if fuel_mixture.is_some() => ps::FusionReaction::Dt,
6960        None => {
6961            return Err(PyValueError::new_err("parametric spec missing `reaction`"));
6962        }
6963    };
6964    let mut config = ps::ParametricPlasmaConfig {
6965        geometry: ps::MillerGeometry {
6966            major_radius_cm: num("major_radius")?,
6967            minor_radius_cm: num("minor_radius")?,
6968            elongation: num("elongation")?,
6969            triangularity: num("triangularity")?,
6970            shafranov_factor_cm: num("shafranov_factor")?,
6971        },
6972        mode,
6973        ion_density: ps::DensityProfile {
6974            centre_m3: num("ion_density_centre")?,
6975            peaking_factor: num("ion_density_peaking_factor")?,
6976            pedestal_m3: num("ion_density_pedestal")?,
6977            separatrix_m3: num("ion_density_separatrix")?,
6978        },
6979        ion_temperature: ps::TemperatureProfile {
6980            centre_kev: num("ion_temperature_centre")?,
6981            peaking_factor: num("ion_temperature_peaking_factor")?,
6982            beta: num("ion_temperature_beta")?,
6983            pedestal_kev: num("ion_temperature_pedestal")?,
6984            separatrix_kev: num("ion_temperature_separatrix")?,
6985        },
6986        pedestal_radius_cm: num("pedestal_radius")?,
6987        fuel,
6988        fuel_mixture,
6989        weight: 1.0,
6990    };
6991    if let Some(weight) = spec.get("weight") {
6992        let weight = weight
6993            .extract::<f64>(py)
6994            .map_err(|_| PyValueError::new_err("`weight` must be a number"))?;
6995        config.weight = weight;
6996    }
6997    config
6998        .validate()
6999        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7000    Ok(config)
7001}
7002
7003/// Parse a source-spec dict: `kind` selects ring/point (`"point"`,
7004/// `"ring"`) or the parametric plasma (`"parametric"`).
7005fn parse_plasma_source_spec(
7006    spec: &BTreeMap<String, Py<PyAny>>,
7007    py: Python<'_>,
7008) -> PyResult<PyPlasmaSource> {
7009    let kind: String = spec
7010        .get("kind")
7011        .ok_or_else(|| PyValueError::new_err("source spec needs a `kind`"))?
7012        .extract::<String>(py)
7013        .map_err(|_| PyValueError::new_err("`kind` must be a string"))?;
7014    match kind.as_str() {
7015        "point" | "ring" => Ok(PyPlasmaSource::Basic(parse_plasma_basic_spec(
7016            spec, py, &kind,
7017        )?)),
7018        "parametric" => Ok(PyPlasmaSource::Parametric(parse_plasma_parametric_spec(
7019            spec, py,
7020        )?)),
7021        other => Err(PyValueError::new_err(format!(
7022            "unknown source kind `{other}` (supported: point, ring, parametric)"
7023        ))),
7024    }
7025}
7026
7027/// Drift report rows as a list of dicts (`quantity`, `accounted`,
7028/// `rel_drift`, `reparsed`, `note`).
7029fn plasma_drift_rows(
7030    py: Python<'_>,
7031    report: &nucleide_plasma_source::DriftReport,
7032) -> PyResult<Vec<Py<pyo3::types::PyDict>>> {
7033    use pyo3::types::PyDict;
7034    let mut rows = Vec::with_capacity(report.rows.len());
7035    for row in &report.rows {
7036        let d = PyDict::new(py);
7037        d.set_item("quantity", &row.quantity)?;
7038        d.set_item("accounted", row.accounted)?;
7039        d.set_item("rel_drift", row.rel_drift)?;
7040        d.set_item("reparsed", row.reparsed)?;
7041        d.set_item("note", &row.note)?;
7042        rows.push(d.unbind());
7043    }
7044    Ok(rows)
7045}
7046
7047/// Sample `n` source particles into per-field float64 NumPy arrays.
7048///
7049/// Thin wrapper over the ring/point `SourceSampler` and the parametric
7050/// `ParametricSampler` (seeded, deterministic per platform): `spec` is the
7051/// source-spec dict (see [`parse_plasma_source_spec`]), `seed` pins the
7052/// stream. Returns `x`, `y`, `z` \[cm\], direction cosines `u`, `v`, `w`
7053/// (unit vectors), `energy` \[MeV\], and `weight`. MCPL projection stays
7054/// caller-side: write the arrays with `nucleide.mcpl` / `nucleide.mcnp` if
7055/// a file is wanted.
7056#[pyfunction]
7057#[pyo3(signature = (spec, n, seed))]
7058fn plasma_source_particles(
7059    py: Python<'_>,
7060    spec: BTreeMap<String, Py<PyAny>>,
7061    n: usize,
7062    seed: u64,
7063) -> PyResult<Py<PyAny>> {
7064    use nucleide_plasma_source as ps;
7065    let particles = match parse_plasma_source_spec(&spec, py)? {
7066        PyPlasmaSource::Basic(config) => ps::SourceSampler::new(config, seed)
7067            .map_err(|e| PyValueError::new_err(e.to_string()))?
7068            .sample_n(n),
7069        PyPlasmaSource::Parametric(config) => ps::ParametricSampler::new(config, seed)
7070            .map_err(|e| PyValueError::new_err(e.to_string()))?
7071            .sample_n(n),
7072    };
7073    let mut x = Vec::with_capacity(n);
7074    let mut y = Vec::with_capacity(n);
7075    let mut z = Vec::with_capacity(n);
7076    let mut u = Vec::with_capacity(n);
7077    let mut v = Vec::with_capacity(n);
7078    let mut w = Vec::with_capacity(n);
7079    let mut energy = Vec::with_capacity(n);
7080    let mut weight = Vec::with_capacity(n);
7081    for p in &particles {
7082        x.push(p.position_cm[0]);
7083        y.push(p.position_cm[1]);
7084        z.push(p.position_cm[2]);
7085        u.push(p.direction[0]);
7086        v.push(p.direction[1]);
7087        w.push(p.direction[2]);
7088        energy.push(p.energy_mev);
7089        weight.push(p.weight);
7090    }
7091    use pyo3::types::PyDict;
7092    let out = PyDict::new(py);
7093    out.set_item("x", x.into_pyarray(py))?;
7094    out.set_item("y", y.into_pyarray(py))?;
7095    out.set_item("z", z.into_pyarray(py))?;
7096    out.set_item("u", u.into_pyarray(py))?;
7097    out.set_item("v", v.into_pyarray(py))?;
7098    out.set_item("w", w.into_pyarray(py))?;
7099    out.set_item("energy", energy.into_pyarray(py))?;
7100    out.set_item("weight", weight.into_pyarray(py))?;
7101    Ok(out.into_any().unbind())
7102}
7103
7104/// Emit MCNP `SDEF` and Serpent `src` source cards plus drift reports.
7105///
7106/// Thin wrapper over `nucleide_plasma_source::{emit_sdef, emit_serpent}`
7107/// (ring/point) and `{emit_sdef_parametric, emit_serpent_parametric}`.
7108/// `spec` is the source-spec dict (optional `mcnp_version`, 5 or 6, default
7109/// 5); `bins` sets the tabulation bin count. Returns `sdef` and `serpent`,
7110/// each `{"card": str, "drift": [row dicts]}`, plus `spectrum` moments
7111/// (`nominal_mev`, `mean_mev`, `sigma_mev`, `mono` — for a parametric
7112/// source these are the magnetic-axis moments). The SDEF card round-trips
7113/// through `nucleide.mcnp.parse_sdef` byte-identically; Serpent drift rows
7114/// are analytic by design (no Serpent source reader in the workspace).
7115#[pyfunction]
7116#[pyo3(signature = (spec, bins=21))]
7117fn plasma_source_emit_cards(
7118    py: Python<'_>,
7119    spec: BTreeMap<String, Py<PyAny>>,
7120    bins: usize,
7121) -> PyResult<Py<PyAny>> {
7122    use nucleide_plasma_source as ps;
7123    let source = parse_plasma_source_spec(&spec, py)?;
7124    let version = match spec.get("mcnp_version") {
7125        Some(v) => v
7126            .extract::<u32>(py)
7127            .map_err(|_| PyValueError::new_err("`mcnp_version` must be an integer (5 or 6)"))?,
7128        None => 5,
7129    };
7130    let emit = |card: ps::EmittedCard| -> PyResult<Py<pyo3::types::PyDict>> {
7131        use pyo3::types::PyDict;
7132        let d = PyDict::new(py);
7133        d.set_item("card", card.text)?;
7134        d.set_item("drift", plasma_drift_rows(py, &card.drift)?)?;
7135        Ok(d.unbind())
7136    };
7137    use pyo3::types::PyDict;
7138    let out = PyDict::new(py);
7139    let (nominal, mean, sigma, mono) = match &source {
7140        PyPlasmaSource::Basic(config) => {
7141            let sdef = ps::emit_sdef(config, version, bins)
7142                .map_err(|e| PyValueError::new_err(e.to_string()))?;
7143            let serpent =
7144                ps::emit_serpent(config, bins).map_err(|e| PyValueError::new_err(e.to_string()))?;
7145            let spectrum = config
7146                .spectrum()
7147                .map_err(|e| PyValueError::new_err(e.to_string()))?;
7148            let sigma = match spectrum {
7149                ps::SpectrumSpec::Gaussian { sigma_mev, .. } => sigma_mev,
7150                ps::SpectrumSpec::Mono { .. } => 0.0,
7151            };
7152            out.set_item("sdef", emit(sdef)?)?;
7153            out.set_item("serpent", emit(serpent)?)?;
7154            (
7155                config.reaction.nominal_energy_mev(),
7156                spectrum.mean_mev(),
7157                sigma,
7158                spectrum.is_mono(),
7159            )
7160        }
7161        PyPlasmaSource::Parametric(config) => {
7162            let sdef = ps::emit_sdef_parametric(config, version, bins)
7163                .map_err(|e| PyValueError::new_err(e.to_string()))?;
7164            let serpent = ps::emit_serpent_parametric(config, bins)
7165                .map_err(|e| PyValueError::new_err(e.to_string()))?;
7166            // Magnetic-axis spectrum summary (the profile peak); for a fuel
7167            // mixture this is the two-branch Gaussian-mixture summary.
7168            let (nominal, mean, sigma) = config
7169                .axis_spectrum_summary()
7170                .map_err(|e| PyValueError::new_err(e.to_string()))?;
7171            out.set_item("sdef", emit(sdef)?)?;
7172            out.set_item("serpent", emit(serpent)?)?;
7173            (nominal, mean, sigma, sigma == 0.0)
7174        }
7175    };
7176    let spec_out = PyDict::new(py);
7177    spec_out.set_item("nominal_mev", nominal)?;
7178    spec_out.set_item("mean_mev", mean)?;
7179    spec_out.set_item("sigma_mev", sigma)?;
7180    spec_out.set_item("mono", mono)?;
7181    out.set_item("spectrum", spec_out)?;
7182    Ok(out.into_any().unbind())
7183}
7184
7185/// Closed-form spectrum moments of a fusion reaction at an ion temperature.
7186///
7187/// Returns `nominal_mev` (the `T_i = 0` line), `mean_mev`, and `sigma_mev`
7188/// (0 when the line is monoenergetic). `reaction` is `"dt"` or `"dd"`;
7189/// `ion_temperature_kev` is in keV. Moments follow Brysk (1973) as fitted by
7190/// Ballabio et al. (1998).
7191#[pyfunction]
7192fn plasma_source_spectrum_moments(
7193    py: Python<'_>,
7194    reaction: &str,
7195    ion_temperature_kev: f64,
7196) -> PyResult<Py<PyAny>> {
7197    use nucleide_plasma_source::FusionReaction as R;
7198    let reaction = parse_plasma_reaction(reaction)?;
7199    let (mean, sigma) = reaction
7200        .moments_mev(ion_temperature_kev)
7201        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7202    use pyo3::types::PyDict;
7203    let out = PyDict::new(py);
7204    out.set_item(
7205        "reaction",
7206        match reaction {
7207            R::Dt => "dt",
7208            R::Dd => "dd",
7209        },
7210    )?;
7211    out.set_item("label", reaction.label())?;
7212    out.set_item("nominal_mev", reaction.nominal_energy_mev())?;
7213    out.set_item("mean_mev", mean)?;
7214    out.set_item("sigma_mev", sigma)?;
7215    Ok(out.into_any().unbind())
7216}
7217
7218/// Thermonuclear reactivity ⟨σv⟩ \[m³/s\] of a fusion reaction at an ion
7219/// temperature \[keV\] (Bosch & Hale, Nucl. Fusion 32 (1992) 611, in the
7220/// Atzeni–Meyer-ter-Vehn parametrization). Zero at `T_i = 0`.
7221#[pyfunction]
7222fn plasma_source_reactivity(reaction: &str, ion_temperature_kev: f64) -> PyResult<f64> {
7223    parse_plasma_reaction(reaction)?
7224        .reactivity_m3_per_s(ion_temperature_kev)
7225        .map_err(|e| PyValueError::new_err(e.to_string()))
7226}
7227
7228// ---------------------------------------------------------------------------
7229// Damage and gas-production metrics (thin glue over `nucleide-damage`)
7230// ---------------------------------------------------------------------------
7231
7232/// Parse a nuclide key (int nucid or str name) into the core [`NuclideId`].
7233fn parse_damage_nuclide(key: &Bound<'_, PyAny>) -> PyResult<NuclideId> {
7234    if let Ok(nucid) = key.extract::<u32>() {
7235        return NuclideId::try_from_nucid(nucid).map_err(wrap_nucid_err);
7236    }
7237    if let Ok(name) = key.extract::<&str>() {
7238        return NuclideId::from_name(name).map_err(wrap_nucid_err);
7239    }
7240    Err(PyTypeError::new_err("expected int nucid or str name"))
7241}
7242
7243/// NRT-dpa: fold caller dpa cross sections (barns) over the group flux.
7244///
7245/// `flux` is the per-group integrated flux (n/cm²/s), `bounds` the G+1 MeV
7246/// group boundaries, `seconds` the exposure time. Piecewise-constant per
7247/// group; zero-flux groups contribute exactly 0.
7248#[pyfunction]
7249#[pyo3(signature = (flux, response, bounds, seconds))]
7250fn damage_nrt_dpa(
7251    flux: Vec<f64>,
7252    response: Vec<f64>,
7253    bounds: Vec<f64>,
7254    seconds: f64,
7255) -> PyResult<f64> {
7256    nucleide_damage::nrt_dpa(&flux, &response, &bounds, seconds)
7257        .map_err(|e| PyValueError::new_err(e.to_string()))
7258}
7259
7260/// arc-dpa fold: same as `damage_nrt_dpa` with arc-corrected cross sections.
7261#[pyfunction]
7262#[pyo3(signature = (flux, response, bounds, seconds))]
7263fn damage_arc_dpa(
7264    flux: Vec<f64>,
7265    response: Vec<f64>,
7266    bounds: Vec<f64>,
7267    seconds: f64,
7268) -> PyResult<f64> {
7269    nucleide_damage::arc_dpa(&flux, &response, &bounds, seconds)
7270        .map_err(|e| PyValueError::new_err(e.to_string()))
7271}
7272
7273/// Gas production in atomic parts per million (He or H, whichever gas the
7274/// caller's `response` counts), by the same fold with the appm normalization.
7275#[pyfunction]
7276#[pyo3(signature = (flux, response, bounds, seconds))]
7277fn damage_gas_appm(
7278    flux: Vec<f64>,
7279    response: Vec<f64>,
7280    bounds: Vec<f64>,
7281    seconds: f64,
7282) -> PyResult<f64> {
7283    nucleide_damage::gas_appm(&flux, &response, &bounds, seconds)
7284        .map_err(|e| PyValueError::new_err(e.to_string()))
7285}
7286
7287/// He/dpa ratio (appm per dpa) from one fold of the He production and
7288/// damage cross sections over the same flux. Zero dpa is a loud error,
7289/// never `inf`.
7290#[pyfunction]
7291#[pyo3(signature = (flux, he_response, damage_response, bounds, seconds))]
7292fn damage_he_dpa_ratio(
7293    flux: Vec<f64>,
7294    he_response: Vec<f64>,
7295    damage_response: Vec<f64>,
7296    bounds: Vec<f64>,
7297    seconds: f64,
7298) -> PyResult<f64> {
7299    nucleide_damage::he_dpa_ratio(&flux, &he_response, &damage_response, &bounds, seconds)
7300        .map_err(|e| PyValueError::new_err(e.to_string()))
7301}
7302
7303/// Lindhard partition fraction `P(ε) = 1/(1 + k_L·g(ε))` for a recoil of
7304/// energy `t_ev` (eV) stopped in a lattice; nuclides accept an int nucid or
7305/// a name string.
7306#[pyfunction]
7307#[pyo3(signature = (t_ev, recoil, lattice))]
7308fn damage_lindhard_partition(
7309    t_ev: f64,
7310    recoil: &Bound<'_, PyAny>,
7311    lattice: &Bound<'_, PyAny>,
7312) -> PyResult<f64> {
7313    let recoil = parse_damage_nuclide(recoil)?;
7314    let lattice = parse_damage_nuclide(lattice)?;
7315    nucleide_damage::lindhard_partition(t_ev, &recoil, &lattice)
7316        .map_err(|e| PyValueError::new_err(e.to_string()))
7317}
7318
7319/// Lindhard damage energy `T_dam = T·P(ε)` in eV.
7320#[pyfunction]
7321#[pyo3(signature = (t_ev, recoil, lattice))]
7322fn damage_damage_energy(
7323    t_ev: f64,
7324    recoil: &Bound<'_, PyAny>,
7325    lattice: &Bound<'_, PyAny>,
7326) -> PyResult<f64> {
7327    let recoil = parse_damage_nuclide(recoil)?;
7328    let lattice = parse_damage_nuclide(lattice)?;
7329    nucleide_damage::damage_energy(t_ev, &recoil, &lattice)
7330        .map_err(|e| PyValueError::new_err(e.to_string()))
7331}
7332
7333/// NRT displacement function `N_d(T)` for a self-recoil `target` (int nucid
7334/// or name) with average threshold displacement energy `ed_ev` (eV).
7335#[pyfunction]
7336#[pyo3(signature = (t_ev, ed_ev, target))]
7337fn damage_nrt_displacements(t_ev: f64, ed_ev: f64, target: &Bound<'_, PyAny>) -> PyResult<f64> {
7338    let target = parse_damage_nuclide(target)?;
7339    nucleide_damage::nrt_displacements(t_ev, ed_ev, &target)
7340        .map_err(|e| PyValueError::new_err(e.to_string()))
7341}
7342
7343/// arc-dpa efficiency `ξ(T_d)` (Nordlund 2018 Eq. (7)) at damage energy
7344/// `t_dam_ev` for threshold `ed_ev` and constants `b_arc`/`c_arc`.
7345#[pyfunction]
7346#[pyo3(signature = (t_dam_ev, ed_ev, b_arc, c_arc))]
7347fn damage_arc_efficiency(t_dam_ev: f64, ed_ev: f64, b_arc: f64, c_arc: f64) -> PyResult<f64> {
7348    let params = nucleide_damage::ArcParams::new(b_arc, c_arc)
7349        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7350    nucleide_damage::arc_efficiency(t_dam_ev, ed_ev, &params)
7351        .map_err(|e| PyValueError::new_err(e.to_string()))
7352}
7353
7354/// arc-dpa displacement function for a self-recoil `target` with threshold
7355/// `ed_ev` (eV) and arc constants `b_arc`/`c_arc`.
7356#[pyfunction]
7357#[pyo3(signature = (t_ev, ed_ev, target, b_arc, c_arc))]
7358fn damage_arc_displacements(
7359    t_ev: f64,
7360    ed_ev: f64,
7361    target: &Bound<'_, PyAny>,
7362    b_arc: f64,
7363    c_arc: f64,
7364) -> PyResult<f64> {
7365    let target = parse_damage_nuclide(target)?;
7366    let params = nucleide_damage::ArcParams::new(b_arc, c_arc)
7367        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7368    nucleide_damage::arc_displacements(t_ev, ed_ev, &target, &params)
7369        .map_err(|e| PyValueError::new_err(e.to_string()))
7370}
7371
7372/// UQ sweep over the fold: seeded MVN draws over the caller's relative
7373/// `[flux, response]` block, refolded per draw, gated at `k` standard
7374/// errors against the exact expectation and the first-order propagated
7375/// standard deviation (the landed U1–U4/U7 pattern). `metric` is one of
7376/// `"nrt_dpa"`, `"arc_dpa"`, `"gas_appm"` (`"he_dpa_ratio"` is a loud
7377/// named-open). Returns the sample/analytic moments plus the gate verdict.
7378#[pyfunction]
7379#[pyo3(signature = (metric, flux, response, bounds, seconds, mean, cov, n, seed, k))]
7380#[allow(clippy::too_many_arguments)] // mirrors the core fold_uq signature plus the PyO3 py handle
7381fn damage_fold_uq(
7382    py: Python<'_>,
7383    metric: &str,
7384    flux: Vec<f64>,
7385    response: Vec<f64>,
7386    bounds: Vec<f64>,
7387    seconds: f64,
7388    mean: Vec<f64>,
7389    cov: Vec<Vec<f64>>,
7390    n: usize,
7391    seed: u64,
7392    k: f64,
7393) -> PyResult<Py<PyAny>> {
7394    use nucleide_damage::FoldMetric as M;
7395    let metric = match metric
7396        .to_ascii_lowercase()
7397        .replace(['-', ' '], "_")
7398        .as_str()
7399    {
7400        "nrt_dpa" => M::NrtDpa,
7401        "arc_dpa" => M::ArcDpa,
7402        "gas_appm" => M::GasAppm,
7403        "he_dpa_ratio" => M::HeDpaRatio,
7404        other => {
7405            return Err(PyValueError::new_err(format!(
7406                "unknown fold metric `{other}` (supported: nrt_dpa, arc_dpa, gas_appm)"
7407            )))
7408        }
7409    };
7410    let s = nucleide_damage::fold_uq(
7411        metric, &flux, &response, &bounds, seconds, &mean, &cov, n, seed, k,
7412    )
7413    .map_err(|e| PyValueError::new_err(e.to_string()))?;
7414    use pyo3::types::PyDict;
7415    let out = PyDict::new(py);
7416    out.set_item("metric", s.metric.name())?;
7417    out.set_item("nominal", s.nominal)?;
7418    out.set_item("mean", s.mean)?;
7419    out.set_item("std", s.std)?;
7420    out.set_item("expected", s.expected)?;
7421    out.set_item("analytic_std", s.analytic_std)?;
7422    out.set_item("k", s.k)?;
7423    out.set_item("n", s.n)?;
7424    out.set_item("seed", s.seed)?;
7425    out.set_item("passed", s.passed)?;
7426    Ok(out.into_any().unbind())
7427}
7428
7429/// Vendored SPECTER Table VII displacement cross sections (barns) for one
7430/// spectrum, keyed by element symbol.
7431///
7432/// Transcribed from Greenwood & Smither, ANL/FPP/TM-197 Table VII (US-gov
7433/// PD): spectrum-averaged damage-energy cross sections converted with the
7434/// vendored Table II `E_d` via the report's `0.8/2E_d` rule. `spectrum` is
7435/// one of `"thermal"`, `"fission"`, `"14mev"`, `"hfir"`, `"ebr2"`,
7436/// `"fftf"`, `"fusion"`. Opt-in fallback only — the fold kernels never
7437/// consult this table implicitly.
7438#[pyfunction]
7439#[pyo3(signature = (spectrum,))]
7440fn damage_specter_table(spectrum: &str) -> PyResult<BTreeMap<String, f64>> {
7441    let spectrum = nucleide_damage::SpecterSpectrum::parse(spectrum)
7442        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7443    Ok(nucleide_damage::SpecterTable::for_spectrum(spectrum)
7444        .iter()
7445        .map(|(element, entry)| (element.clone(), entry.dpa_xs_barns()))
7446        .collect())
7447}
7448
7449/// Verbatim vendored SPECTER Table VII damage-energy cross sections
7450/// (keV-b, as printed) for one spectrum, keyed by element symbol.
7451///
7452/// Same transcription and `spectrum` spellings as `damage_specter_table`,
7453/// without the `0.8/2E_d` conversion.
7454#[pyfunction]
7455#[pyo3(signature = (spectrum,))]
7456fn damage_specter_damage_energy(spectrum: &str) -> PyResult<BTreeMap<String, f64>> {
7457    let spectrum = nucleide_damage::SpecterSpectrum::parse(spectrum)
7458        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7459    Ok(nucleide_damage::SpecterTable::for_spectrum(spectrum)
7460        .iter()
7461        .map(|(element, entry)| (element.clone(), entry.damage_energy_kev_b))
7462        .collect())
7463}
7464
7465/// Vendored SPECTER Table II `E_d` (eV) for an element symbol
7466/// (e.g. `"Fe"`; `"Ag"` is natural silver, `"W"` natural tungsten).
7467#[pyfunction]
7468#[pyo3(signature = (element,))]
7469fn damage_specter_ed(element: &str) -> PyResult<f64> {
7470    nucleide_damage::specter_ed_ev(element).map_err(|e| PyValueError::new_err(e.to_string()))
7471}
7472
7473/// Canonical spectrum names of the vendored SPECTER Table VII fallback.
7474#[pyfunction]
7475fn damage_specter_spectra() -> Vec<String> {
7476    nucleide_damage::SpecterSpectrum::ALL
7477        .iter()
7478        .map(|s| s.name().to_string())
7479        .collect()
7480}
7481
7482// ---------------------------------------------------------------------------
7483// Tritium transport (thin glue over `nucleide-tritium`; solver stays in core)
7484// ---------------------------------------------------------------------------
7485
7486/// Parse a boundary-spec dict into the core [`Boundary`].
7487///
7488/// `kind` selects the surface law (`"dirichlet"`, `"sieverts"`, `"henry"`,
7489/// `"recombination"`, `"zero_flux"`). Keys per kind: dirichlet (`value`
7490/// [mol/m³]); sieverts/henry (`solubility`, `pressure` [Pa]);
7491/// recombination (`rate`); zero_flux (no keys). Recombination ends close
7492/// per solve — steady (G5) and transient (G6) alike.
7493fn parse_tritium_boundary(
7494    spec: &BTreeMap<String, Py<PyAny>>,
7495    py: Python<'_>,
7496) -> PyResult<nucleide_tritium::Boundary> {
7497    use nucleide_tritium::Boundary as B;
7498    let kind: String = spec
7499        .get("kind")
7500        .ok_or_else(|| PyValueError::new_err("boundary spec needs a `kind`"))?
7501        .extract::<String>(py)
7502        .map_err(|_| PyValueError::new_err("`kind` must be a string"))?;
7503    let num = |key: &str| -> PyResult<f64> {
7504        spec.get(key)
7505            .ok_or_else(|| PyValueError::new_err(format!("boundary spec missing `{key}`")))?
7506            .extract::<f64>(py)
7507            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7508    };
7509    let b = match kind.as_str() {
7510        "dirichlet" => B::dirichlet(num("value")?),
7511        "sieverts" => B::sieverts(num("solubility")?, num("pressure")?),
7512        "henry" => B::henry(num("solubility")?, num("pressure")?),
7513        "recombination" => B::recombination(num("rate")?),
7514        "zero_flux" => Ok(B::ZeroFlux),
7515        other => {
7516            return Err(PyValueError::new_err(format!(
7517                "unknown boundary kind `{other}` (supported: dirichlet, sieverts, henry, recombination, zero_flux)"
7518            )))
7519        }
7520    };
7521    b.map_err(|e| PyValueError::new_err(e.to_string()))
7522}
7523
7524/// Parse a trap-spec dict into the core [`TrapSpec`].
7525///
7526/// Keys: `k0` [m³/mol/s], `p0` [1/s], `site_density` [mol/m³] (required);
7527/// `e_k`/`e_p` [J/mol] (optional, default 0 = constant rates).
7528fn parse_tritium_trap(
7529    spec: &BTreeMap<String, Py<PyAny>>,
7530    py: Python<'_>,
7531) -> PyResult<nucleide_tritium::TrapSpec> {
7532    let num = |key: &str| -> PyResult<f64> {
7533        spec.get(key)
7534            .ok_or_else(|| PyValueError::new_err(format!("trap spec missing `{key}`")))?
7535            .extract::<f64>(py)
7536            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7537    };
7538    let opt = |key: &str| -> PyResult<f64> {
7539        match spec.get(key) {
7540            None => Ok(0.0),
7541            Some(v) => v
7542                .extract::<f64>(py)
7543                .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number"))),
7544        }
7545    };
7546    nucleide_tritium::TrapSpec::new(
7547        num("k0")?,
7548        opt("e_k")?,
7549        num("p0")?,
7550        opt("e_p")?,
7551        num("site_density")?,
7552    )
7553    .map_err(|e| PyValueError::new_err(e.to_string()))
7554}
7555
7556#[allow(clippy::too_many_arguments)]
7557fn tritium_params(
7558    py: Python<'_>,
7559    length: f64,
7560    cells: usize,
7561    d0: f64,
7562    e_d: f64,
7563    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7564    temperature: Vec<f64>,
7565    source: Option<Vec<f64>>,
7566) -> PyResult<nucleide_tritium::TransportParams> {
7567    let parsed: Vec<nucleide_tritium::TrapSpec> = traps
7568        .iter()
7569        .map(|s| parse_tritium_trap(s, py))
7570        .collect::<PyResult<_>>()?;
7571    nucleide_tritium::TransportParams::new(
7572        length,
7573        cells,
7574        d0,
7575        e_d,
7576        parsed,
7577        temperature,
7578        source.unwrap_or_default(),
7579    )
7580    .map_err(|e| PyValueError::new_err(e.to_string()))
7581}
7582
7583/// Trap-free-style steady state of (T1–T2).
7584///
7585/// Thin wrapper over `nucleide_tritium::steady_state`: `traps` holds one
7586/// spec dict per species (see `parse_tritium_trap`), `temperature` is one
7587/// value (uniform) or one per cell, `source` is None (zero), one value, or
7588/// one per cell, and `left`/`right` are boundary-spec dicts (see
7589/// `parse_tritium_boundary`). Returns a dict with `centres`, `mobile`,
7590/// `trapped` (`[cell][trap]`), `flux_left`, `flux_right`,
7591/// `inventory_mobile`, and `inventory_trapped`.
7592#[pyfunction]
7593#[pyo3(signature = (length, cells, d0, e_d, traps, temperature, source, left, right))]
7594#[allow(clippy::too_many_arguments)]
7595fn tritium_steady(
7596    py: Python<'_>,
7597    length: f64,
7598    cells: usize,
7599    d0: f64,
7600    e_d: f64,
7601    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7602    temperature: Vec<f64>,
7603    source: Option<Vec<f64>>,
7604    left: BTreeMap<String, Py<PyAny>>,
7605    right: BTreeMap<String, Py<PyAny>>,
7606) -> PyResult<Py<PyAny>> {
7607    let params = tritium_params(py, length, cells, d0, e_d, traps, temperature, source)?;
7608    let left = parse_tritium_boundary(&left, py)?;
7609    let right = parse_tritium_boundary(&right, py)?;
7610    let s = nucleide_tritium::steady_state(&params, &left, &right)
7611        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7612    use pyo3::types::PyDict;
7613    let out = PyDict::new(py);
7614    out.set_item("centres", &s.centres).ok();
7615    out.set_item("mobile", &s.mobile).ok();
7616    out.set_item("trapped", &s.trapped).ok();
7617    out.set_item("flux_left", s.flux_left).ok();
7618    out.set_item("flux_right", s.flux_right).ok();
7619    out.set_item("inventory_mobile", s.inventory_mobile).ok();
7620    out.set_item("inventory_trapped", s.inventory_trapped).ok();
7621    Ok(out.into_any().unbind())
7622}
7623
7624/// Solve the (T1–T2) transient over the output grid `t`.
7625///
7626/// Thin wrapper over `nucleide_tritium::solve` with the same slab/trap/BC
7627/// arguments as `tritium_steady` plus the output times `t` [s], the
7628/// optional initial profiles (`mobile0` per cell, `trapped0` as
7629/// `[cell][trap]`; both default to zero), and the solver options
7630/// (`method` is `"crank_nicolson"` (default) or `"backward_euler"`).
7631/// Returns a dict with `times`, `mobile` (`[time][cell]`), `trapped`
7632/// (`[time][cell][trap]`), `flux_left`, and `flux_right`.
7633#[pyfunction]
7634#[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))]
7635#[allow(clippy::too_many_arguments)]
7636fn tritium_transient(
7637    py: Python<'_>,
7638    length: f64,
7639    cells: usize,
7640    d0: f64,
7641    e_d: f64,
7642    traps: Vec<BTreeMap<String, Py<PyAny>>>,
7643    temperature: Vec<f64>,
7644    source: Option<Vec<f64>>,
7645    left: BTreeMap<String, Py<PyAny>>,
7646    right: BTreeMap<String, Py<PyAny>>,
7647    t: Vec<f64>,
7648    mobile0: Option<Vec<f64>>,
7649    trapped0: Option<Vec<Vec<f64>>>,
7650    method: &str,
7651    rtol: f64,
7652    atol: f64,
7653    dt_min: f64,
7654    dt_max: Option<f64>,
7655    max_steps: usize,
7656) -> PyResult<Py<PyAny>> {
7657    use nucleide_tritium::{SolverOptions, Theta};
7658    let params = tritium_params(py, length, cells, d0, e_d, traps, temperature, source)?;
7659    let left = parse_tritium_boundary(&left, py)?;
7660    let right = parse_tritium_boundary(&right, py)?;
7661    let grid =
7662        nucleide_tritium::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
7663    let ntraps = params.traps.len();
7664    let mobile = mobile0.unwrap_or_else(|| vec![0.0; params.cells]);
7665    let trapped = trapped0.unwrap_or_else(|| vec![vec![0.0; ntraps]; params.cells]);
7666    let initial = nucleide_tritium::InitialState::new(&params, mobile, trapped)
7667        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7668    let theta = if method.eq_ignore_ascii_case("crank_nicolson") {
7669        Theta::CrankNicolson
7670    } else if method.eq_ignore_ascii_case("backward_euler") {
7671        Theta::BackwardEuler
7672    } else {
7673        return Err(PyValueError::new_err(format!(
7674            "unknown tritium method `{method}` (supported: crank_nicolson, backward_euler)"
7675        )));
7676    };
7677    let opts = SolverOptions {
7678        theta,
7679        rtol,
7680        atol,
7681        dt_min,
7682        dt_max: dt_max.unwrap_or(f64::INFINITY),
7683        max_steps,
7684    };
7685    let sol = nucleide_tritium::solve(&params, &left, &right, &grid, &initial, &opts)
7686        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7687    use pyo3::types::PyDict;
7688    let out = PyDict::new(py);
7689    out.set_item("times", &sol.times).ok();
7690    out.set_item("mobile", &sol.mobile).ok();
7691    out.set_item("trapped", &sol.trapped).ok();
7692    out.set_item("flux_left", &sol.flux_left).ok();
7693    out.set_item("flux_right", &sol.flux_right).ok();
7694    Ok(out.into_any().unbind())
7695}
7696
7697/// Permeation time lag `t_lag = L²/6D` [s] (G2-lag).
7698#[pyfunction]
7699fn tritium_time_lag(length: f64, diffusivity: f64) -> PyResult<f64> {
7700    nucleide_tritium::time_lag(length, diffusivity)
7701        .map_err(|e| PyValueError::new_err(e.to_string()))
7702}
7703
7704/// Normalized outlet flux `J(L,t)/J_ss` at each time (G2 series).
7705#[pyfunction]
7706fn tritium_breakthrough(diffusivity: f64, length: f64, times: Vec<f64>) -> PyResult<Vec<f64>> {
7707    times
7708        .iter()
7709        .map(|t| {
7710            nucleide_tritium::breakthrough_ratio(diffusivity, length, *t)
7711                .map_err(|e| PyValueError::new_err(e.to_string()))
7712        })
7713        .collect()
7714}
7715
7716/// Oriani effective diffusivity `D_eff = D/(1 + K N)` [m²/s] (G3a).
7717#[pyfunction]
7718fn tritium_oriani(diffusivity: f64, equilibrium_constant: f64, site_density: f64) -> PyResult<f64> {
7719    nucleide_tritium::effective_diffusivity(diffusivity, equilibrium_constant, site_density)
7720        .map_err(|e| PyValueError::new_err(e.to_string()))
7721}
7722
7723/// Langmuir equilibrium load `c_t = N K c/(1 + K c)` [mol/m³] (T2-eq).
7724#[pyfunction]
7725fn tritium_langmuir(site_density: f64, equilibrium_constant: f64, c_mobile: f64) -> PyResult<f64> {
7726    nucleide_tritium::equilibrium_trapped(site_density, equilibrium_constant, c_mobile)
7727        .map_err(|e| PyValueError::new_err(e.to_string()))
7728}
7729
7730/// Irreversible-trap fill `c_t(t) = N(1 − e^{−kct})` [mol/m³] at each time (G3c).
7731#[pyfunction]
7732fn tritium_irreversible_fill(
7733    rate_k: f64,
7734    c_mobile: f64,
7735    site_density: f64,
7736    times: Vec<f64>,
7737) -> PyResult<Vec<f64>> {
7738    times
7739        .iter()
7740        .map(|t| {
7741            nucleide_tritium::irreversible_fill(rate_k, c_mobile, site_density, *t)
7742                .map_err(|e| PyValueError::new_err(e.to_string()))
7743        })
7744        .collect()
7745}
7746
7747/// Sieverts surface concentration `c = K_S sqrt(p)` [mol/m³] (G4).
7748#[pyfunction]
7749fn tritium_sieverts(solubility: f64, pressure: f64) -> PyResult<f64> {
7750    nucleide_tritium::sieverts_concentration(solubility, pressure)
7751        .map_err(|e| PyValueError::new_err(e.to_string()))
7752}
7753
7754/// Recombination rate `K_r = kr0 * exp(-e_r / R / temp)` [m⁴/mol/s] (G5).
7755#[pyfunction]
7756fn tritium_recombination_rate(kr0: f64, e_r: f64, temp: f64) -> PyResult<f64> {
7757    nucleide_tritium::recombination_rate_arrhenius(kr0, e_r, temp)
7758        .map_err(|e| PyValueError::new_err(e.to_string()))
7759}
7760
7761/// Parse a layer-spec dict into the core [`nucleide_tritium::LayerSpec`].
7762///
7763/// Keys: `thickness` [m], `cells`, `D` [m²/s], `solubility` (required) —
7764/// the layer's interface constant `K` (`K_S` [mol/m³/Pa¹ᐟ²] under a Sieverts
7765/// law, `K_H` [mol/m³/Pa] under a Henry law, per the adjacent interface's
7766/// law); `E_D` [J/mol] (optional, default 0 = constant diffusivity); `traps`
7767/// (optional list of trap-spec dicts, default none), `temperature`
7768/// (optional, default [500]), `source` (optional). The interface law itself
7769/// is selected by the `interfaces` argument of the layered solves (see
7770/// [`parse_tritium_interfaces`]).
7771fn parse_tritium_layer(
7772    spec: &BTreeMap<String, Py<PyAny>>,
7773    py: Python<'_>,
7774) -> PyResult<nucleide_tritium::LayerSpec> {
7775    let num = |key: &str| -> PyResult<f64> {
7776        spec.get(key)
7777            .ok_or_else(|| PyValueError::new_err(format!("layer spec missing `{key}`")))?
7778            .extract::<f64>(py)
7779            .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number")))
7780    };
7781    let opt = |key: &str| -> PyResult<f64> {
7782        match spec.get(key) {
7783            None => Ok(0.0),
7784            Some(v) => v
7785                .extract::<f64>(py)
7786                .map_err(|_| PyValueError::new_err(format!("`{key}` must be a number"))),
7787        }
7788    };
7789    let traps = match spec.get("traps") {
7790        None => Vec::new(),
7791        Some(v) => v
7792            .extract::<Vec<BTreeMap<String, Py<PyAny>>>>(py)
7793            .map_err(|_| PyValueError::new_err("`traps` must be a list of dicts"))?
7794            .iter()
7795            .map(|s| parse_tritium_trap(s, py))
7796            .collect::<PyResult<_>>()?,
7797    };
7798    let temperature: Vec<f64> = match spec.get("temperature") {
7799        None => vec![500.0],
7800        Some(v) => v
7801            .extract::<Vec<f64>>(py)
7802            .map_err(|_| PyValueError::new_err("`temperature` must be a list of numbers"))?,
7803    };
7804    let source: Vec<f64> = match spec.get("source") {
7805        None => Vec::new(),
7806        Some(v) => v
7807            .extract::<Vec<f64>>(py)
7808            .map_err(|_| PyValueError::new_err("`source` must be a list of numbers"))?,
7809    };
7810    let cells: usize = spec
7811        .get("cells")
7812        .ok_or_else(|| PyValueError::new_err("layer spec missing `cells`"))?
7813        .extract::<usize>(py)
7814        .map_err(|_| PyValueError::new_err("`cells` must be an integer"))?;
7815    nucleide_tritium::LayerSpec::new(
7816        num("thickness")?,
7817        cells,
7818        num("D")?,
7819        opt("E_D")?,
7820        num("solubility")?,
7821        traps,
7822        temperature,
7823        source,
7824    )
7825    .map_err(|e| PyValueError::new_err(e.to_string()))
7826}
7827
7828/// Parse the optional `interfaces` argument of the layered solves into one
7829/// core [`nucleide_tritium::Interface`] per gap between consecutive layers.
7830///
7831/// `None` (the default) selects Sieverts at every gap, so existing
7832/// single-slab and Sieverts-stack calls keep their behavior. `Some(list)`
7833/// must name exactly one law per gap: `"sieverts"` or `"henry"` (linear
7834/// laws — `c/K` continuous with continuous flux, folding into the θ-step
7835/// matrix identically); `"recombination"` maps to the core variant and is
7836/// rejected loudly at stack construction. Unknown spellings are a
7837/// `ValueError`.
7838fn parse_tritium_interfaces(
7839    specs: Option<Vec<String>>,
7840    n_layers: usize,
7841) -> PyResult<Vec<nucleide_tritium::Interface>> {
7842    use nucleide_tritium::Interface as I;
7843    match specs {
7844        None => Ok(vec![I::Sieverts; n_layers.saturating_sub(1)]),
7845        Some(list) => {
7846            if list.len() + 1 != n_layers {
7847                return Err(PyValueError::new_err(format!(
7848                    "need exactly one interface per gap ({n_layers} layers -> {} interfaces, got {})",
7849                    n_layers.saturating_sub(1),
7850                    list.len()
7851                )));
7852            }
7853            list.iter()
7854                .map(|s| match s.to_ascii_lowercase().as_str() {
7855                    "sieverts" => Ok(I::Sieverts),
7856                    "henry" => Ok(I::Henry),
7857                    "recombination" => Ok(I::Recombination),
7858                    other => Err(PyValueError::new_err(format!(
7859                        "unknown tritium interface kind `{other}` (supported: sieverts, henry; recombination interfaces are not supported)"
7860                    ))),
7861                })
7862                .collect()
7863        }
7864    }
7865}
7866
7867fn tritium_layer_stack(
7868    py: Python<'_>,
7869    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7870    interfaces: Option<Vec<String>>,
7871) -> PyResult<nucleide_tritium::LayerStack> {
7872    let parsed: Vec<nucleide_tritium::LayerSpec> = layers
7873        .iter()
7874        .map(|s| parse_tritium_layer(s, py))
7875        .collect::<PyResult<_>>()?;
7876    let interfaces = parse_tritium_interfaces(interfaces, parsed.len())?;
7877    nucleide_tritium::LayerStack::new(parsed, interfaces)
7878        .map_err(|e| PyValueError::new_err(e.to_string()))
7879}
7880
7881/// Trap-free-style steady state of a multi-layer series stack (G7/G9).
7882///
7883/// Thin wrapper over `nucleide_tritium::steady_layers`: `layers` holds one
7884/// spec dict per layer (see `parse_tritium_layer`) and `left`/`right` are
7885/// boundary-spec dicts (see `parse_tritium_boundary`). `interfaces`
7886/// optionally names one internal-interface law per gap — `"sieverts"`
7887/// (default) or `"henry"` (G9); `"recombination"` is rejected loudly by the
7888/// core. A one-layer stack reproduces `tritium_steady` exactly. Returns a
7889/// dict with `centres`, `mobile`, `trapped` (`[cell][trap]`), `flux_left`,
7890/// `flux_right`, `inventory_mobile`, and `inventory_trapped`.
7891#[pyfunction]
7892#[pyo3(signature = (layers, left, right, interfaces=None))]
7893fn tritium_layers_steady(
7894    py: Python<'_>,
7895    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7896    left: BTreeMap<String, Py<PyAny>>,
7897    right: BTreeMap<String, Py<PyAny>>,
7898    interfaces: Option<Vec<String>>,
7899) -> PyResult<Py<PyAny>> {
7900    let stack = tritium_layer_stack(py, layers, interfaces)?;
7901    let left = parse_tritium_boundary(&left, py)?;
7902    let right = parse_tritium_boundary(&right, py)?;
7903    let s = nucleide_tritium::steady_layers(&stack, &left, &right)
7904        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7905    use pyo3::types::PyDict;
7906    let out = PyDict::new(py);
7907    out.set_item("centres", &s.centres).ok();
7908    out.set_item("mobile", &s.mobile).ok();
7909    out.set_item("trapped", &s.trapped).ok();
7910    out.set_item("flux_left", s.flux_left).ok();
7911    out.set_item("flux_right", s.flux_right).ok();
7912    out.set_item("inventory_mobile", s.inventory_mobile).ok();
7913    out.set_item("inventory_trapped", s.inventory_trapped).ok();
7914    Ok(out.into_any().unbind())
7915}
7916
7917/// Solve the multi-layer (T1–T2) transient over the output grid `t` (G8).
7918///
7919/// Thin wrapper over `nucleide_tritium::solve_layers` with the same layer
7920/// stack and boundary arguments as `tritium_layers_steady` (including the
7921/// optional `interfaces` law per gap) plus the output times `t` [s], the
7922/// optional initial profiles (`mobile0` per cell, `trapped0` as
7923/// `[cell][trap]` matching each layer's trap count; both default to zero),
7924/// and the solver options (`method` is `"crank_nicolson"` (default) or
7925/// `"backward_euler"`). Returns a dict with `times`, `mobile`
7926/// (`[time][cell]`), `trapped` (`[time][cell][trap]`), `flux_left`, and
7927/// `flux_right`.
7928#[pyfunction]
7929#[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, interfaces=None))]
7930#[allow(clippy::too_many_arguments)]
7931fn tritium_layers_transient(
7932    py: Python<'_>,
7933    layers: Vec<BTreeMap<String, Py<PyAny>>>,
7934    left: BTreeMap<String, Py<PyAny>>,
7935    right: BTreeMap<String, Py<PyAny>>,
7936    t: Vec<f64>,
7937    mobile0: Option<Vec<f64>>,
7938    trapped0: Option<Vec<Vec<f64>>>,
7939    method: &str,
7940    rtol: f64,
7941    atol: f64,
7942    dt_min: f64,
7943    dt_max: Option<f64>,
7944    max_steps: usize,
7945    interfaces: Option<Vec<String>>,
7946) -> PyResult<Py<PyAny>> {
7947    use nucleide_tritium::{SolverOptions, Theta};
7948    let stack = tritium_layer_stack(py, layers, interfaces)?;
7949    let left = parse_tritium_boundary(&left, py)?;
7950    let right = parse_tritium_boundary(&right, py)?;
7951    let grid =
7952        nucleide_tritium::TimeGrid::new(t).map_err(|e| PyValueError::new_err(e.to_string()))?;
7953    let mobile = mobile0.unwrap_or_else(|| vec![0.0; stack.total_cells()]);
7954    let trapped = trapped0.unwrap_or_else(|| stack.zero_state().trapped);
7955    let initial = nucleide_tritium::InitialState { mobile, trapped };
7956    let theta = if method.eq_ignore_ascii_case("crank_nicolson") {
7957        Theta::CrankNicolson
7958    } else if method.eq_ignore_ascii_case("backward_euler") {
7959        Theta::BackwardEuler
7960    } else {
7961        return Err(PyValueError::new_err(format!(
7962            "unknown tritium method `{method}` (supported: crank_nicolson, backward_euler)"
7963        )));
7964    };
7965    let opts = SolverOptions {
7966        theta,
7967        rtol,
7968        atol,
7969        dt_min,
7970        dt_max: dt_max.unwrap_or(f64::INFINITY),
7971        max_steps,
7972    };
7973    let sol = nucleide_tritium::solve_layers(&stack, &left, &right, &grid, &initial, &opts)
7974        .map_err(|e| PyValueError::new_err(e.to_string()))?;
7975    use pyo3::types::PyDict;
7976    let out = PyDict::new(py);
7977    out.set_item("times", &sol.times).ok();
7978    out.set_item("mobile", &sol.mobile).ok();
7979    out.set_item("trapped", &sol.trapped).ok();
7980    out.set_item("flux_left", &sol.flux_left).ok();
7981    out.set_item("flux_right", &sol.flux_right).ok();
7982    Ok(out.into_any().unbind())
7983}
7984
7985// ---------------------------------------------------------------------------
7986// Spectroscopy (thin glue over `nucleide-spectroscopy`; algorithms stay in core)
7987// ---------------------------------------------------------------------------
7988
7989/// Rectangular smoothing (E1): `m` must be odd and at least 3.
7990#[pyfunction]
7991fn spectroscopy_rect_smooth(counts: Vec<f64>, m: i64) -> PyResult<Vec<f64>> {
7992    let w = usize::try_from(m).map_err(|_| {
7993        PyValueError::new_err(format!("spectroscopy: smoothing width {m} is less than 3"))
7994    })?;
7995    nucleide_spectroscopy::rect_smooth(&counts, w).map_err(|e| PyValueError::new_err(e.to_string()))
7996}
7997
7998/// Five-point smoothing (E2); the first/last two channels are copied.
7999#[pyfunction]
8000fn spectroscopy_five_point_smooth(counts: Vec<f64>) -> PyResult<Vec<f64>> {
8001    nucleide_spectroscopy::five_point_smooth(&counts)
8002        .map_err(|e| PyValueError::new_err(e.to_string()))
8003}
8004
8005/// Background under a peak (E3, `m == 1` only).
8006#[pyfunction]
8007fn spectroscopy_calc_bg(
8008    counts: Vec<f64>,
8009    channels: Vec<f64>,
8010    c1: i64,
8011    c2: i64,
8012    m: i64,
8013) -> PyResult<f64> {
8014    nucleide_spectroscopy::calc_bg(&counts, &channels, c1, c2, m)
8015        .map_err(|e| PyValueError::new_err(e.to_string()))
8016}
8017
8018/// Gross counts between two channels, half-open (E4, excludes `c2`).
8019#[pyfunction]
8020fn spectroscopy_gross_count(
8021    counts: Vec<f64>,
8022    channels: Vec<f64>,
8023    c1: i64,
8024    c2: i64,
8025) -> PyResult<f64> {
8026    nucleide_spectroscopy::gross_count(&counts, &channels, c1, c2)
8027        .map_err(|e| PyValueError::new_err(e.to_string()))
8028}
8029
8030/// Net counts: gross minus background (E5).
8031#[pyfunction]
8032fn spectroscopy_net_counts(
8033    counts: Vec<f64>,
8034    channels: Vec<f64>,
8035    c1: i64,
8036    c2: i64,
8037    m: i64,
8038) -> PyResult<f64> {
8039    nucleide_spectroscopy::net_counts(&counts, &channels, c1, c2, m)
8040        .map_err(|e| PyValueError::new_err(e.to_string()))
8041}
8042
8043/// Energy per channel from the `[a0, a1, a2]` fit (E6).
8044#[pyfunction]
8045fn spectroscopy_energy_bins(channels: Vec<f64>, calib_e_fit: Vec<f64>) -> PyResult<Vec<f64>> {
8046    nucleide_spectroscopy::energy_bins(&channels, &calib_e_fit)
8047        .map_err(|e| PyValueError::new_err(e.to_string()))
8048}
8049
8050/// Detector efficiency at `energy_mev` (E7, energy in MeV, `eff_fit` 1 or 2).
8051#[pyfunction]
8052fn spectroscopy_detector_efficiency(
8053    energy_mev: f64,
8054    eff_coeff: Vec<f64>,
8055    eff_fit: i64,
8056) -> PyResult<f64> {
8057    nucleide_spectroscopy::detector_efficiency(energy_mev, &eff_coeff, eff_fit)
8058        .map_err(|e| PyValueError::new_err(e.to_string()))
8059}
8060
8061/// Efficiency-coefficient fit (E7-fit): log-space weighted least squares over
8062/// caller `(energies_mev, effs, weights)` points with `order + 1` coefficients
8063/// under the `eff_fit` 1 (`(ln E)^j`) or 2 (`(1/E)^j`) basis. Thin wrapper
8064/// over `nucleide-spectroscopy` `fit_efficiency` (which solves through the
8065/// workspace `nucleide-linalg` least-squares kernel).
8066#[pyfunction]
8067#[pyo3(signature = (energies, effs, weights, order, eff_fit=1))]
8068fn spectroscopy_fit_efficiency(
8069    energies: Vec<f64>,
8070    effs: Vec<f64>,
8071    weights: Vec<f64>,
8072    order: usize,
8073    eff_fit: i64,
8074) -> PyResult<Vec<f64>> {
8075    nucleide_spectroscopy::fit_efficiency(&energies, &effs, &weights, order, eff_fit)
8076        .map_err(|e| PyValueError::new_err(e.to_string()))
8077}
8078
8079/// Fetch one caller-supplied atomic constant or raise a `ValueError`.
8080fn atomic_key(atomic: &BTreeMap<String, f64>, key: &str) -> PyResult<f64> {
8081    atomic
8082        .get(key)
8083        .copied()
8084        .ok_or_else(|| PyValueError::new_err(format!("atomic constants missing `{key}`")))
8085}
8086
8087/// X-ray lines (E8) as `[(energy_kev, intensity); Ka1, Ka2, Kb, L]`.
8088///
8089/// `atomic` carries the nine caller-supplied constants (`k_shell_fluor`,
8090/// `l_shell_fluor`, `prob`, `kb_to_ka`, `ka2_to_ka1`, `ka1_en_kev`,
8091/// `ka2_en_kev`, `kb_en_kev`, `l_en_kev`). `None` (or NaN, the upstream
8092/// sentinel) marks a conversion absent. Upstream exposes no combined
8093/// function for this routine — only a material method — so this explicit
8094/// entry point is the documented Nucleide surface.
8095#[pyfunction]
8096#[pyo3(signature = (atomic, k_conv=None, l_conv=None))]
8097fn spectroscopy_xray_lines(
8098    atomic: BTreeMap<String, f64>,
8099    k_conv: Option<f64>,
8100    l_conv: Option<f64>,
8101) -> PyResult<Vec<(f64, f64)>> {
8102    let data = nucleide_spectroscopy::AtomicData {
8103        k_shell_fluor: atomic_key(&atomic, "k_shell_fluor")?,
8104        l_shell_fluor: atomic_key(&atomic, "l_shell_fluor")?,
8105        prob: atomic_key(&atomic, "prob")?,
8106        kb_to_ka: atomic_key(&atomic, "kb_to_ka")?,
8107        ka2_to_ka1: atomic_key(&atomic, "ka2_to_ka1")?,
8108        ka1_en_kev: atomic_key(&atomic, "ka1_en_kev")?,
8109        ka2_en_kev: atomic_key(&atomic, "ka2_en_kev")?,
8110        kb_en_kev: atomic_key(&atomic, "kb_en_kev")?,
8111        l_en_kev: atomic_key(&atomic, "l_en_kev")?,
8112    };
8113    // NaN plays the upstream "conversion absent" sentinel role.
8114    let present = |v: Option<f64>| v.filter(|x| !x.is_nan());
8115    Ok(
8116        nucleide_spectroscopy::xray_lines(&data, present(k_conv), present(l_conv))
8117            .iter()
8118            .map(|l| (l.energy_kev, l.intensity))
8119            .collect(),
8120    )
8121}
8122
8123/// SDEF decay-source card (E9) as `(normalized_bins, card_text)`.
8124///
8125/// `lines` carries caller-supplied `(energy_mev, intensity)` pairs; every
8126/// energy and intensity is an input (no evaluated data is vendored).
8127/// Intensities are merged at duplicate energies, sorted ascending, and
8128/// normalized to probabilities summing to 1.0. The card keeps the upstream
8129/// monoenergetic point-source field order (`POS`, optional `VEC ... DIR=1`,
8130/// `ERG`, `WGT`, `PAR`); one surviving line renders inline `ERG=<E>`, while
8131/// several render the discrete-distribution form `ERG=D1` with paired
8132/// `SI1 L` / `SP1 D` cards. That distribution syntax is parser-verified
8133/// surface only — MCNP sampling semantics are the caller's responsibility.
8134/// `particle` parses through the `nucleide-nuclei` dialect (`"Neutron"`,
8135/// `"Photon"`, `"Electron"`, ...); `version` is 5 or 6 and selects the
8136/// `PAR=` designator.
8137#[pyfunction]
8138#[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))]
8139#[allow(clippy::too_many_arguments)]
8140fn spectroscopy_sdef_decay_source(
8141    lines: Vec<(f64, f64)>,
8142    x: f64,
8143    y: f64,
8144    z: f64,
8145    u: f64,
8146    v: f64,
8147    w: f64,
8148    weight: f64,
8149    particle: &str,
8150    version: u32,
8151) -> PyResult<(Vec<(f64, f64)>, String)> {
8152    let particle = particle
8153        .parse::<nucleide_nuclei::particles::ParticleId>()
8154        .map_err(|e| PyValueError::new_err(format!("spectroscopy: particle {e}")))?;
8155    let source = nucleide_spectroscopy::PointSource {
8156        x,
8157        y,
8158        z,
8159        u,
8160        v,
8161        w,
8162        weight,
8163        particle,
8164    };
8165    nucleide_spectroscopy::sdef_card(&lines, &source, version)
8166        .map_err(|e| PyValueError::new_err(e.to_string()))
8167}
8168
8169/// Render a parsed spectrum as a Python dict.
8170fn spectrum_to_py(
8171    py: Python<'_>,
8172    spec: &nucleide_spectroscopy::GammaSpectrum,
8173) -> PyResult<Py<PyAny>> {
8174    use pyo3::types::PyDict;
8175    let d = PyDict::new(py);
8176    let s = &spec.spectrum;
8177    d.set_item("spec_name", &s.spec_name)?;
8178    d.set_item("start_chan_num", s.start_chan_num)?;
8179    d.set_item("num_channels", s.num_channels)?;
8180    d.set_item("channels", &s.channels)?;
8181    d.set_item("counts", &s.counts)?;
8182    d.set_item("ebin", &s.ebin)?;
8183    d.set_item("real_time", spec.real_time)?;
8184    d.set_item("live_time", spec.live_time)?;
8185    d.set_item("dead_time", spec.dead_time())?;
8186    d.set_item("det_id", &spec.det_id)?;
8187    d.set_item("det_descp", &spec.det_descp)?;
8188    d.set_item("start_date", &spec.start_date)?;
8189    d.set_item("start_time", &spec.start_time)?;
8190    d.set_item("calib_e_fit", &spec.calib_e_fit)?;
8191    d.set_item("calib_fwhm_fit", &spec.calib_fwhm_fit)?;
8192    d.set_item("file_name", &spec.file_name)?;
8193    Ok(d.into_any().unbind())
8194}
8195
8196/// Parse dollar-format `.spe` text (first line must be `$SPEC_ID:`).
8197#[pyfunction]
8198fn spectroscopy_parse_dollar_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
8199    let spec = nucleide_spectroscopy::parse_dollar_spe(text, "")
8200        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8201    spectrum_to_py(py, &spec)
8202}
8203
8204/// Parse plain-format `.spe` text (rejects the `$SPEC_ID:` magic).
8205#[pyfunction]
8206fn spectroscopy_parse_spe(py: Python<'_>, text: &str) -> PyResult<Py<PyAny>> {
8207    let spec = nucleide_spectroscopy::parse_plain_spe(text, "")
8208        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8209    spectrum_to_py(py, &spec)
8210}
8211
8212/// Read a dollar-format `.spe` file.
8213#[pyfunction]
8214fn spectroscopy_read_dollar_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
8215    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
8216    let spec = nucleide_spectroscopy::parse_dollar_spe(&text, path)
8217        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8218    spectrum_to_py(py, &spec)
8219}
8220
8221/// Read a plain-format `.spe` file.
8222#[pyfunction]
8223fn spectroscopy_read_spe(py: Python<'_>, path: &str) -> PyResult<Py<PyAny>> {
8224    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
8225    let spec = nucleide_spectroscopy::parse_plain_spe(&text, path)
8226        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8227    spectrum_to_py(py, &spec)
8228}
8229
8230/// Parse decay-lines interchange TSV text into `(energy_MeV, intensity)`
8231/// pairs (`#` comments and blank lines skipped; E9 normalization stays in
8232/// `sdef_decay_source`).
8233#[pyfunction]
8234fn spectroscopy_parse_lines_tsv(text: &str) -> PyResult<Vec<(f64, f64)>> {
8235    nucleide_spectroscopy::parse_lines_tsv(text).map_err(|e| PyValueError::new_err(e.to_string()))
8236}
8237
8238/// Read a decay-lines interchange TSV file (same grammar as
8239/// `spectroscopy_parse_lines_tsv`).
8240#[pyfunction]
8241fn spectroscopy_read_decay_lines(path: &str) -> PyResult<Vec<(f64, f64)>> {
8242    let text = std::fs::read_to_string(path).map_err(|e| PyValueError::new_err(e.to_string()))?;
8243    nucleide_spectroscopy::parse_lines_tsv(&text).map_err(|e| PyValueError::new_err(e.to_string()))
8244}
8245
8246// ---------------------------------------------------------------------------
8247// UQ-lite sampling kernel (thin glue over `linalg`; decay-only sub-scope)
8248// ---------------------------------------------------------------------------
8249
8250fn uq_sample_err(e: nucleide_linalg::sample::SampleError) -> PyErr {
8251    PyValueError::new_err(e.to_string())
8252}
8253
8254fn uq_decay_err(e: nucleide_linalg::decay::DecayError) -> PyErr {
8255    PyValueError::new_err(e.to_string())
8256}
8257
8258/// Seeded multivariate-normal draws over a caller-supplied covariance block.
8259///
8260/// Returns a dict with `samples` (list of `n` row lists), `method`
8261/// (`"cholesky"` or `"eigen_clip"`), and the unclipped `min_eigen` /
8262/// `max_eigen` (`None` on the Cholesky path). Same
8263/// `(mean, cov, n, seed)` inputs always yield identical samples. Thin
8264/// wrapper over `nucleide-linalg` `sample`.
8265#[pyfunction]
8266fn uq_sample_mvn(
8267    py: Python<'_>,
8268    mean: Vec<f64>,
8269    cov: Vec<Vec<f64>>,
8270    n: usize,
8271    seed: u64,
8272) -> PyResult<Py<PyAny>> {
8273    use pyo3::types::PyDict;
8274    let set = nucleide_linalg::sample::sample_mvn(&mean, &cov, n, seed).map_err(uq_sample_err)?;
8275    let d = PyDict::new(py);
8276    d.set_item("samples", set.samples)?;
8277    d.set_item("method", set.method.name())?;
8278    match &set.method {
8279        nucleide_linalg::sample::FactorMethod::Cholesky => {
8280            d.set_item("min_eigen", py.None())?;
8281            d.set_item("max_eigen", py.None())?;
8282        }
8283        nucleide_linalg::sample::FactorMethod::EigenClip {
8284            min_eigen,
8285            max_eigen,
8286        } => {
8287            d.set_item("min_eigen", *min_eigen)?;
8288            d.set_item("max_eigen", *max_eigen)?;
8289        }
8290    }
8291    Ok(d.into_any().unbind())
8292}
8293
8294/// Sample mean over draws (one entry per dimension).
8295#[pyfunction]
8296fn uq_sample_mean(samples: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
8297    nucleide_linalg::sample::sample_mean(&samples).map_err(uq_sample_err)
8298}
8299
8300/// Unbiased sample covariance (`1/(n-1)`, matching SANDY `Samples.get_cov`).
8301#[pyfunction]
8302fn uq_sample_cov(samples: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
8303    nucleide_linalg::sample::sample_cov(&samples).map_err(uq_sample_err)
8304}
8305
8306/// Sample mean/covariance convergence diagnostics à la SANDY.
8307///
8308/// Returns a dict with `mean_err_max`, `cov_err_fro`, the echoed
8309/// `mean_tol`/`cov_tol`, and `passed`. Thin wrapper over
8310/// `nucleide-linalg` `sample`.
8311#[pyfunction]
8312fn uq_check_convergence(
8313    py: Python<'_>,
8314    mean: Vec<f64>,
8315    cov: Vec<Vec<f64>>,
8316    samples: Vec<Vec<f64>>,
8317    mean_tol: f64,
8318    cov_tol: f64,
8319) -> PyResult<Py<PyAny>> {
8320    use pyo3::types::PyDict;
8321    let rep = nucleide_linalg::sample::check_convergence(&mean, &cov, &samples, mean_tol, cov_tol)
8322        .map_err(uq_sample_err)?;
8323    let d = PyDict::new(py);
8324    d.set_item("mean_err_max", rep.mean_err_max)?;
8325    d.set_item("cov_err_fro", rep.cov_err_fro)?;
8326    d.set_item("mean_tol", rep.mean_tol)?;
8327    d.set_item("cov_tol", rep.cov_tol)?;
8328    d.set_item("passed", rep.passed)?;
8329    Ok(d.into_any().unbind())
8330}
8331
8332/// Perturb one parent's kept branch fractions with relative deltas,
8333/// preserving the incoming `1 - BR(SF)` deficit by renormalisation.
8334#[pyfunction]
8335fn uq_perturb_branches(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
8336    nucleide_linalg::decay::perturb_branches(&base, &rel).map_err(uq_decay_err)
8337}
8338
8339/// Perturb decay energies under `convention`
8340/// (`"relative"`/`"absolute"`/`"lognormal"`); negative results clamp to zero
8341/// (a no-op for lognormal draws, which stay positive for non-negative bases).
8342#[pyfunction]
8343fn uq_perturb_energies(base: Vec<f64>, delta: Vec<f64>, convention: &str) -> PyResult<Vec<f64>> {
8344    let conv = nucleide_linalg::sample::PerturbConvention::parse(convention)
8345        .map_err(PyValueError::new_err)?;
8346    nucleide_linalg::decay::perturb_energies(&base, &delta, conv).map_err(uq_decay_err)
8347}
8348
8349/// Seeded log-normal draws: `x ~ N(mean_log, cov_log)` via the shared MVN
8350/// factor path and RNG, then `y = exp(x)` elementwise.
8351///
8352/// Returns the same dict shape as [`uq_sample_mvn`]; `mean_log`/`cov_log`
8353/// are log-space MVN parameters (never the moments of `y`). Thin wrapper
8354/// over `nucleide-linalg` `sample`.
8355#[pyfunction]
8356fn uq_sample_lognormal(
8357    py: Python<'_>,
8358    mean_log: Vec<f64>,
8359    cov: Vec<Vec<f64>>,
8360    n: usize,
8361    seed: u64,
8362) -> PyResult<Py<PyAny>> {
8363    use pyo3::types::PyDict;
8364    let set = nucleide_linalg::sample::sample_lognormal(&mean_log, &cov, n, seed)
8365        .map_err(uq_sample_err)?;
8366    let d = PyDict::new(py);
8367    d.set_item("samples", set.samples)?;
8368    d.set_item("method", set.method.name())?;
8369    match &set.method {
8370        nucleide_linalg::sample::FactorMethod::Cholesky => {
8371            d.set_item("min_eigen", py.None())?;
8372            d.set_item("max_eigen", py.None())?;
8373        }
8374        nucleide_linalg::sample::FactorMethod::EigenClip {
8375            min_eigen,
8376            max_eigen,
8377        } => {
8378            d.set_item("min_eigen", *min_eigen)?;
8379            d.set_item("max_eigen", *max_eigen)?;
8380        }
8381    }
8382    Ok(d.into_any().unbind())
8383}
8384
8385/// Seeded Latin-hypercube draws over a caller-supplied covariance block.
8386///
8387/// Stratified `U(0,1)` draws (one jittered draw per stratum per dimension)
8388/// through the hand-rolled inverse-normal CDF, then the shared MVN factor
8389/// path and `x = μ + Bz` application. Returns the same dict shape as
8390/// [`uq_sample_mvn`]. Thin wrapper over `nucleide-linalg` `sample`.
8391#[pyfunction]
8392fn uq_sample_lhs(
8393    py: Python<'_>,
8394    mean: Vec<f64>,
8395    cov: Vec<Vec<f64>>,
8396    n: usize,
8397    seed: u64,
8398) -> PyResult<Py<PyAny>> {
8399    use pyo3::types::PyDict;
8400    let set = nucleide_linalg::sample::sample_lhs(&mean, &cov, n, seed).map_err(uq_sample_err)?;
8401    let d = PyDict::new(py);
8402    d.set_item("samples", set.samples)?;
8403    d.set_item("method", set.method.name())?;
8404    match &set.method {
8405        nucleide_linalg::sample::FactorMethod::Cholesky => {
8406            d.set_item("min_eigen", py.None())?;
8407            d.set_item("max_eigen", py.None())?;
8408        }
8409        nucleide_linalg::sample::FactorMethod::EigenClip {
8410            min_eigen,
8411            max_eigen,
8412        } => {
8413            d.set_item("min_eigen", *min_eigen)?;
8414            d.set_item("max_eigen", *max_eigen)?;
8415        }
8416    }
8417    Ok(d.into_any().unbind())
8418}
8419
8420/// Closed-form log-normal mean `E[y_i] = exp(mu_i + C_ii/2)` over the
8421/// log-space `(mean_log, cov)` parameters.
8422#[pyfunction]
8423fn uq_lognormal_mean(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<f64>> {
8424    nucleide_linalg::sample::lognormal_mean(&mean_log, &cov).map_err(uq_sample_err)
8425}
8426
8427/// Closed-form log-normal covariance
8428/// `Cov(y_i, y_j) = exp(mu_i + mu_j + (C_ii + C_jj)/2) (exp(C_ij) - 1)`.
8429#[pyfunction]
8430fn uq_lognormal_cov(mean_log: Vec<f64>, cov: Vec<Vec<f64>>) -> PyResult<Vec<Vec<f64>>> {
8431    nucleide_linalg::sample::lognormal_cov(&mean_log, &cov).map_err(uq_sample_err)
8432}
8433
8434/// Passthrough copy of a perturbation vector (finiteness-checked).
8435#[pyfunction]
8436fn uq_passthrough(delta: Vec<f64>) -> PyResult<Vec<f64>> {
8437    nucleide_linalg::decay::passthrough(&delta).map_err(uq_decay_err)
8438}
8439
8440/// Fission-yield perturbation over a caller-supplied block (same deficit
8441/// discipline as `perturb_branches`, preserving the incoming sum).
8442#[pyfunction]
8443fn uq_perturb_fission_yields(base: Vec<f64>, rel: Vec<f64>) -> PyResult<Vec<f64>> {
8444    nucleide_linalg::decay::perturb_fission_yields(&base, &rel).map_err(uq_decay_err)
8445}
8446
8447// ---------------------------------------------------------------------------
8448// Thin reader facade bundle over existing Rust (no new math/data)
8449// ---------------------------------------------------------------------------
8450
8451fn parse_projectile(flag: &str) -> PyResult<nucleide_nuclei::rxname::Projectile> {
8452    flag.parse::<nucleide_nuclei::rxname::Projectile>()
8453        .map_err(|e| PyValueError::new_err(e.to_string()))
8454}
8455
8456fn resolve_rx_id(spec: &Bound<'_, PyAny>) -> PyResult<u32> {
8457    if let Ok(id) = spec.extract::<u32>() {
8458        return Ok(id);
8459    }
8460    if let Ok(s) = spec.extract::<&str>() {
8461        return nucleide_nuclei::rxname::name_to_id(s)
8462            .map_err(|e| PyValueError::new_err(e.to_string()));
8463    }
8464    Err(PyTypeError::new_err(
8465        "expected reaction id (int) or name (str)",
8466    ))
8467}
8468
8469/// Short `"(z,a)"`-style label for a reaction id ("" when unknown).
8470#[pyfunction]
8471fn rxname_label(id: u32) -> &'static str {
8472    nucleide_nuclei::rxname::label(id)
8473}
8474
8475/// Long documentation string for a reaction id ("" when unknown).
8476#[pyfunction]
8477fn rxname_doc(id: u32) -> &'static str {
8478    nucleide_nuclei::rxname::doc(id)
8479}
8480
8481/// Registry row for a reaction id as {id, name, mt, label, doc}, or None.
8482#[pyfunction]
8483fn rxname_reaction(py: Python<'_>, id: u32) -> PyResult<Option<Py<PyAny>>> {
8484    use pyo3::types::PyDict;
8485    Ok(nucleide_nuclei::rxname::reaction(id).map(|r| {
8486        let d = PyDict::new(py);
8487        d.set_item("id", r.id).ok();
8488        d.set_item("name", r.name).ok();
8489        d.set_item("mt", r.mt).ok();
8490        d.set_item("label", r.label).ok();
8491        d.set_item("doc", r.doc).ok();
8492        d.into_any().unbind()
8493    }))
8494}
8495
8496/// Reaction channel connecting `from_nucid` to `to_nucid` under `projectile`.
8497#[pyfunction]
8498#[pyo3(signature = (from_nucid, to_nucid, projectile="n"))]
8499fn rxname_id_from_nucdelta(from_nucid: u32, to_nucid: u32, projectile: &str) -> PyResult<u32> {
8500    let p = parse_projectile(projectile)?;
8501    nucleide_nuclei::rxname::id_from_nucdelta(from_nucid, to_nucid, p)
8502        .map_err(|e| PyValueError::new_err(e.to_string()))
8503}
8504
8505/// Daughter nuclide (GNDS name) when `parent` undergoes `rx` under `projectile`.
8506#[pyfunction]
8507#[pyo3(signature = (parent, rx, projectile="n"))]
8508fn rxname_child(parent: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
8509    let p = parse_projectile(projectile)?;
8510    let rx = resolve_rx_id(rx)?;
8511    let parent_id = NuclideId::from_name(parent)
8512        .map_err(|e| PyValueError::new_err(format!("`{parent}`: {e}")))?;
8513    nucleide_nuclei::rxname::child(parent_id, rx, p)
8514        .map(|id| id.to_name())
8515        .map_err(|e| PyValueError::new_err(e.to_string()))
8516}
8517
8518/// Parent nuclide (GNDS name) whose `rx` under `projectile` yields `child`.
8519#[pyfunction]
8520#[pyo3(signature = (child, rx, projectile="n"))]
8521fn rxname_parent(child: &str, rx: &Bound<'_, PyAny>, projectile: &str) -> PyResult<String> {
8522    let p = parse_projectile(projectile)?;
8523    let rx = resolve_rx_id(rx)?;
8524    let child_id = NuclideId::from_name(child)
8525        .map_err(|e| PyValueError::new_err(format!("`{child}`: {e}")))?;
8526    nucleide_nuclei::rxname::parent(child_id, rx, p)
8527        .map(|id| id.to_name())
8528        .map_err(|e| PyValueError::new_err(e.to_string()))
8529}
8530
8531/// True when `spec` names a particle or a nuclide (hydrogen or heavy ion).
8532#[pyfunction]
8533fn particle_is_valid(spec: &str) -> bool {
8534    nucleide_nuclei::particles::is_valid(spec)
8535}
8536
8537/// True when `n` is a registered PDC number.
8538#[pyfunction]
8539fn particle_is_valid_pdc(n: i32) -> bool {
8540    nucleide_nuclei::particles::is_valid_pdc(n)
8541}
8542
8543/// True when `spec` is ground-state hydrogen.
8544#[pyfunction]
8545fn particle_is_hydrogen(spec: &str) -> bool {
8546    nucleide_nuclei::particles::is_hydrogen(spec)
8547}
8548
8549/// True when `spec` is a nuclide heavier than ground-state hydrogen.
8550#[pyfunction]
8551fn particle_is_heavy_ion(spec: &str) -> bool {
8552    nucleide_nuclei::particles::is_heavy_ion(spec)
8553}
8554
8555/// Gut-uptake fraction `f1` for ingestion rows, or None (source default EPA).
8556#[pyfunction]
8557#[pyo3(signature = (name, source="EPA"))]
8558fn dose_f1(name: &str, source: &str) -> PyResult<Option<f64>> {
8559    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
8560    let s = parse_dose_source(source)?;
8561    Ok(nucleide_nuclei::data::dose_f1_by_name(name, s))
8562}
8563
8564/// Lung-clearance class for inhalation rows, or None (source default EPA).
8565#[pyfunction]
8566#[pyo3(signature = (name, source="EPA"))]
8567fn dose_lung_model(name: &str, source: &str) -> PyResult<Option<char>> {
8568    NuclideId::from_name(name).map_err(wrap_nucid_err)?;
8569    let s = parse_dose_source(source)?;
8570    Ok(nucleide_nuclei::data::dose_lung_model_by_name(name, s))
8571}
8572
8573/// Canonical element symbol for a bare-symbol comp key, or None.
8574fn bare_element_z(name: &str) -> Option<u32> {
8575    let t = name.trim();
8576    if t.is_empty() {
8577        return None;
8578    }
8579    let mut chars = t.chars();
8580    let first = chars.next()?.to_uppercase().next()?;
8581    let rest: String = chars.collect::<String>().to_lowercase();
8582    let canon = format!("{first}{rest}");
8583    nucleide_nuclei::element_z(&canon)
8584}
8585
8586fn mat_from_comp_elements(comp: BTreeMap<String, f64>) -> PyResult<nucleide_material::Material> {
8587    let mut mat = nucleide_material::Material::new();
8588    for (name, grams) in &comp {
8589        let id = match NuclideId::from_name(name) {
8590            Ok(id) => id,
8591            Err(_) => match bare_element_z(name) {
8592                Some(z) => NuclideId::from_nucid(z * 10_000_000),
8593                None => {
8594                    return Err(PyValueError::new_err(format!(
8595                        "`{name}`: unknown nuclide or element"
8596                    )));
8597                }
8598            },
8599        };
8600        mat.add_nuclide(id, *grams);
8601    }
8602    Ok(mat)
8603}
8604
8605fn mat_to_comp_elements(mat: &nucleide_material::Material) -> BTreeMap<String, f64> {
8606    let mut out = BTreeMap::new();
8607    for (&id, &grams) in &mat.comp {
8608        let key = if id.a() == 0 && id.state() == 0 {
8609            nucleide_nuclei::element_symbol(id.z())
8610                .unwrap_or("X")
8611                .to_string()
8612        } else {
8613            id.to_name()
8614        };
8615        *out.entry(key).or_insert(0.0) += grams;
8616    }
8617    out
8618}
8619
8620/// Mix streams weighted by relative mass amounts (thin wrapper over
8621/// `Material::mix_by_mass`). Bare element symbols map to natural-element
8622/// placeholders; collapsed/elemental keys round-trip as symbols.
8623#[pyfunction]
8624fn mix_by_mass(parts: Vec<(BTreeMap<String, f64>, f64)>) -> PyResult<BTreeMap<String, f64>> {
8625    let mats: Vec<nucleide_material::Material> = parts
8626        .iter()
8627        .map(|(comp, _)| mat_from_comp_elements(comp.clone()))
8628        .collect::<PyResult<_>>()?;
8629    let refs: Vec<(&nucleide_material::Material, f64)> =
8630        mats.iter().zip(parts.iter().map(|(_, w)| *w)).collect();
8631    let out = nucleide_material::Material::mix_by_mass(&refs)
8632        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8633    Ok(mat_to_comp_elements(&out))
8634}
8635
8636/// Mix streams weighted by relative volumes, converting through each
8637/// stream's density (thin wrapper over `Material::mix_by_volume`).
8638/// `parts` holds `(comp, volume, density)` triples.
8639#[pyfunction]
8640fn mix_by_volume(parts: Vec<(BTreeMap<String, f64>, f64, f64)>) -> PyResult<BTreeMap<String, f64>> {
8641    let mut mats: Vec<nucleide_material::Material> = Vec::with_capacity(parts.len());
8642    for (comp, _, density) in &parts {
8643        let mut m = mat_from_comp_elements(comp.clone())?;
8644        m.set_density(Some(*density));
8645        mats.push(m);
8646    }
8647    let refs: Vec<(&nucleide_material::Material, f64)> =
8648        mats.iter().zip(parts.iter().map(|(_, v, _)| *v)).collect();
8649    let out = nucleide_material::Material::mix_by_volume(&refs)
8650        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8651    Ok(mat_to_comp_elements(&out))
8652}
8653
8654/// Specific activity of a composition in Bq/g (AME2020 + chain decays).
8655#[pyfunction]
8656fn specific_activity(comp: BTreeMap<String, f64>) -> PyResult<f64> {
8657    let mat = mat_from_comp_elements(comp)?;
8658    let analytics = nucleide_material::Analytics {
8659        masses: &nucleide_material::Ame2020,
8660        decays: &nucleide_material::ChainDecays,
8661    };
8662    mat.specific_activity(&analytics)
8663        .map_err(|e| PyValueError::new_err(e.to_string()))
8664}
8665
8666/// Serialize a `<materials>` document bundling named materials.
8667/// `entries` holds `(name, comp, density)` triples; `cross_sections`
8668/// sets the root attribute when given.
8669#[pyfunction]
8670#[pyo3(signature = (entries, cross_sections=None))]
8671fn materials_doc_to_xml(
8672    entries: Vec<(String, BTreeMap<String, f64>, f64)>,
8673    cross_sections: Option<String>,
8674) -> PyResult<String> {
8675    let mut doc = nucleide_material::MaterialsDoc::new();
8676    if let Some(path) = cross_sections {
8677        doc = doc.cross_sections(path);
8678    }
8679    for (name, comp, density) in entries {
8680        let mut mat = mat_from_comp_elements(comp)?;
8681        mat.set_density(Some(density));
8682        doc = doc.push(name, mat);
8683    }
8684    doc.to_xml()
8685        .map_err(|e| PyValueError::new_err(e.to_string()))
8686}
8687
8688/// Replace natural-element placeholders with isotopic breakdowns (AME2020 +
8689/// natural abundances). Bare element symbols are placeholders; nuclide
8690/// names pass through untouched.
8691#[pyfunction]
8692fn expand_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
8693    let mut mat = mat_from_comp_elements(comp)?;
8694    mat.expand_elements(
8695        &nucleide_material::Ame2020,
8696        &nucleide_material::NaturalAbundances,
8697    )
8698    .map_err(|e| PyValueError::new_err(e.to_string()))?;
8699    Ok(mat_to_comp_elements(&mat))
8700}
8701
8702/// Fold every nuclide into its element placeholder (bare-symbol keys).
8703#[pyfunction]
8704fn collapse_elements(comp: BTreeMap<String, f64>) -> PyResult<BTreeMap<String, f64>> {
8705    let mat = mat_from_comp_elements(comp)?;
8706    Ok(mat_to_comp_elements(&mat.collapse_elements()))
8707}
8708
8709fn parse_fluka_nuc(spec: &str) -> PyResult<nucleide_fluka_io::material::FlukaNuc> {
8710    use nucleide_fluka_io::material::FlukaNuc;
8711    if let Ok(id) = NuclideId::from_name(spec) {
8712        return Ok(FlukaNuc::Nuclide(id));
8713    }
8714    if let Some(z) = bare_element_z(spec) {
8715        return Ok(FlukaNuc::Element(z));
8716    }
8717    if let Ok(z) = spec.trim().parse::<u32>() {
8718        if nucleide_nuclei::element_symbol(z).is_some() {
8719            return Ok(FlukaNuc::Element(z));
8720        }
8721    }
8722    Err(PyValueError::new_err(format!(
8723        "`{spec}`: unknown nuclide or element"
8724    )))
8725}
8726
8727/// Render the MATERIAL record for an elemental nuclide ("", when builtin).
8728#[pyfunction]
8729fn fluka_material_str(fid: u32, nuc: &str, density: f64) -> PyResult<String> {
8730    let parsed = parse_fluka_nuc(nuc)?;
8731    nucleide_fluka_io::material::material_str(fid, parsed, density)
8732        .map_err(|e| PyValueError::new_err(e.to_string()))
8733}
8734
8735/// Render MATERIAL + COMPOUND records for a compound.
8736/// `frac_type` is "mass" (default) or "atom"; `components` holds
8737/// `(nuclide-or-element, fraction)` pairs.
8738#[pyfunction]
8739#[pyo3(signature = (fid, compound_name, density, frac_type="mass", components=None))]
8740fn fluka_compound_str(
8741    fid: u32,
8742    compound_name: &str,
8743    density: f64,
8744    frac_type: &str,
8745    components: Option<Vec<(String, f64)>>,
8746) -> PyResult<String> {
8747    use nucleide_fluka_io::material::{Component, FracType};
8748    let frac = match frac_type.trim().to_ascii_lowercase().as_str() {
8749        "mass" => FracType::Mass,
8750        "atom" => FracType::Atom,
8751        other => {
8752            return Err(PyValueError::new_err(format!(
8753                "frac_type must be mass|atom, got `{other}`"
8754            )));
8755        }
8756    };
8757    let pairs = components.unwrap_or_default();
8758    let comps: Vec<Component> = pairs
8759        .iter()
8760        .map(|(nuc, frac)| parse_fluka_nuc(nuc).map(|n| Component::new(n, *frac)))
8761        .collect::<PyResult<_>>()?;
8762    nucleide_fluka_io::material::compound_str(fid, compound_name, density, frac, &comps)
8763        .map_err(|e| PyValueError::new_err(e.to_string()))
8764}
8765
8766/// Sorted built-in FLUKA material names.
8767#[pyfunction]
8768fn fluka_builtin_set() -> Vec<String> {
8769    let mut out: Vec<String> = nucleide_fluka_io::material::builtin_set()
8770        .into_iter()
8771        .map(str::to_string)
8772        .collect();
8773    out.sort();
8774    out
8775}
8776
8777/// Validate an ALARA deck's cross-references (parse + `validate`).
8778#[pyfunction]
8779fn alara_validate_deck(text: &str) -> PyResult<()> {
8780    let deck = nucleide_alara_io::AlaraDeck::parse(text)
8781        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8782    deck.validate()
8783        .map_err(|e| PyValueError::new_err(e.to_string()))
8784}
8785
8786/// Reject an unknown ALARA block keyword (`line` is 1-based).
8787#[pyfunction]
8788fn alara_check_block(block: &str, line: usize) -> PyResult<()> {
8789    nucleide_alara_io::AlaraDeck::check_block(block, line)
8790        .map_err(|e| PyValueError::new_err(e.to_string()))
8791}
8792
8793/// Sum of an ALARA group-flux spectrum over its groups.
8794#[pyfunction]
8795fn alara_flux_total(name: &str, text: &str) -> PyResult<f64> {
8796    nucleide_alara_io::FluxSpec::parse(name, text)
8797        .map(|f| f.total())
8798        .map_err(|e| PyValueError::new_err(e.to_string()))
8799}
8800
8801/// Number of groups in an ALARA group-flux spectrum.
8802#[pyfunction]
8803fn alara_flux_len(name: &str, text: &str) -> PyResult<usize> {
8804    nucleide_alara_io::FluxSpec::parse(name, text)
8805        .map(|f| f.len())
8806        .map_err(|e| PyValueError::new_err(e.to_string()))
8807}
8808
8809/// Keep only the `total` aggregate rows of an ALARA/FISPACT response frame.
8810#[pyfunction]
8811fn alara_output_totals(
8812    py: Python<'_>,
8813    text: &str,
8814    run_lbl: &str,
8815) -> PyResult<Vec<BTreeMap<String, Py<PyAny>>>> {
8816    let owned_text = text.to_owned();
8817    let owned_lbl = run_lbl.to_owned();
8818    let frame = py
8819        .detach(move || {
8820            nucleide_alara_io::output::ResponseFrame::parse(&owned_text, &owned_lbl)
8821                .map(|f| f.totals())
8822        })
8823        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8824    Ok(frame
8825        .rows
8826        .iter()
8827        .map(|r| fispact_row_to_map(py, r))
8828        .collect())
8829}
8830
8831/// Sum of `value` over SpecificActivity rows of a response frame.
8832#[pyfunction]
8833fn alara_output_total_activity(text: &str, run_lbl: &str) -> PyResult<f64> {
8834    nucleide_alara_io::output::ResponseFrame::parse(text, run_lbl)
8835        .map(|f| f.total_activity())
8836        .map_err(|e| PyValueError::new_err(e.to_string()))
8837}
8838
8839/// Sum over every group and strength of a `.photonSrc` listing.
8840#[pyfunction]
8841fn alara_photon_total_strength(text: &str) -> PyResult<f64> {
8842    nucleide_alara_io::PhotonSource::from_str(text)
8843        .map(|p| p.total_strength())
8844        .map_err(|e| PyValueError::new_err(e.to_string()))
8845}
8846
8847/// Total schedule time in seconds over a deck's expanded flat steps.
8848#[pyfunction]
8849#[pyo3(signature = (deck_text, top=None))]
8850fn alara_schedule_total_time(deck_text: &str, top: Option<&str>) -> PyResult<f64> {
8851    let owned = deck_text.to_owned();
8852    let owned_top = top.map(str::to_owned);
8853    let steps =
8854        expand_deck_schedules(&owned, owned_top.as_deref()).map_err(PyValueError::new_err)?;
8855    Ok(nucleide_alara_io::schedule::total_time(&steps))
8856}
8857
8858/// Vendored EU 2013/59/Euratom Annex VII Table A clearance levels.
8859///
8860/// Returns a dict mapping nuclide names (GNDS spelling, e.g. `"H-3"`,
8861/// `"Co-60"`) to activity-concentration clearance levels in Bq/g (numerically
8862/// identical to the directive's kBq/kg). Official legal text transcribed from
8863/// EUR-Lex CELEX:32013L0059 (Annex VII Table A, accessed 2026-09-15),
8864/// reusable with attribution per Decision (EU) 2011/833. Screening default
8865/// only — see `nucleide-alara-io` `clearance` for the unit-basis contract.
8866#[pyfunction]
8867fn alara_clearance_eu_table() -> BTreeMap<String, f64> {
8868    nucleide_alara_io::ClearanceTable::eu_annex_vii()
8869        .iter()
8870        .map(|(nuc, limit)| (nucleide_nuclei::dialects::serpent(nuc), limit))
8871        .collect()
8872}
8873
8874/// Vendored Spanish CSN conditional NORM clearance levels for landfill
8875/// disposal (draft technical opinion CSN/PDT/AICD/TGE/2503/02, TGE/VAR/2025/1,
8876/// hosted on csn.es, accessed 2026-09-16; RD 1029/2022 and RD 1217/2024
8877/// (RINR), both transposing Directive 2013/59/Euratom).
8878///
8879/// `landfill` selects the source table: `"inert"` (Tabla 1), `"non_hazardous"`
8880/// (Tabla 2), or `"hazardous"` (Tabla 3). `material` selects the source
8881/// column: `"rocks"` (ROCAS), `"ashes"` (CENIZAS), `"sands"` (ARENAS),
8882/// `"slags"` (ESCORIAS), or `"oil_gas"` (GAS/PETROLEO). Returns a dict mapping
8883/// nuclide names (Serpent spelling) to activity-concentration clearance
8884/// levels in Bq/g, with the source Tabla 4 chain keys expanded to per-member
8885/// entries at the parent value. The caller selects the table explicitly — no
8886/// cross-table logic; screening arithmetic only, never a compliance decision.
8887#[pyfunction]
8888fn alara_clearance_es_table(landfill: &str, material: &str) -> PyResult<BTreeMap<String, f64>> {
8889    use nucleide_alara_io::EsNormMaterial;
8890    let material = match material {
8891        "rocks" => EsNormMaterial::Rocks,
8892        "ashes" => EsNormMaterial::Ashes,
8893        "sands" => EsNormMaterial::Sands,
8894        "slags" => EsNormMaterial::Slags,
8895        "oil_gas" => EsNormMaterial::OilGas,
8896        other => {
8897            return Err(PyValueError::new_err(format!(
8898                "bad material `{other}` (expected one of: rocks, ashes, sands, slags, oil_gas)"
8899            )));
8900        }
8901    };
8902    let table = match landfill {
8903        "inert" => nucleide_alara_io::ClearanceTable::es_conditional_inert(material),
8904        "non_hazardous" => {
8905            nucleide_alara_io::ClearanceTable::es_conditional_non_hazardous(material)
8906        }
8907        "hazardous" => nucleide_alara_io::ClearanceTable::es_conditional_hazardous(material),
8908        other => {
8909            return Err(PyValueError::new_err(format!(
8910                "bad landfill `{other}` (expected one of: inert, non_hazardous, hazardous)"
8911            )));
8912        }
8913    };
8914    Ok(table
8915        .iter()
8916        .map(|(nuc, limit)| (nucleide_nuclei::dialects::serpent(nuc), limit))
8917        .collect())
8918}
8919
8920/// Resolve a caller-supplied inventory/limits dict key to a `NuclideId`.
8921fn clearance_key(key: &str) -> PyResult<NuclideId> {
8922    nucleide_nuclei::dialects::normalize_nuclide_name(key)
8923        .map_err(|e| PyValueError::new_err(format!("bad nuclide name `{key}`: {e}")))
8924}
8925
8926/// Build `(NuclideId, f64)` pairs from a `{name: value}` dict.
8927fn clearance_pairs(map: &BTreeMap<String, f64>, what: &str) -> PyResult<Vec<(NuclideId, f64)>> {
8928    map.iter()
8929        .map(|(name, value)| Ok((clearance_key(name)?, *value)))
8930        .collect::<PyResult<_>>()
8931        .map_err(|e| PyValueError::new_err(format!("{what}: {e}")))
8932}
8933
8934/// Build a caller-supplied clearance table from a `{name: limit_Bq_per_g}` dict.
8935fn clearance_table_from(
8936    map: &BTreeMap<String, f64>,
8937) -> PyResult<nucleide_alara_io::ClearanceTable> {
8938    let mut table = nucleide_alara_io::ClearanceTable::new();
8939    for (nuc, limit) in clearance_pairs(map, "limits")? {
8940        table
8941            .insert(nuc, limit)
8942            .map_err(|e| PyValueError::new_err(e.to_string()))?;
8943    }
8944    Ok(table)
8945}
8946
8947/// Clearance index CI = sum_i A_i / CL_i over a parsed inventory.
8948///
8949/// `inventory` maps nuclide names to activities; `limits` maps nuclide names
8950/// to clearance levels (a dict, or None for the vendored EU 2013/59/Euratom
8951/// Annex VII Table A default in Bq/g). Activities and limits must share one
8952/// unit basis (Bq/g against the default table). Every inventory nuclide must
8953/// have a limit entry; negative/non-finite activities raise `ValueError`.
8954#[pyfunction]
8955#[pyo3(signature = (inventory, limits=None))]
8956fn alara_clearance_index(
8957    inventory: BTreeMap<String, f64>,
8958    limits: Option<BTreeMap<String, f64>>,
8959) -> PyResult<f64> {
8960    let table = match limits {
8961        Some(map) => clearance_table_from(&map)?,
8962        None => nucleide_alara_io::ClearanceTable::eu_annex_vii(),
8963    };
8964    let pairs = clearance_pairs(&inventory, "inventory")?;
8965    nucleide_alara_io::clearance_index(&pairs, &table)
8966        .map_err(|e| PyValueError::new_err(e.to_string()))
8967}
8968
8969/// Sum-of-fractions screening over a parsed inventory.
8970///
8971/// Same inputs as `alara_clearance_index`. Returns a dict with `sum`
8972/// (sum_i A_i / CL_i), `class` ("satisfied" when the sum does not exceed 1,
8973/// boundary included; "exceeded" otherwise), `max_fraction`, and
8974/// `max_nuclide` (dominant contributor, or None for an empty inventory).
8975/// RS-G-1.7 §5 rule referenced by
8976/// designation; screening arithmetic, not a compliance decision.
8977#[pyfunction]
8978#[pyo3(signature = (inventory, limits=None))]
8979fn alara_sum_of_fractions(
8980    py: Python<'_>,
8981    inventory: BTreeMap<String, f64>,
8982    limits: Option<BTreeMap<String, f64>>,
8983) -> PyResult<BTreeMap<String, Py<PyAny>>> {
8984    let table = match limits {
8985        Some(map) => clearance_table_from(&map)?,
8986        None => nucleide_alara_io::ClearanceTable::eu_annex_vii(),
8987    };
8988    let pairs = clearance_pairs(&inventory, "inventory")?;
8989    let out = nucleide_alara_io::sum_of_fractions(&pairs, &table)
8990        .map_err(|e| PyValueError::new_err(e.to_string()))?;
8991    let mut d = BTreeMap::new();
8992    d.insert(
8993        "sum".to_string(),
8994        out.sum.into_pyobject(py).unwrap().unbind().into_any(),
8995    );
8996    d.insert(
8997        "class".to_string(),
8998        out.class
8999            .to_string()
9000            .into_pyobject(py)
9001            .unwrap()
9002            .unbind()
9003            .into_any(),
9004    );
9005    d.insert(
9006        "max_fraction".to_string(),
9007        out.max_fraction
9008            .into_pyobject(py)
9009            .unwrap()
9010            .unbind()
9011            .into_any(),
9012    );
9013    d.insert(
9014        "max_nuclide".to_string(),
9015        out.max_nuclide
9016            .map(nucleide_nuclei::dialects::serpent)
9017            .into_pyobject(py)
9018            .unwrap()
9019            .unbind()
9020            .into_any(),
9021    );
9022    Ok(d)
9023}
9024
9025/// Find a TAPE6 record by nuclide name, or None.
9026#[pyfunction]
9027fn origen_tape6_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
9028    use pyo3::types::PyDict;
9029    let owned = text.to_owned();
9030    let query = nuclide.to_owned();
9031    let found = py
9032        .detach(move || nucleide_origen_io::Tape6::parse(&owned).map(|t| t.find(&query).cloned()))
9033        .map_err(|e| PyValueError::new_err(e.to_string()))?;
9034    Ok(found.map(|r| {
9035        let d = PyDict::new(py);
9036        d.set_item("nuclide", &r.nuclide).ok();
9037        d.set_item("grams", r.grams).ok();
9038        d.set_item("activity_bq", r.activity_bq).ok();
9039        d.into_any().unbind()
9040    }))
9041}
9042
9043/// Total TAPE6 inventory activity in becquerel.
9044#[pyfunction]
9045fn origen_tape6_total_activity(text: &str) -> PyResult<f64> {
9046    nucleide_origen_io::Tape6::parse(text)
9047        .map(|t| t.total_activity())
9048        .map_err(|e| PyValueError::new_err(e.to_string()))
9049}
9050
9051/// Find a TAPE9 decay entry by nuclide name, or None.
9052#[pyfunction]
9053fn origen_tape9_find(py: Python<'_>, text: &str, nuclide: &str) -> PyResult<Option<Py<PyAny>>> {
9054    use pyo3::types::PyDict;
9055    let owned = text.to_owned();
9056    let query = nuclide.to_owned();
9057    let found = py
9058        .detach(move || {
9059            nucleide_origen_io::Tape9Entry::parse(&owned)
9060                .map(|entries| nucleide_origen_io::Tape9Entry::find(&entries, &query).cloned())
9061        })
9062        .map_err(|e| PyValueError::new_err(e.to_string()))?;
9063    Ok(found.map(|e| {
9064        let d = PyDict::new(py);
9065        d.set_item("nuclide", &e.nuclide).ok();
9066        d.set_item("decay_const", e.decay_const).ok();
9067        d.into_any().unbind()
9068    }))
9069}
9070
9071/// Number of spatial points in an RTFLUX/ATFLUX/RZFLUX file.
9072#[pyfunction]
9073#[pyo3(signature = (text, kind="rtflux"))]
9074fn cccc_rtflux_npoints(text: &str, kind: &str) -> PyResult<usize> {
9075    let flux_kind = parse_flux_kind(kind)?;
9076    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
9077        .map(|f| f.npoints())
9078        .map_err(|e| PyValueError::new_err(e.to_string()))
9079}
9080
9081/// Flux vector for point `i`, or None when out of range.
9082#[pyfunction]
9083#[pyo3(signature = (text, kind="rtflux", index=0))]
9084fn cccc_rtflux_point(text: &str, kind: &str, index: usize) -> PyResult<Option<Vec<f64>>> {
9085    let flux_kind = parse_flux_kind(kind)?;
9086    let flux = nucleide_cccc_io::FluxFile::parse(flux_kind, text)
9087        .map_err(|e| PyValueError::new_err(e.to_string()))?;
9088    Ok(flux.point(index).map(<[f64]>::to_vec))
9089}
9090
9091/// Sum of all flux values in an RTFLUX/ATFLUX/RZFLUX file.
9092#[pyfunction]
9093#[pyo3(signature = (text, kind="rtflux"))]
9094fn cccc_rtflux_total(text: &str, kind: &str) -> PyResult<f64> {
9095    let flux_kind = parse_flux_kind(kind)?;
9096    nucleide_cccc_io::FluxFile::parse(flux_kind, text)
9097        .map(|f| f.total())
9098        .map_err(|e| PyValueError::new_err(e.to_string()))
9099}
9100
9101fn parse_flux_kind(kind: &str) -> PyResult<nucleide_cccc_io::rtflux::FluxKind> {
9102    match kind.to_ascii_lowercase().as_str() {
9103        "rtflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rtflux),
9104        "atflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Atflux),
9105        "rzflux" => Ok(nucleide_cccc_io::rtflux::FluxKind::Rzflux),
9106        other => Err(PyValueError::new_err(format!(
9107            "kind must be rtflux|atflux|rzflux, got `{other}`"
9108        ))),
9109    }
9110}
9111
9112/// Find an ISOTXS nuclide by label, or None.
9113#[pyfunction]
9114fn cccc_isotxs_find(py: Python<'_>, text: &str, label: &str) -> PyResult<Option<Py<PyAny>>> {
9115    use pyo3::types::PyDict;
9116    let owned = text.to_owned();
9117    let query = label.to_owned();
9118    let found = py
9119        .detach(move || {
9120            nucleide_cccc_io::IsotxsLib::parse(&owned).map(|lib| lib.find(&query).cloned())
9121        })
9122        .map_err(|e| PyValueError::new_err(e.to_string()))?;
9123    Ok(found.map(|n| {
9124        let d = PyDict::new(py);
9125        d.set_item("label", &n.label).ok();
9126        d.set_item("zaid", &n.zaid).ok();
9127        d.set_item("groups", n.groups).ok();
9128        d.set_item("total_xs", n.total_xs.clone()).ok();
9129        d.into_any().unbind()
9130    }))
9131}
9132
9133/// Number of nuclides in an ISOTXS library.
9134#[pyfunction]
9135fn cccc_isotxs_len(text: &str) -> PyResult<usize> {
9136    nucleide_cccc_io::IsotxsLib::parse(text)
9137        .map(|lib| lib.len())
9138        .map_err(|e| PyValueError::new_err(e.to_string()))
9139}
9140
9141/// Identify a FISPACT-II output by its `.fis` suffix convention.
9142#[pyfunction]
9143fn fispact_is_output(path: &str) -> bool {
9144    nucleide_fispact_io::is_fispact_output(path)
9145}
9146
9147/// Product-per-feed mass ratio for assays `x_feed`, `x_prod`, `x_tail`.
9148#[pyfunction]
9149fn enrichment_prod_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9150    nucleide_enrichment::prod_per_feed(x_feed, x_prod, x_tail)
9151}
9152
9153/// Tails-per-feed mass ratio.
9154#[pyfunction]
9155fn enrichment_tail_per_feed(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9156    nucleide_enrichment::tail_per_feed(x_feed, x_prod, x_tail)
9157}
9158
9159/// Tails-per-product mass ratio.
9160#[pyfunction]
9161fn enrichment_tail_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9162    nucleide_enrichment::tail_per_prod(x_feed, x_prod, x_tail)
9163}
9164
9165/// Feed-per-product mass ratio.
9166#[pyfunction]
9167fn enrichment_feed_per_prod(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9168    nucleide_enrichment::feed_per_prod(x_feed, x_prod, x_tail)
9169}
9170
9171/// Feed-per-tails mass ratio.
9172#[pyfunction]
9173fn enrichment_feed_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9174    nucleide_enrichment::feed_per_tail(x_feed, x_prod, x_tail)
9175}
9176
9177/// Product-per-tails mass ratio.
9178#[pyfunction]
9179fn enrichment_prod_per_tail(x_feed: f64, x_prod: f64, x_tail: f64) -> f64 {
9180    nucleide_enrichment::prod_per_tail(x_feed, x_prod, x_tail)
9181}
9182
9183/// Stage separation factor for a component of mass `m_i`.
9184#[pyfunction]
9185#[allow(non_snake_case)]
9186fn enrichment_alphastar_i(alpha: f64, Mstar: f64, M_i: f64) -> f64 {
9187    nucleide_enrichment::alphastar_i(alpha, Mstar, M_i)
9188}
9189
9190/// Validated delayed-neutron data from OpenMC IFP kinetics data.
9191///
9192/// OpenMC's IFP estimator reports effective delayed fractions (`betas`)
9193/// and the generation time (`lambda_gen`) but no precursor decay
9194/// constants: the caller supplies `lambdas` from the same data library.
9195/// Returns {betas, lambdas, lambda_gen, beta_total, groups}.
9196#[pyfunction]
9197fn kinetics_from_ifp(
9198    py: Python<'_>,
9199    betas: Vec<f64>,
9200    lambda_gen: f64,
9201    lambdas: Vec<f64>,
9202) -> PyResult<Py<PyAny>> {
9203    use pyo3::types::PyDict;
9204    let params = nucleide_kinetics::KineticParams::from_ifp(betas, lambda_gen, lambdas)
9205        .map_err(|e| PyValueError::new_err(e.to_string()))?;
9206    let d = PyDict::new(py);
9207    d.set_item("betas", params.betas()).ok();
9208    d.set_item("lambdas", params.lambdas()).ok();
9209    d.set_item("lambda_gen", params.lambda_gen()).ok();
9210    d.set_item("beta_total", params.beta_total()).ok();
9211    d.set_item("groups", params.groups()).ok();
9212    Ok(d.into_any().unbind())
9213}
9214
9215/// Run MAGIC with explicit array selection and parameters.
9216/// `selection` is "total" (default) or "per_group".
9217#[pyfunction]
9218#[pyo3(signature = (tally, selection="total", tolerance=0.5, null_value=0.0))]
9219fn magic_with(
9220    tally: &PyMeshTally,
9221    selection: &str,
9222    tolerance: f64,
9223    null_value: f64,
9224) -> PyResult<PyMagicOutput> {
9225    let sel = match selection.trim().to_ascii_lowercase().as_str() {
9226        "total" => nucleide_vr_tools::magic::MagicSelection::Total,
9227        "per_group" | "pergroup" | "per-group" => {
9228            nucleide_vr_tools::magic::MagicSelection::PerGroup
9229        }
9230        other => {
9231            return Err(PyValueError::new_err(format!(
9232                "selection must be total|per_group, got `{other}`"
9233            )));
9234        }
9235    };
9236    let params = nucleide_vr_tools::magic::MagicParams {
9237        tolerance,
9238        null_value,
9239    };
9240    nucleide_vr_tools::magic::magic_with(&tally.inner, sel, params)
9241        .map(|inner| PyMagicOutput { inner })
9242        .map_err(|e| PyValueError::new_err(e.to_string()))
9243}
9244
9245/// Emit MAGIC weight windows as an OpenMC `settings.xml` fragment (`<mesh>`
9246/// + `<weight_windows>` elements to paste inside the existing `<settings>`
9247/// root). Returns `{"xml": str, "notes": list[str]}`.
9248#[pyfunction]
9249#[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))]
9250#[allow(clippy::too_many_arguments)]
9251fn emit_openmc_weight_windows(
9252    py: Python<'_>,
9253    tally: &PyMeshTally,
9254    output: &PyMagicOutput,
9255    mesh_id: u32,
9256    window_id: u32,
9257    upper_bound_ratio: f64,
9258    survival_ratio: f64,
9259    max_split: u32,
9260    weight_cutoff: f64,
9261) -> PyResult<Py<PyAny>> {
9262    use pyo3::types::PyDict;
9263    let options = nucleide_vr_tools::windows::OpenMcOptions {
9264        mesh_id,
9265        window_id,
9266        upper_bound_ratio,
9267        survival_ratio,
9268        max_split,
9269        weight_cutoff,
9270    };
9271    let out = nucleide_vr_tools::windows::emit_openmc_weight_windows(
9272        &output.inner,
9273        &tally.inner,
9274        &options,
9275    )
9276    .map_err(|e| PyValueError::new_err(e.to_string()))?;
9277    let d = PyDict::new(py);
9278    d.set_item("xml", out.xml)?;
9279    d.set_item("notes", out.notes)?;
9280    Ok(d.into_any().unbind())
9281}
9282
9283/// Emit MAGIC weight windows as a Serpent-readable weight-window file in the
9284/// MCNP WWINP text spelling (`wwin <name> wf "<file>" 2`). Returns
9285/// `{"text": str, "card": str, "notes": list[str]}`.
9286#[pyfunction]
9287#[pyo3(signature = (tally, output, name="ww1", file="wwindows.wwd"))]
9288fn emit_serpent_wwin(
9289    py: Python<'_>,
9290    tally: &PyMeshTally,
9291    output: &PyMagicOutput,
9292    name: &str,
9293    file: &str,
9294) -> PyResult<Py<PyAny>> {
9295    use pyo3::types::PyDict;
9296    let out =
9297        nucleide_vr_tools::windows::emit_serpent_wwin(&output.inner, &tally.inner, name, file)
9298            .map_err(|e| PyValueError::new_err(e.to_string()))?;
9299    let d = PyDict::new(py);
9300    d.set_item("text", out.text)?;
9301    d.set_item("card", out.card)?;
9302    d.set_item("notes", out.notes)?;
9303    Ok(d.into_any().unbind())
9304}
9305
9306/// Check one `stat:sum:<key>:<24-char value>` MCPL header comment.
9307#[pyfunction]
9308fn mcpl_statsum_validate(comment: &str) -> PyResult<String> {
9309    nucleide_mcpl_io::statsum_validate(comment)
9310        .map(str::to_string)
9311        .map_err(PyValueError::new_err)
9312}
9313
9314/// Build a well-formed `stat:sum:` MCPL header comment.
9315#[pyfunction]
9316fn mcpl_statsum_comment(key: &str, value: f64) -> PyResult<String> {
9317    nucleide_mcpl_io::statsum_comment(key, value).map_err(|e| PyValueError::new_err(e.to_string()))
9318}
9319
9320/// Python module entry point.
9321#[pymodule]
9322fn _internal(m: &Bound<'_, PyModule>) -> PyResult<()> {
9323    m.add_function(wrap_pyfunction!(version, m)?)?;
9324    m.add_function(wrap_pyfunction!(from_zaid, m)?)?;
9325    m.add_function(wrap_pyfunction!(atomic_mass, m)?)?;
9326    m.add_function(wrap_pyfunction!(natural_abundance, m)?)?;
9327    m.add_function(wrap_pyfunction!(rxname_id, m)?)?;
9328    m.add_function(wrap_pyfunction!(rxname_name, m)?)?;
9329    m.add_function(wrap_pyfunction!(rxname_mt, m)?)?;
9330    m.add_function(wrap_pyfunction!(rxname_label, m)?)?;
9331    m.add_function(wrap_pyfunction!(rxname_doc, m)?)?;
9332    m.add_function(wrap_pyfunction!(rxname_reaction, m)?)?;
9333    m.add_function(wrap_pyfunction!(rxname_id_from_nucdelta, m)?)?;
9334    m.add_function(wrap_pyfunction!(rxname_child, m)?)?;
9335    m.add_function(wrap_pyfunction!(rxname_parent, m)?)?;
9336    m.add_function(wrap_pyfunction!(particle_is_valid, m)?)?;
9337    m.add_function(wrap_pyfunction!(particle_is_valid_pdc, m)?)?;
9338    m.add_function(wrap_pyfunction!(particle_is_hydrogen, m)?)?;
9339    m.add_function(wrap_pyfunction!(particle_is_heavy_ion, m)?)?;
9340    m.add_function(wrap_pyfunction!(dose_f1, m)?)?;
9341    m.add_function(wrap_pyfunction!(dose_lung_model, m)?)?;
9342    m.add_function(wrap_pyfunction!(read_xsdir, m)?)?;
9343    m.add_function(wrap_pyfunction!(read_meshtal, m)?)?;
9344    m.add_function(wrap_pyfunction!(read_wwinp, m)?)?;
9345    m.add_function(wrap_pyfunction!(read_mctal, m)?)?;
9346    m.add_function(wrap_pyfunction!(read_ssw, m)?)?;
9347    m.add_function(wrap_pyfunction!(read_ptrac, m)?)?;
9348    m.add_function(wrap_pyfunction!(read_mcpl, m)?)?;
9349    m.add_function(wrap_pyfunction!(write_mcpl, m)?)?;
9350    m.add_function(wrap_pyfunction!(ssw2mcpl, m)?)?;
9351    m.add_function(wrap_pyfunction!(mcpl2ssw, m)?)?;
9352    m.add_function(wrap_pyfunction!(merge_mcpl, m)?)?;
9353    m.add_function(wrap_pyfunction!(extract_mcpl, m)?)?;
9354    m.add_function(wrap_pyfunction!(mcpl_stats, m)?)?;
9355    m.add_function(wrap_pyfunction!(repair_mcpl, m)?)?;
9356    m.add_function(wrap_pyfunction!(read_endl, m)?)?;
9357    m.add_function(wrap_pyfunction!(endl_endftod, m)?)?;
9358    m.add_function(wrap_pyfunction!(combine_ssw_files, m)?)?;
9359    m.add_function(wrap_pyfunction!(read_chain, m)?)?;
9360    m.add_function(wrap_pyfunction!(build_depletion_system, m)?)?;
9361    m.add_function(wrap_pyfunction!(deplete, m)?)?;
9362    m.add_function(wrap_pyfunction!(deplete_series, m)?)?;
9363    m.add_function(wrap_pyfunction!(simple_xs, m)?)?;
9364    m.add_function(wrap_pyfunction!(scattering_length, m)?)?;
9365    m.add_function(wrap_pyfunction!(decay_energy, m)?)?;
9366    m.add_function(wrap_pyfunction!(decay_branches, m)?)?;
9367    m.add_function(wrap_pyfunction!(decay_branch_fraction, m)?)?;
9368    m.add_function(wrap_pyfunction!(fission_yields, m)?)?;
9369    m.add_function(wrap_pyfunction!(fission_yield, m)?)?;
9370    m.add_function(wrap_pyfunction!(normalize_nuclide, m)?)?;
9371    m.add_function(wrap_pyfunction!(decay_heat, m)?)?;
9372    m.add_function(wrap_pyfunction!(dose_factor, m)?)?;
9373    m.add_function(wrap_pyfunction!(dose_per_g, m)?)?;
9374    m.add_function(wrap_pyfunction!(parse_fgr15_table, m)?)?;
9375    m.add_function(wrap_pyfunction!(fgr15_age_index, m)?)?;
9376    m.add_function(wrap_pyfunction!(parse_irdff_g725, m)?)?;
9377    m.add_function(wrap_pyfunction!(mix_by_mass, m)?)?;
9378    m.add_function(wrap_pyfunction!(mix_by_volume, m)?)?;
9379    m.add_function(wrap_pyfunction!(specific_activity, m)?)?;
9380    m.add_function(wrap_pyfunction!(materials_doc_to_xml, m)?)?;
9381    m.add_function(wrap_pyfunction!(expand_elements, m)?)?;
9382    m.add_function(wrap_pyfunction!(collapse_elements, m)?)?;
9383    m.add_function(wrap_pyfunction!(read_serpent, m)?)?;
9384    m.add_function(wrap_pyfunction!(read_usrbin, m)?)?;
9385    m.add_function(wrap_pyfunction!(fluka_material_str, m)?)?;
9386    m.add_function(wrap_pyfunction!(fluka_compound_str, m)?)?;
9387    m.add_function(wrap_pyfunction!(fluka_builtin_set, m)?)?;
9388    m.add_function(wrap_pyfunction!(magic, m)?)?;
9389    m.add_function(wrap_pyfunction!(magic_with, m)?)?;
9390    m.add_function(wrap_pyfunction!(emit_openmc_weight_windows, m)?)?;
9391    m.add_function(wrap_pyfunction!(emit_serpent_wwin, m)?)?;
9392    m.add_function(wrap_pyfunction!(write_ssw, m)?)?;
9393    m.add_function(wrap_pyfunction!(mesh_to_geom, m)?)?;
9394    m.add_function(wrap_pyfunction!(half_life, m)?)?;
9395    m.add_function(wrap_pyfunction!(decay_constant, m)?)?;
9396    m.add_function(wrap_pyfunction!(q_value_capture, m)?)?;
9397    m.add_function(wrap_pyfunction!(q_value_alpha, m)?)?;
9398    m.add_function(wrap_pyfunction!(read_inp, m)?)?;
9399    m.add_function(wrap_pyfunction!(from_formula, m)?)?;
9400    m.add_function(wrap_pyfunction!(activity, m)?)?;
9401    m.add_function(wrap_pyfunction!(to_xml, m)?)?;
9402    m.add_function(wrap_pyfunction!(alara_parse_deck, m)?)?;
9403    m.add_function(wrap_pyfunction!(alara_parse_flux, m)?)?;
9404    m.add_function(wrap_pyfunction!(alara_parse_output, m)?)?;
9405    m.add_function(wrap_pyfunction!(alara_expand_schedule, m)?)?;
9406    m.add_function(wrap_pyfunction!(alara_validate_deck, m)?)?;
9407    m.add_function(wrap_pyfunction!(alara_check_block, m)?)?;
9408    m.add_function(wrap_pyfunction!(alara_flux_total, m)?)?;
9409    m.add_function(wrap_pyfunction!(alara_flux_len, m)?)?;
9410    m.add_function(wrap_pyfunction!(alara_output_totals, m)?)?;
9411    m.add_function(wrap_pyfunction!(alara_output_total_activity, m)?)?;
9412    m.add_function(wrap_pyfunction!(alara_photon_total_strength, m)?)?;
9413    m.add_function(wrap_pyfunction!(alara_schedule_total_time, m)?)?;
9414    m.add_function(wrap_pyfunction!(alara_clearance_eu_table, m)?)?;
9415    m.add_function(wrap_pyfunction!(alara_clearance_es_table, m)?)?;
9416    m.add_function(wrap_pyfunction!(alara_clearance_index, m)?)?;
9417    m.add_function(wrap_pyfunction!(alara_sum_of_fractions, m)?)?;
9418    m.add_function(wrap_pyfunction!(isotxs_parse, m)?)?;
9419    m.add_function(wrap_pyfunction!(rtflux_parse, m)?)?;
9420    m.add_function(wrap_pyfunction!(cccc_rtflux_npoints, m)?)?;
9421    m.add_function(wrap_pyfunction!(cccc_rtflux_point, m)?)?;
9422    m.add_function(wrap_pyfunction!(cccc_rtflux_total, m)?)?;
9423    m.add_function(wrap_pyfunction!(cccc_isotxs_find, m)?)?;
9424    m.add_function(wrap_pyfunction!(cccc_isotxs_len, m)?)?;
9425    m.add_function(wrap_pyfunction!(partisn_render, m)?)?;
9426    m.add_function(wrap_pyfunction!(partisn_validate, m)?)?;
9427    m.add_function(wrap_pyfunction!(fispact_parse_output, m)?)?;
9428    m.add_function(wrap_pyfunction!(fispact_parse_clearance, m)?)?;
9429    m.add_function(wrap_pyfunction!(fispact_is_output, m)?)?;
9430    m.add_function(wrap_pyfunction!(origen_parse_tape5, m)?)?;
9431    m.add_function(wrap_pyfunction!(origen_parse_tape6, m)?)?;
9432    m.add_function(wrap_pyfunction!(origen_parse_tape9, m)?)?;
9433    m.add_function(wrap_pyfunction!(origen_tape6_find, m)?)?;
9434    m.add_function(wrap_pyfunction!(origen_tape6_total_activity, m)?)?;
9435    m.add_function(wrap_pyfunction!(origen_tape9_find, m)?)?;
9436    m.add_function(wrap_pyfunction!(r2s_from_deck, m)?)?;
9437    m.add_function(wrap_pyfunction!(r2s_from_snapshot, m)?)?;
9438    m.add_function(wrap_pyfunction!(r2s_validate, m)?)?;
9439    m.add_function(wrap_pyfunction!(r2s_expand, m)?)?;
9440    m.add_function(wrap_pyfunction!(r2s_assemble, m)?)?;
9441    m.add_function(wrap_pyfunction!(r2s_tag_zone_strength, m)?)?;
9442    m.add_function(wrap_pyfunction!(r2s_photon_group_sums, m)?)?;
9443    m.add_function(wrap_pyfunction!(r2s_snapshot_inventory, m)?)?;
9444    m.add_function(wrap_pyfunction!(r2s_expand_sweep, m)?)?;
9445    m.add_function(wrap_pyfunction!(kinetics_solve, m)?)?;
9446    m.add_function(wrap_pyfunction!(kinetics_equilibrium, m)?)?;
9447    m.add_function(wrap_pyfunction!(kinetics_initial_rate, m)?)?;
9448    m.add_function(wrap_pyfunction!(kinetics_inhour_rho, m)?)?;
9449    m.add_function(wrap_pyfunction!(kinetics_stable_period, m)?)?;
9450    m.add_function(wrap_pyfunction!(kinetics_prompt_jump, m)?)?;
9451    m.add_function(wrap_pyfunction!(kinetics_from_ifp, m)?)?;
9452    m.add_function(wrap_pyfunction!(unfold_sandii, m)?)?;
9453    m.add_function(wrap_pyfunction!(unfold_staysl, m)?)?;
9454    m.add_function(wrap_pyfunction!(unfold_gravel, m)?)?;
9455    m.add_function(wrap_pyfunction!(unfold_forward_fold, m)?)?;
9456    m.add_function(wrap_pyfunction!(plasma_source_particles, m)?)?;
9457    m.add_function(wrap_pyfunction!(plasma_source_emit_cards, m)?)?;
9458    m.add_function(wrap_pyfunction!(plasma_source_spectrum_moments, m)?)?;
9459    m.add_function(wrap_pyfunction!(plasma_source_reactivity, m)?)?;
9460    m.add_function(wrap_pyfunction!(damage_nrt_dpa, m)?)?;
9461    m.add_function(wrap_pyfunction!(damage_arc_dpa, m)?)?;
9462    m.add_function(wrap_pyfunction!(damage_gas_appm, m)?)?;
9463    m.add_function(wrap_pyfunction!(damage_he_dpa_ratio, m)?)?;
9464    m.add_function(wrap_pyfunction!(damage_lindhard_partition, m)?)?;
9465    m.add_function(wrap_pyfunction!(damage_damage_energy, m)?)?;
9466    m.add_function(wrap_pyfunction!(damage_nrt_displacements, m)?)?;
9467    m.add_function(wrap_pyfunction!(damage_arc_efficiency, m)?)?;
9468    m.add_function(wrap_pyfunction!(damage_arc_displacements, m)?)?;
9469    m.add_function(wrap_pyfunction!(damage_fold_uq, m)?)?;
9470    m.add_function(wrap_pyfunction!(damage_specter_table, m)?)?;
9471    m.add_function(wrap_pyfunction!(damage_specter_damage_energy, m)?)?;
9472    m.add_function(wrap_pyfunction!(damage_specter_ed, m)?)?;
9473    m.add_function(wrap_pyfunction!(damage_specter_spectra, m)?)?;
9474    m.add_function(wrap_pyfunction!(tritium_steady, m)?)?;
9475    m.add_function(wrap_pyfunction!(tritium_transient, m)?)?;
9476    m.add_function(wrap_pyfunction!(tritium_time_lag, m)?)?;
9477    m.add_function(wrap_pyfunction!(tritium_breakthrough, m)?)?;
9478    m.add_function(wrap_pyfunction!(tritium_oriani, m)?)?;
9479    m.add_function(wrap_pyfunction!(tritium_langmuir, m)?)?;
9480    m.add_function(wrap_pyfunction!(tritium_irreversible_fill, m)?)?;
9481    m.add_function(wrap_pyfunction!(tritium_sieverts, m)?)?;
9482    m.add_function(wrap_pyfunction!(tritium_recombination_rate, m)?)?;
9483    m.add_function(wrap_pyfunction!(tritium_layers_steady, m)?)?;
9484    m.add_function(wrap_pyfunction!(tritium_layers_transient, m)?)?;
9485    m.add_function(wrap_pyfunction!(spectroscopy_rect_smooth, m)?)?;
9486    m.add_function(wrap_pyfunction!(spectroscopy_five_point_smooth, m)?)?;
9487    m.add_function(wrap_pyfunction!(spectroscopy_calc_bg, m)?)?;
9488    m.add_function(wrap_pyfunction!(spectroscopy_gross_count, m)?)?;
9489    m.add_function(wrap_pyfunction!(spectroscopy_net_counts, m)?)?;
9490    m.add_function(wrap_pyfunction!(spectroscopy_energy_bins, m)?)?;
9491    m.add_function(wrap_pyfunction!(spectroscopy_detector_efficiency, m)?)?;
9492    m.add_function(wrap_pyfunction!(spectroscopy_fit_efficiency, m)?)?;
9493    m.add_function(wrap_pyfunction!(spectroscopy_xray_lines, m)?)?;
9494    m.add_function(wrap_pyfunction!(spectroscopy_sdef_decay_source, m)?)?;
9495    m.add_function(wrap_pyfunction!(spectroscopy_parse_dollar_spe, m)?)?;
9496    m.add_function(wrap_pyfunction!(spectroscopy_parse_spe, m)?)?;
9497    m.add_function(wrap_pyfunction!(spectroscopy_read_dollar_spe, m)?)?;
9498    m.add_function(wrap_pyfunction!(spectroscopy_read_spe, m)?)?;
9499    m.add_function(wrap_pyfunction!(spectroscopy_parse_lines_tsv, m)?)?;
9500    m.add_function(wrap_pyfunction!(spectroscopy_read_decay_lines, m)?)?;
9501    m.add_function(wrap_pyfunction!(uq_sample_mvn, m)?)?;
9502    m.add_function(wrap_pyfunction!(uq_sample_lhs, m)?)?;
9503    m.add_function(wrap_pyfunction!(uq_sample_lognormal, m)?)?;
9504    m.add_function(wrap_pyfunction!(uq_lognormal_mean, m)?)?;
9505    m.add_function(wrap_pyfunction!(uq_lognormal_cov, m)?)?;
9506    m.add_function(wrap_pyfunction!(uq_sample_mean, m)?)?;
9507    m.add_function(wrap_pyfunction!(uq_sample_cov, m)?)?;
9508    m.add_function(wrap_pyfunction!(uq_check_convergence, m)?)?;
9509    m.add_function(wrap_pyfunction!(uq_perturb_branches, m)?)?;
9510    m.add_function(wrap_pyfunction!(uq_perturb_energies, m)?)?;
9511    m.add_function(wrap_pyfunction!(uq_passthrough, m)?)?;
9512    m.add_function(wrap_pyfunction!(uq_perturb_fission_yields, m)?)?;
9513    m.add_function(wrap_pyfunction!(parse_deck, m)?)?;
9514    m.add_function(wrap_pyfunction!(read_deck, m)?)?;
9515    m.add_function(wrap_pyfunction!(parse_sdef, m)?)?;
9516    m.add_function(wrap_pyfunction!(parse_csg_to_openmc, m)?)?;
9517    m.add_function(wrap_pyfunction!(read_csg_to_openmc, m)?)?;
9518    m.add_function(wrap_pyfunction!(parse_csg_to_serpent, m)?)?;
9519    m.add_function(wrap_pyfunction!(read_csg_to_serpent, m)?)?;
9520    m.add_function(wrap_pyfunction!(parse_csg_to_phits, m)?)?;
9521    m.add_function(wrap_pyfunction!(read_csg_to_phits, m)?)?;
9522    m.add_function(wrap_pyfunction!(parse_csg_to_gdml, m)?)?;
9523    m.add_function(wrap_pyfunction!(read_csg_to_gdml, m)?)?;
9524    m.add_function(wrap_pyfunction!(cumulative_decays, m)?)?;
9525    m.add_function(wrap_pyfunction!(progeny, m)?)?;
9526    m.add_function(wrap_pyfunction!(branching_fraction, m)?)?;
9527    m.add_function(wrap_pyfunction!(decay_mode, m)?)?;
9528    m.add_function(wrap_pyfunction!(chain_edges, m)?)?;
9529    m.add_function(wrap_pyfunction!(armi_to_nucid, m)?)?;
9530    m.add_function(wrap_pyfunction!(nucid_to_armi, m)?)?;
9531    m.add_function(wrap_pyfunction!(mcc3_to_nucid, m)?)?;
9532    m.add_function(wrap_pyfunction!(check_labels, m)?)?;
9533    m.add_function(wrap_pyfunction!(audit_material, m)?)?;
9534    m.add_function(wrap_pyfunction!(separate_material, m)?)?;
9535    m.add_function(wrap_pyfunction!(blend_material, m)?)?;
9536    m.add_function(wrap_pyfunction!(enrichment_value_func, m)?)?;
9537    m.add_function(wrap_pyfunction!(enrichment_swu_per_feed, m)?)?;
9538    m.add_function(wrap_pyfunction!(enrichment_swu_per_prod, m)?)?;
9539    m.add_function(wrap_pyfunction!(enrichment_swu_per_tail, m)?)?;
9540    m.add_function(wrap_pyfunction!(enrichment_prod_per_feed, m)?)?;
9541    m.add_function(wrap_pyfunction!(enrichment_tail_per_feed, m)?)?;
9542    m.add_function(wrap_pyfunction!(enrichment_tail_per_prod, m)?)?;
9543    m.add_function(wrap_pyfunction!(enrichment_feed_per_prod, m)?)?;
9544    m.add_function(wrap_pyfunction!(enrichment_feed_per_tail, m)?)?;
9545    m.add_function(wrap_pyfunction!(enrichment_prod_per_tail, m)?)?;
9546    m.add_function(wrap_pyfunction!(enrichment_alphastar_i, m)?)?;
9547    m.add_function(wrap_pyfunction!(mcpl_statsum_validate, m)?)?;
9548    m.add_function(wrap_pyfunction!(mcpl_statsum_comment, m)?)?;
9549    m.add_class::<PyCusum>()?;
9550    m.add_function(wrap_pyfunction!(emit_cards, m)?)?;
9551    m.add_function(wrap_pyfunction!(emit_drift_table, m)?)?;
9552    m.add_function(wrap_pyfunction!(emit_armi_cards, m)?)?;
9553    m.add_function(wrap_pyfunction!(emit_armi_drift_table, m)?)?;
9554    m.add_class::<PyNuclide>()?;
9555    m.add_class::<PyParticle>()?;
9556    m.add_class::<PyXsdir>()?;
9557    m.add_class::<PyXsdirTable>()?;
9558    m.add_class::<PyMeshtal>()?;
9559    m.add_class::<PyMeshTally>()?;
9560    m.add_class::<PyWwinp>()?;
9561    m.add_class::<PyMctal>()?;
9562    m.add_class::<PySurfSrc>()?;
9563    m.add_class::<PyPtracFile>()?;
9564    m.add_class::<PyMcplFile>()?;
9565    m.add_class::<PyEndlLibrary>()?;
9566    m.add_class::<PyChain>()?;
9567    m.add_class::<PyDepletionSystem>()?;
9568    m.add_class::<PyUsrbinTally>()?;
9569    m.add_class::<PyMagicOutput>()?;
9570    m.add_class::<PyAliasTable>()?;
9571    m.add_class::<PyMeshSourceSampler>()?;
9572    m.add_class::<PyKdeSampler>()?;
9573    m.add_class::<PyCascade>()?;
9574    m.add_class::<PyMaterialsCompendium>()?;
9575    m.add_class::<PyDeckProblem>()?;
9576    m.add_class::<PyInventory>()?;
9577    Ok(())
9578}