Skip to main content

laddu_python/
quantum.rs

1pub mod angular_momentum {
2    use laddu_core::{
3        allowed_projections, AngularMomentum, LadduError, LadduResult, OrbitalAngularMomentum,
4        Projection,
5    };
6    use num::rational::Ratio;
7    use pyo3::{
8        prelude::*,
9        types::{PyAny, PyBool, PyModule},
10        IntoPyObjectExt,
11    };
12    type PyQuantumNumber = Py<PyAny>;
13
14    pub fn parse_angular_momentum(input: &Bound<'_, PyAny>) -> PyResult<AngularMomentum> {
15        Ok(parse_ratio_like(input).and_then(AngularMomentum::try_from)?)
16    }
17
18    fn parse_ratio_like(input: &Bound<'_, PyAny>) -> LadduResult<Ratio<i32>> {
19        if input.is_instance_of::<PyBool>() {
20            return Err(LadduError::Custom(
21                "quantum number cannot be a bool".to_string(),
22            ));
23        }
24        if let Ok(value) = input.extract::<i32>() {
25            return Ok(Ratio::from_integer(value));
26        }
27        if let Ok(value) = input.extract::<f64>() {
28            let twice = Projection::try_from(value)?.value();
29            return Ok(Ratio::new(twice, 2));
30        }
31        let numerator = input
32            .getattr("numerator")
33            .and_then(|value| value.extract::<i32>());
34        let denominator = input
35            .getattr("denominator")
36            .and_then(|value| value.extract::<i32>());
37        if let (Ok(numerator), Ok(denominator)) = (numerator, denominator) {
38            if denominator == 0 {
39                return Err(LadduError::Custom(
40                    "quantum number denominator cannot be zero".to_string(),
41                ));
42            }
43            return Ok(Ratio::new(numerator, denominator));
44        }
45        Err(LadduError::Custom(
46            "quantum number must be an int, float, or fractions.Fraction".to_string(),
47        ))
48    }
49
50    pub fn parse_projection(input: &Bound<'_, PyAny>) -> PyResult<Projection> {
51        Ok(parse_ratio_like(input).and_then(Projection::try_from)?)
52    }
53
54    pub fn parse_orbital_angular_momentum(
55        input: &Bound<'_, PyAny>,
56    ) -> PyResult<OrbitalAngularMomentum> {
57        Ok(parse_ratio_like(input).and_then(OrbitalAngularMomentum::try_from)?)
58    }
59
60    pub fn angular_momentum_to_python(
61        py: Python<'_>,
62        angular_momentum: laddu_core::AngularMomentum,
63    ) -> PyResult<PyQuantumNumber> {
64        let twice = angular_momentum.value() as i32;
65        if twice % 2 == 0 {
66            Ok((twice / 2).into_bound_py_any(py)?.unbind())
67        } else {
68            let fractions = PyModule::import(py, "fractions")?;
69            let fraction = fractions.getattr("Fraction")?;
70            Ok(fraction.call1((twice, 2))?.unbind())
71        }
72    }
73
74    pub fn projection_to_python(
75        py: Python<'_>,
76        projection: Projection,
77    ) -> PyResult<PyQuantumNumber> {
78        let twice = projection.value();
79        if twice % 2 == 0 {
80            Ok((twice / 2).into_bound_py_any(py)?.unbind())
81        } else {
82            let fractions = PyModule::import(py, "fractions")?;
83            let fraction = fractions.getattr("Fraction")?;
84            Ok(fraction.call1((twice, 2))?.unbind())
85        }
86    }
87
88    /// Enumerate allowed spin projections.
89    #[pyfunction(name = "allowed_projections")]
90    pub fn py_allowed_projections(
91        py: Python<'_>,
92        spin: &Bound<'_, PyAny>,
93    ) -> PyResult<Vec<PyQuantumNumber>> {
94        allowed_projections(parse_angular_momentum(spin)?)
95            .into_iter()
96            .map(|projection| projection_to_python(py, projection))
97            .collect()
98    }
99}
100
101use laddu_core::{
102    AllowedPartialWave, Charge, Isospin, LadduError, OrbitalAngularMomentum, Parity, PartialWave,
103    ParticleProperties, RuleSet, SelectionRules, Statistics,
104};
105use pyo3::{
106    exceptions::PyTypeError,
107    prelude::*,
108    types::{PyAny, PyBool},
109    IntoPyObjectExt,
110};
111
112use self::angular_momentum::{
113    angular_momentum_to_python, parse_angular_momentum, parse_orbital_angular_momentum,
114    parse_projection, projection_to_python,
115};
116
117type PyQuantumNumber = Py<PyAny>;
118
119fn parse_parity(input: &Bound<'_, PyAny>) -> PyResult<Parity> {
120    if let Ok(value) = input.extract::<PyParity>() {
121        return Ok(value.0);
122    }
123    if let Ok(value) = input.extract::<String>() {
124        return Ok(value.parse()?);
125    }
126    Err(PyTypeError::new_err(
127        "parity must be a Parity or sign string",
128    ))
129}
130
131fn parse_statistics(input: &Bound<'_, PyAny>) -> PyResult<Statistics> {
132    if let Ok(value) = input.extract::<PyStatistics>() {
133        return Ok(value.into());
134    }
135    if let Ok(value) = input.extract::<String>() {
136        return match value.to_ascii_lowercase().as_str() {
137            "boson" | "bosonic" => Ok(Statistics::Boson),
138            "fermion" | "fermionic" => Ok(Statistics::Fermion),
139            _ => Err(LadduError::ParseError {
140                name: value,
141                object: "Statistics".to_string(),
142            }
143            .into()),
144        };
145    }
146    Err(PyTypeError::new_err(
147        "statistics must be a Statistics value or string",
148    ))
149}
150
151fn parse_charge_input(input: &Bound<'_, PyAny>) -> PyResult<Charge> {
152    if let Ok(value) = input.extract::<PyCharge>() {
153        return Ok(value.0);
154    }
155    if input.is_instance_of::<PyBool>() {
156        return Err(LadduError::Custom("electric charge cannot be a bool".to_string()).into());
157    }
158    if let Ok(value) = input.extract::<i32>() {
159        return Ok(Charge::try_from(num::rational::Ratio::from_integer(value))?);
160    }
161    let numerator = input
162        .getattr("numerator")
163        .and_then(|value| value.extract::<i32>());
164    let denominator = input
165        .getattr("denominator")
166        .and_then(|value| value.extract::<i32>());
167    if let (Ok(numerator), Ok(denominator)) = (numerator, denominator) {
168        if denominator == 0 {
169            return Err(LadduError::Custom(
170                "electric charge denominator cannot be zero".to_string(),
171            )
172            .into());
173        }
174        return Ok(Charge::try_from(num::rational::Ratio::new(
175            numerator,
176            denominator,
177        ))?);
178    }
179    if let Ok(value) = input.extract::<f64>() {
180        return Ok(Charge::try_from(value)?);
181    }
182    Err(PyTypeError::new_err(
183        "electric charge must be an int, float, fractions.Fraction, or Charge",
184    ))
185}
186
187fn charge_to_python(py: Python<'_>, charge: Charge) -> PyResult<PyQuantumNumber> {
188    let thirds = charge.value();
189    if thirds % 3 == 0 {
190        Ok((thirds / 3).into_bound_py_any(py)?.unbind())
191    } else {
192        let fractions = pyo3::types::PyModule::import(py, "fractions")?;
193        let fraction = fractions.getattr("Fraction")?;
194        Ok(fraction.call1((thirds, 3))?.unbind())
195    }
196}
197
198#[pyclass(eq, name = "Parity", module = "laddu", from_py_object)]
199#[derive(Clone, Copy, PartialEq)]
200pub struct PyParity(pub Parity);
201
202#[pymethods]
203impl PyParity {
204    #[new]
205    fn new(value: &str) -> PyResult<Self> {
206        Ok(Self(value.parse()?))
207    }
208
209    #[staticmethod]
210    fn positive() -> Self {
211        Self(Parity::Positive)
212    }
213
214    #[staticmethod]
215    fn negative() -> Self {
216        Self(Parity::Negative)
217    }
218
219    #[getter]
220    fn value(&self) -> i32 {
221        self.0.value()
222    }
223
224    fn __repr__(&self) -> String {
225        format!("Parity('{}')", self.0)
226    }
227
228    fn __str__(&self) -> String {
229        self.0.to_string()
230    }
231}
232
233#[pyclass(eq, eq_int, name = "Statistics", module = "laddu", from_py_object)]
234#[derive(Clone, Copy, PartialEq)]
235pub enum PyStatistics {
236    Boson,
237    Fermion,
238}
239
240impl From<PyStatistics> for Statistics {
241    fn from(value: PyStatistics) -> Self {
242        match value {
243            PyStatistics::Boson => Self::Boson,
244            PyStatistics::Fermion => Self::Fermion,
245        }
246    }
247}
248
249impl From<Statistics> for PyStatistics {
250    fn from(value: Statistics) -> Self {
251        match value {
252            Statistics::Boson => Self::Boson,
253            Statistics::Fermion => Self::Fermion,
254        }
255    }
256}
257
258#[pyclass(eq, name = "Charge", module = "laddu", from_py_object)]
259#[derive(Clone, Copy, PartialEq)]
260pub struct PyCharge(pub Charge);
261
262#[pymethods]
263impl PyCharge {
264    #[new]
265    fn new(value: &Bound<'_, PyAny>) -> PyResult<Self> {
266        Ok(Self(parse_charge_input(value)?))
267    }
268
269    #[getter]
270    fn value(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
271        charge_to_python(py, self.0)
272    }
273
274    fn __repr__(&self) -> String {
275        format!("Charge({})", self.0)
276    }
277
278    fn __str__(&self) -> String {
279        self.0.to_string()
280    }
281}
282
283#[pyclass(eq, name = "Isospin", module = "laddu", from_py_object)]
284#[derive(Clone, Copy, PartialEq)]
285pub struct PyIsospin(pub Isospin);
286
287#[pymethods]
288impl PyIsospin {
289    #[new]
290    #[pyo3(signature = (isospin, *, projection=None))]
291    fn new(isospin: &Bound<'_, PyAny>, projection: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
292        Ok(Self(Isospin::new(
293            parse_angular_momentum(isospin)?,
294            projection.map(parse_projection).transpose()?,
295        )?))
296    }
297
298    #[getter]
299    fn isospin(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
300        angular_momentum_to_python(py, self.0.isospin())
301    }
302
303    #[getter]
304    fn projection_unchecked(&self, py: Python<'_>) -> PyResult<Option<PyQuantumNumber>> {
305        self.0
306            .projection
307            .map(|projection| projection_to_python(py, projection))
308            .transpose()
309    }
310
311    #[getter]
312    fn projection(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
313        projection_to_python(py, self.0.projection()?)
314    }
315
316    fn __repr__(&self) -> String {
317        match self.0.projection {
318            Some(projection) => format!("Isospin({}, projection={})", self.0.isospin(), projection),
319            None => format!("Isospin({})", self.0.isospin()),
320        }
321    }
322}
323
324#[pyclass(name = "ParticleProperties", module = "laddu", from_py_object)]
325#[derive(Clone)]
326pub struct PyParticleProperties(pub ParticleProperties);
327
328#[pymethods]
329impl PyParticleProperties {
330    #[new]
331    #[pyo3(signature = (name=None, *, species=None, antiparticle_species=None, self_conjugate=None, spin=None, parity=None, c_parity=None, g_parity=None, charge=None, isospin=None, strangeness=None, charm=None, bottomness=None, topness=None, baryon_number=None, electron_lepton_number=None, muon_lepton_number=None, tau_lepton_number=None, statistics=None))]
332    #[allow(clippy::too_many_arguments)]
333    fn new(
334        name: Option<String>,
335        species: Option<String>,
336        antiparticle_species: Option<String>,
337        self_conjugate: Option<bool>,
338        spin: Option<&Bound<'_, PyAny>>,
339        parity: Option<&Bound<'_, PyAny>>,
340        c_parity: Option<&Bound<'_, PyAny>>,
341        g_parity: Option<&Bound<'_, PyAny>>,
342        charge: Option<&Bound<'_, PyAny>>,
343        isospin: Option<PyIsospin>,
344        strangeness: Option<i32>,
345        charm: Option<i32>,
346        bottomness: Option<i32>,
347        topness: Option<i32>,
348        baryon_number: Option<i32>,
349        electron_lepton_number: Option<i32>,
350        muon_lepton_number: Option<i32>,
351        tau_lepton_number: Option<i32>,
352        statistics: Option<&Bound<'_, PyAny>>,
353    ) -> PyResult<Self> {
354        let mut properties = ParticleProperties::unknown();
355        if let Some(name) = name {
356            properties = properties.with_name(name);
357        }
358        if let Some(species) = species {
359            properties = properties.with_species(species);
360        }
361        if let Some(antiparticle_species) = antiparticle_species {
362            properties = properties.with_antiparticle_species(antiparticle_species);
363        }
364        if let Some(self_conjugate) = self_conjugate {
365            properties = properties.with_self_conjugate(self_conjugate);
366        }
367        if let Some(spin) = spin {
368            properties = properties.with_spin(parse_angular_momentum(spin)?);
369        }
370        if let Some(parity) = parity {
371            properties = properties.with_parity(parse_parity(parity)?);
372        }
373        if let Some(c_parity) = c_parity {
374            properties = properties.with_c_parity(parse_parity(c_parity)?);
375        }
376        if let Some(g_parity) = g_parity {
377            properties = properties.with_g_parity(parse_parity(g_parity)?);
378        }
379        if let Some(charge) = charge {
380            properties = properties.with_charge(parse_charge_input(charge)?);
381        }
382        if let Some(isospin) = isospin {
383            properties = properties.with_isospin(isospin.0);
384        }
385        if let Some(strangeness) = strangeness {
386            properties = properties.with_strangeness(strangeness);
387        }
388        if let Some(charm) = charm {
389            properties = properties.with_charm(charm);
390        }
391        if let Some(bottomness) = bottomness {
392            properties = properties.with_bottomness(bottomness);
393        }
394        if let Some(topness) = topness {
395            properties = properties.with_topness(topness);
396        }
397        if let Some(baryon_number) = baryon_number {
398            properties = properties.with_baryon_number(baryon_number);
399        }
400        if let Some(electron_lepton_number) = electron_lepton_number {
401            properties = properties.with_electron_lepton_number(electron_lepton_number);
402        }
403        if let Some(muon_lepton_number) = muon_lepton_number {
404            properties = properties.with_muon_lepton_number(muon_lepton_number);
405        }
406        if let Some(tau_lepton_number) = tau_lepton_number {
407            properties = properties.with_tau_lepton_number(tau_lepton_number);
408        }
409        if let Some(statistics) = statistics {
410            properties = properties.with_statistics(parse_statistics(statistics)?)?;
411        }
412        Ok(Self(properties))
413    }
414
415    #[getter]
416    fn name(&self) -> PyResult<String> {
417        Ok(self.0.name()?)
418    }
419
420    #[getter]
421    fn name_unchecked(&self) -> Option<String> {
422        self.0.name.clone()
423    }
424
425    #[getter]
426    fn species(&self) -> PyResult<String> {
427        Ok(self.0.species()?)
428    }
429
430    #[getter]
431    fn species_unchecked(&self) -> Option<String> {
432        self.0.species.clone()
433    }
434
435    #[getter]
436    fn antiparticle_species(&self) -> PyResult<String> {
437        Ok(self.0.antiparticle_species()?)
438    }
439
440    #[getter]
441    fn antiparticle_species_unchecked(&self) -> Option<String> {
442        self.0.antiparticle_species.clone()
443    }
444
445    #[getter]
446    fn self_conjugate(&self) -> PyResult<bool> {
447        Ok(self.0.self_conjugate()?)
448    }
449
450    #[getter]
451    fn self_conjugate_unchecked(&self) -> Option<bool> {
452        self.0.self_conjugate
453    }
454
455    #[getter]
456    fn spin(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
457        angular_momentum_to_python(py, self.0.spin()?)
458    }
459
460    #[getter]
461    fn spin_unchecked(&self, py: Python<'_>) -> PyResult<Option<PyQuantumNumber>> {
462        self.0
463            .spin
464            .map(|spin| angular_momentum_to_python(py, spin))
465            .transpose()
466    }
467
468    #[getter]
469    fn parity(&self) -> PyResult<PyParity> {
470        Ok(PyParity(self.0.parity()?))
471    }
472
473    #[getter]
474    fn parity_unchecked(&self) -> Option<PyParity> {
475        self.0.parity.map(PyParity)
476    }
477
478    #[getter]
479    fn c_parity(&self) -> PyResult<PyParity> {
480        Ok(PyParity(self.0.c_parity()?))
481    }
482
483    #[getter]
484    fn c_parity_unchecked(&self) -> Option<PyParity> {
485        self.0.c_parity.map(PyParity)
486    }
487
488    #[getter]
489    fn g_parity(&self) -> PyResult<PyParity> {
490        Ok(PyParity(self.0.g_parity()?))
491    }
492
493    #[getter]
494    fn g_parity_unchecked(&self) -> Option<PyParity> {
495        self.0.g_parity.map(PyParity)
496    }
497
498    #[getter]
499    fn charge(&self) -> PyResult<PyCharge> {
500        Ok(PyCharge(self.0.charge()?))
501    }
502
503    #[getter]
504    fn charge_unchecked(&self) -> Option<PyCharge> {
505        self.0.charge.map(PyCharge)
506    }
507
508    #[getter]
509    fn isospin(&self) -> PyResult<PyIsospin> {
510        Ok(PyIsospin(self.0.isospin()?))
511    }
512
513    #[getter]
514    fn isospin_unchecked(&self) -> Option<PyIsospin> {
515        self.0.isospin.map(PyIsospin)
516    }
517
518    #[getter]
519    fn strangeness(&self) -> PyResult<i32> {
520        Ok(self.0.strangeness()?)
521    }
522
523    #[getter]
524    fn strangeness_unchecked(&self) -> Option<i32> {
525        self.0.strangeness
526    }
527
528    #[getter]
529    fn charm(&self) -> PyResult<i32> {
530        Ok(self.0.charm()?)
531    }
532
533    #[getter]
534    fn charm_unchecked(&self) -> Option<i32> {
535        self.0.charm
536    }
537
538    #[getter]
539    fn bottomness(&self) -> PyResult<i32> {
540        Ok(self.0.bottomness()?)
541    }
542
543    #[getter]
544    fn bottomness_unchecked(&self) -> Option<i32> {
545        self.0.bottomness
546    }
547
548    #[getter]
549    fn topness(&self) -> PyResult<i32> {
550        Ok(self.0.topness()?)
551    }
552
553    #[getter]
554    fn topness_unchecked(&self) -> Option<i32> {
555        self.0.topness
556    }
557
558    #[getter]
559    fn baryon_number(&self) -> PyResult<i32> {
560        Ok(self.0.baryon_number()?)
561    }
562
563    #[getter]
564    fn baryon_number_unchecked(&self) -> Option<i32> {
565        self.0.baryon_number
566    }
567
568    #[getter]
569    fn electron_lepton_number(&self) -> PyResult<i32> {
570        Ok(self.0.electron_lepton_number()?)
571    }
572
573    #[getter]
574    fn electron_lepton_number_unchecked(&self) -> Option<i32> {
575        self.0.electron_lepton_number
576    }
577
578    #[getter]
579    fn muon_lepton_number(&self) -> PyResult<i32> {
580        Ok(self.0.muon_lepton_number()?)
581    }
582
583    #[getter]
584    fn muon_lepton_number_unchecked(&self) -> Option<i32> {
585        self.0.muon_lepton_number
586    }
587
588    #[getter]
589    fn tau_lepton_number(&self) -> PyResult<i32> {
590        Ok(self.0.tau_lepton_number()?)
591    }
592
593    #[getter]
594    fn tau_lepton_number_unchecked(&self) -> Option<i32> {
595        self.0.tau_lepton_number
596    }
597
598    #[getter]
599    fn statistics(&self) -> PyResult<PyStatistics> {
600        Ok(self.0.statistics()?.into())
601    }
602
603    #[getter]
604    fn statistics_unchecked(&self) -> Option<PyStatistics> {
605        self.0.statistics.map(|s| s.into())
606    }
607
608    fn __repr__(&self) -> String {
609        format!("{:?}", self.0)
610    }
611}
612
613#[pyclass(eq, name = "PartialWave", module = "laddu", from_py_object)]
614#[derive(Clone, PartialEq)]
615pub struct PyPartialWave(pub PartialWave);
616
617#[pymethods]
618impl PyPartialWave {
619    #[new]
620    #[pyo3(signature = (*, j, l, s, label=None))]
621    fn new(
622        j: &Bound<'_, PyAny>,
623        l: &Bound<'_, PyAny>,
624        s: &Bound<'_, PyAny>,
625        label: Option<String>,
626    ) -> PyResult<Self> {
627        let wave = PartialWave::new(
628            parse_angular_momentum(j)?,
629            parse_orbital_angular_momentum(l)?,
630            parse_angular_momentum(s)?,
631        )?;
632        Ok(Self(match label {
633            Some(label) => wave.with_label(label),
634            None => wave,
635        }))
636    }
637
638    #[getter]
639    fn j(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
640        angular_momentum_to_python(py, self.0.j)
641    }
642
643    #[getter]
644    fn l(&self) -> u32 {
645        self.0.l.value()
646    }
647
648    #[getter]
649    fn s(&self, py: Python<'_>) -> PyResult<PyQuantumNumber> {
650        angular_momentum_to_python(py, self.0.s)
651    }
652
653    #[getter]
654    fn label(&self) -> String {
655        self.0.label.clone()
656    }
657
658    fn __repr__(&self) -> String {
659        format!("PartialWave('{}')", self.0.label)
660    }
661
662    fn __str__(&self) -> String {
663        self.0.to_string()
664    }
665}
666
667#[pyclass(eq, name = "AllowedPartialWave", module = "laddu", from_py_object)]
668#[derive(Clone, PartialEq)]
669pub struct PyAllowedPartialWave(pub AllowedPartialWave);
670
671#[pymethods]
672impl PyAllowedPartialWave {
673    #[getter]
674    fn wave(&self) -> PyPartialWave {
675        PyPartialWave(self.0.wave.clone())
676    }
677
678    #[getter]
679    fn parity(&self) -> Option<PyParity> {
680        self.0.parity.map(PyParity)
681    }
682
683    #[getter]
684    fn c_parity(&self) -> Option<PyParity> {
685        self.0.c_parity.map(PyParity)
686    }
687
688    fn __repr__(&self) -> String {
689        format!("{:?}", self.0)
690    }
691}
692
693#[pyclass(eq, name = "RuleSet", module = "laddu", from_py_object)]
694#[derive(Clone, PartialEq)]
695pub struct PyRuleSet(pub RuleSet);
696
697#[pymethods]
698impl PyRuleSet {
699    #[new]
700    fn new() -> Self {
701        Self(RuleSet::default())
702    }
703
704    #[staticmethod]
705    fn angular() -> Self {
706        Self(RuleSet::angular())
707    }
708
709    #[staticmethod]
710    fn strong() -> Self {
711        Self(RuleSet::strong())
712    }
713
714    #[staticmethod]
715    fn electromagnetic() -> Self {
716        Self(RuleSet::electromagnetic())
717    }
718
719    #[staticmethod]
720    fn weak() -> Self {
721        Self(RuleSet::weak())
722    }
723
724    fn __repr__(&self) -> String {
725        format!("{:?}", self.0)
726    }
727}
728
729fn parse_rules(rules: Option<&Bound<'_, PyAny>>) -> PyResult<RuleSet> {
730    let Some(rules) = rules else {
731        return Ok(RuleSet::strong());
732    };
733    if let Ok(rules) = rules.extract::<PyRuleSet>() {
734        return Ok(rules.0);
735    }
736    if let Ok(name) = rules.extract::<String>() {
737        return match name.to_ascii_lowercase().as_str() {
738            "angular" => Ok(RuleSet::angular()),
739            "strong" => Ok(RuleSet::strong()),
740            "electromagnetic" | "em" => Ok(RuleSet::electromagnetic()),
741            "weak" => Ok(RuleSet::weak()),
742            _ => Err(LadduError::ParseError {
743                name,
744                object: "RuleSet".to_string(),
745            }
746            .into()),
747        };
748    }
749    Err(PyTypeError::new_err(
750        "rules must be a RuleSet or preset string",
751    ))
752}
753
754#[pyclass(name = "SelectionRules", module = "laddu", from_py_object)]
755#[derive(Clone)]
756pub struct PySelectionRules(pub SelectionRules);
757
758#[pymethods]
759impl PySelectionRules {
760    #[new]
761    #[pyo3(signature = (*, max_l=6, rules=None))]
762    fn new(max_l: u32, rules: Option<&Bound<'_, PyAny>>) -> PyResult<Self> {
763        Ok(Self(SelectionRules {
764            max_l: OrbitalAngularMomentum::integer(max_l),
765            rules: parse_rules(rules)?,
766        }))
767    }
768
769    #[staticmethod]
770    fn coupled_spins(
771        py: Python<'_>,
772        spin_1: &Bound<'_, PyAny>,
773        spin_2: &Bound<'_, PyAny>,
774    ) -> PyResult<Vec<PyQuantumNumber>> {
775        SelectionRules::coupled_spins(
776            parse_angular_momentum(spin_1)?,
777            parse_angular_momentum(spin_2)?,
778        )
779        .into_iter()
780        .map(|spin| angular_momentum_to_python(py, spin))
781        .collect()
782    }
783
784    fn allowed_partial_waves(
785        &self,
786        parent: &PyParticleProperties,
787        daughter_1: &PyParticleProperties,
788        daughter_2: &PyParticleProperties,
789    ) -> Vec<PyAllowedPartialWave> {
790        self.0
791            .allowed_partial_waves(&parent.0, (&daughter_1.0, &daughter_2.0))
792            .into_iter()
793            .map(PyAllowedPartialWave)
794            .collect()
795    }
796
797    fn __repr__(&self) -> String {
798        format!("{:?}", self.0)
799    }
800}
801
802/// Return all possible coupled total spins from two daughter spins.
803#[pyfunction(name = "coupled_spins")]
804pub fn py_coupled_spins(
805    py: Python<'_>,
806    spin_1: &Bound<'_, PyAny>,
807    spin_2: &Bound<'_, PyAny>,
808) -> PyResult<Vec<PyQuantumNumber>> {
809    PySelectionRules::coupled_spins(py, spin_1, spin_2)
810}
811
812/// Generate allowed two-body partial waves.
813#[pyfunction(name = "allowed_partial_waves", signature = (parent, daughter_1, daughter_2, *, max_l=6, rules=None))]
814pub fn py_allowed_partial_waves(
815    parent: &PyParticleProperties,
816    daughter_1: &PyParticleProperties,
817    daughter_2: &PyParticleProperties,
818    max_l: u32,
819    rules: Option<&Bound<'_, PyAny>>,
820) -> PyResult<Vec<PyAllowedPartialWave>> {
821    Ok(PySelectionRules::new(max_l, rules)?
822        .0
823        .allowed_partial_waves(&parent.0, (&daughter_1.0, &daughter_2.0))
824        .into_iter()
825        .map(PyAllowedPartialWave)
826        .collect())
827}