Skip to main content

laddu_physics/quantum/
state.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    LadduPhysicsError, LadduPhysicsResult,
6    quantum::{J, L, M, Parity, Statistics},
7};
8
9/// An external identifier associated with a physical particle species.
10#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
11pub enum ExternalId {
12    /// A numeric identifier, such as a PDG code.
13    Code {
14        /// Identifier value.
15        value: i64,
16    },
17    /// A textual identifier.
18    Label {
19        /// Identifier value.
20        value: String,
21    },
22}
23
24impl From<&str> for ExternalId {
25    fn from(value: &str) -> Self {
26        Self::label(value)
27    }
28}
29impl From<String> for ExternalId {
30    fn from(value: String) -> Self {
31        Self::label(value)
32    }
33}
34impl From<&String> for ExternalId {
35    fn from(value: &String) -> Self {
36        Self::label(value)
37    }
38}
39impl From<i64> for ExternalId {
40    fn from(value: i64) -> Self {
41        Self::code(value)
42    }
43}
44
45impl ExternalId {
46    /// Construct a numeric identifier.
47    pub fn code(value: i64) -> Self {
48        Self::Code { value }
49    }
50
51    /// Construct a textual identifier in an arbitrary namespace.
52    pub fn label(value: impl Into<String>) -> Self {
53        Self::Label {
54            value: value.into(),
55        }
56    }
57
58    /// Return the numeric value, if this is a numeric identifier.
59    pub fn code_value(&self) -> Option<i64> {
60        match self {
61            Self::Code { value, .. } => Some(*value),
62            Self::Label { .. } => None,
63        }
64    }
65
66    /// Return the textual value, if this is a textual identifier.
67    pub fn label_value(&self) -> Option<&str> {
68        match self {
69            Self::Label { value, .. } => Some(value),
70            Self::Code { .. } => None,
71        }
72    }
73}
74
75/// A validated spin state with spin and projection stored as doubled quantum numbers.
76#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
77pub struct SpinState {
78    spin: J,
79    projection: M,
80}
81
82impl SpinState {
83    /// Construct a spin state after validating projection bounds and parity.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`LadduPhysicsError`] when `projection` is outside the spin
88    /// range or has incompatible integer/half-integer parity.
89    pub fn new(spin: J, projection: M) -> LadduPhysicsResult<Self> {
90        validate_projection(spin, projection)?;
91        Ok(Self { spin, projection })
92    }
93
94    /// Return the spin quantum number.
95    pub const fn spin(self) -> J {
96        self.spin
97    }
98
99    /// Return the spin projection quantum number.
100    pub const fn projection(self) -> M {
101        self.projection
102    }
103}
104
105/// An isospin state with optional projection.
106#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
107pub struct Isospin {
108    /// The total isospin of the state.
109    pub isospin: J,
110    /// The isospin projection of the state.
111    pub projection: Option<M>,
112}
113
114impl Isospin {
115    /// Construct a new isospin state from the given total isospin and optional projection.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`LadduPhysicsError`] when the projection is outside the
120    /// isospin range or has incompatible integer/half-integer parity.
121    pub fn new(isospin: J, projection: Option<M>) -> LadduPhysicsResult<Self> {
122        if let Some(projection) = projection {
123            validate_projection(isospin, projection)?;
124        }
125        Ok(Self {
126            isospin,
127            projection,
128        })
129    }
130
131    /// The total isospin of the state.
132    pub fn isospin(self) -> J {
133        self.isospin
134    }
135    /// The isospin projection of the state.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the
140    /// projection is unknown.
141    pub fn projection(self) -> LadduPhysicsResult<M> {
142        self.projection
143            .ok_or(LadduPhysicsError::MissingParticleProperty {
144                property: "isospin.projection",
145            })
146    }
147}
148
149impl From<J> for Isospin {
150    fn from(value: J) -> Self {
151        Self {
152            isospin: value,
153            projection: None,
154        }
155    }
156}
157
158/// The set of properties which define the quantum state of a particle.
159#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
160pub struct ParticleProperties {
161    /// The name of the particle, if known.
162    pub name: Option<String>,
163    /// The species of the particle, if known (used to compare to [`ParticleProperties::antiparticle_species`]).
164    pub species: Option<String>,
165    /// The species of the particle's antiparticle, if known (used to compare to [`ParticleProperties::species`]).
166    pub antiparticle_species: Option<String>,
167    /// Whether the particle is its own antiparticle.
168    pub self_conjugate: Option<bool>,
169    /// The spin of the particle, if known.
170    pub spin: Option<J>,
171    /// The intrinsic parity of the particle, if known.
172    pub parity: Option<Parity>,
173    /// The intrinsic C-parity of the particle, if known or applicable.
174    pub c_parity: Option<Parity>,
175    /// The intrinsic G-parity of the particle, if known or applicable.
176    pub g_parity: Option<Parity>,
177    /// The electric charge of the particle, if known.
178    pub charge: Option<i32>,
179    /// The isospin of the particle, if known.
180    pub isospin: Option<Isospin>,
181    /// The total strangeness of the particle, if known.
182    pub strangeness: Option<i32>,
183    /// The total charm of the particle, if known.
184    pub charm: Option<i32>,
185    /// The total bottomness of the particle, if known.
186    pub bottomness: Option<i32>,
187    /// The total topness of the particle, if known.
188    pub topness: Option<i32>,
189    /// The total baryon number of the particle, if known.
190    pub baryon_number: Option<i32>,
191    /// The electron lepton number of the particle, if known.
192    pub electron_lepton_number: Option<i32>,
193    /// The muon lepton number of the particle, if known.
194    pub muon_lepton_number: Option<i32>,
195    /// The tau lepton number of the particle, if known.
196    pub tau_lepton_number: Option<i32>,
197    /// The particle's statistical nature, if known.
198    pub statistics: Option<Statistics>,
199    /// The nominal particle mass, if known.
200    pub mass: Option<f64>,
201    /// External identifiers for this particle.
202    pub ids: IndexMap<String, ExternalId>,
203}
204
205impl ParticleProperties {
206    /// Get the particle's name
207    ///
208    /// # Errors
209    ///
210    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the name is
211    /// unknown.
212    pub fn name(&self) -> LadduPhysicsResult<String> {
213        self.name
214            .clone()
215            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "name" })
216            .clone()
217    }
218    /// Get the particle's species
219    ///
220    /// # Errors
221    ///
222    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the species
223    /// is unknown.
224    pub fn species(&self) -> LadduPhysicsResult<String> {
225        self.species
226            .clone()
227            .ok_or(LadduPhysicsError::MissingParticleProperty {
228                property: "species",
229            })
230            .clone()
231    }
232    /// Get the particle's antiparticle species
233    ///
234    /// # Errors
235    ///
236    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the
237    /// antiparticle species is unknown.
238    pub fn antiparticle_species(&self) -> LadduPhysicsResult<String> {
239        self.antiparticle_species
240            .clone()
241            .ok_or(LadduPhysicsError::MissingParticleProperty {
242                property: "antiparticle_species",
243            })
244            .clone()
245    }
246    /// Get the particle's self-conjugate status
247    ///
248    /// # Errors
249    ///
250    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the
251    /// self-conjugate status is unknown.
252    pub fn self_conjugate(&self) -> LadduPhysicsResult<bool> {
253        self.self_conjugate
254            .ok_or(LadduPhysicsError::MissingParticleProperty {
255                property: "self_conjugate",
256            })
257            .clone()
258    }
259    /// Get the particle's spin
260    ///
261    /// # Errors
262    ///
263    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the spin is
264    /// unknown.
265    pub fn spin(&self) -> LadduPhysicsResult<J> {
266        self.spin
267            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "spin" })
268            .clone()
269    }
270    /// Get the particle's intrinsic parity
271    ///
272    /// # Errors
273    ///
274    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when parity is
275    /// unknown.
276    pub fn parity(&self) -> LadduPhysicsResult<Parity> {
277        self.parity
278            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "parity" })
279            .clone()
280    }
281    /// Get the particle's intrinsic C-parity
282    ///
283    /// # Errors
284    ///
285    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when C-parity is
286    /// unknown.
287    pub fn c_parity(&self) -> LadduPhysicsResult<Parity> {
288        self.c_parity
289            .ok_or(LadduPhysicsError::MissingParticleProperty {
290                property: "c_parity",
291            })
292            .clone()
293    }
294    /// Get the particle's intrinsic G-parity
295    ///
296    /// # Errors
297    ///
298    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when G-parity is
299    /// unknown.
300    pub fn g_parity(&self) -> LadduPhysicsResult<Parity> {
301        self.g_parity
302            .ok_or(LadduPhysicsError::MissingParticleProperty {
303                property: "g_parity",
304            })
305            .clone()
306    }
307    /// Get the particle's electric charge
308    ///
309    /// # Errors
310    ///
311    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when charge is
312    /// unknown.
313    pub fn charge(&self) -> LadduPhysicsResult<i32> {
314        self.charge
315            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "charge" })
316            .clone()
317    }
318    /// Get the particle's isospin
319    ///
320    /// # Errors
321    ///
322    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when isospin is
323    /// unknown.
324    pub fn isospin(&self) -> LadduPhysicsResult<Isospin> {
325        self.isospin
326            .ok_or(LadduPhysicsError::MissingParticleProperty {
327                property: "isospin",
328            })
329            .clone()
330    }
331    /// Get the particle's strangeness
332    ///
333    /// # Errors
334    ///
335    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when strangeness
336    /// is unknown.
337    pub fn strangeness(&self) -> LadduPhysicsResult<i32> {
338        self.strangeness
339            .ok_or(LadduPhysicsError::MissingParticleProperty {
340                property: "strangeness",
341            })
342            .clone()
343    }
344    /// Get the particle's charm
345    ///
346    /// # Errors
347    ///
348    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when charm is
349    /// unknown.
350    pub fn charm(&self) -> LadduPhysicsResult<i32> {
351        self.charm
352            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "charm" })
353            .clone()
354    }
355    /// Get the particle's bottomness
356    ///
357    /// # Errors
358    ///
359    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when bottomness
360    /// is unknown.
361    pub fn bottomness(&self) -> LadduPhysicsResult<i32> {
362        self.bottomness
363            .ok_or(LadduPhysicsError::MissingParticleProperty {
364                property: "bottomness",
365            })
366            .clone()
367    }
368    /// Get the particle's topness
369    ///
370    /// # Errors
371    ///
372    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when topness is
373    /// unknown.
374    pub fn topness(&self) -> LadduPhysicsResult<i32> {
375        self.topness
376            .ok_or(LadduPhysicsError::MissingParticleProperty {
377                property: "topness",
378            })
379            .clone()
380    }
381    /// Get the particle's baryon number
382    ///
383    /// # Errors
384    ///
385    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the baryon
386    /// number is unknown.
387    pub fn baryon_number(&self) -> LadduPhysicsResult<i32> {
388        self.baryon_number
389            .ok_or(LadduPhysicsError::MissingParticleProperty {
390                property: "baryon_number",
391            })
392            .clone()
393    }
394    /// Get the particle's electron lepton number
395    ///
396    /// # Errors
397    ///
398    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the electron
399    /// lepton number is unknown.
400    pub fn electron_lepton_number(&self) -> LadduPhysicsResult<i32> {
401        self.electron_lepton_number
402            .ok_or(LadduPhysicsError::MissingParticleProperty {
403                property: "electron_lepton_number",
404            })
405            .clone()
406    }
407    /// Get the particle's muon lepton number
408    ///
409    /// # Errors
410    ///
411    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the muon
412    /// lepton number is unknown.
413    pub fn muon_lepton_number(&self) -> LadduPhysicsResult<i32> {
414        self.muon_lepton_number
415            .ok_or(LadduPhysicsError::MissingParticleProperty {
416                property: "muon_lepton_number",
417            })
418            .clone()
419    }
420    /// Get the particle's tau lepton number
421    ///
422    /// # Errors
423    ///
424    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the tau
425    /// lepton number is unknown.
426    pub fn tau_lepton_number(&self) -> LadduPhysicsResult<i32> {
427        self.tau_lepton_number
428            .ok_or(LadduPhysicsError::MissingParticleProperty {
429                property: "tau_lepton_number",
430            })
431            .clone()
432    }
433    /// Get the particle's statistics
434    ///
435    /// # Errors
436    ///
437    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the
438    /// statistics are unknown.
439    pub fn statistics(&self) -> LadduPhysicsResult<Statistics> {
440        self.statistics
441            .ok_or(LadduPhysicsError::MissingParticleProperty {
442                property: "statistics",
443            })
444            .clone()
445    }
446    /// Get the particle's mass.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`LadduPhysicsError::MissingParticleProperty`] when the mass is
451    /// unknown.
452    pub fn mass(&self) -> LadduPhysicsResult<f64> {
453        self.mass
454            .ok_or(LadduPhysicsError::MissingParticleProperty { property: "mass" })
455    }
456
457    /// Construct a particle with no specified properties.
458    pub fn unknown() -> Self {
459        Self::default()
460    }
461
462    /// Construct a particle with the given spin and parity.
463    /// Construct a particle with the given spin and intrinsic parity.
464    pub fn jp(j: J, p: Parity) -> Self {
465        Self {
466            spin: Some(j),
467            parity: Some(p),
468            statistics: Some(Statistics::from_spin(j)),
469            ..Self::default()
470        }
471    }
472    /// Construct a particle with the given spin, parity, and C-parity.
473    pub fn jpc(j: J, p: Parity, c: Parity) -> Self {
474        Self {
475            spin: Some(j),
476            parity: Some(p),
477            c_parity: Some(c),
478            statistics: Some(Statistics::from_spin(j)),
479            ..Self::default()
480        }
481    }
482
483    /// A Boson-like state with spin `j` and zero baryon or lepton number
484    pub fn boson(j: L) -> Self {
485        let mut particle = Self::unknown().with_spin(j.into());
486        particle.baryon_number = Some(0);
487        particle.electron_lepton_number = Some(0);
488        particle.muon_lepton_number = Some(0);
489        particle.tau_lepton_number = Some(0);
490        particle
491    }
492
493    /// Construct a lepton-like state with the supplied family lepton numbers.
494    pub fn lepton(e: i32, m: i32, t: i32) -> Self {
495        let mut particle = Self::unknown().with_zero_flavor();
496        particle.baryon_number = Some(0);
497        particle.electron_lepton_number = Some(e);
498        particle.muon_lepton_number = Some(m);
499        particle.tau_lepton_number = Some(t);
500        particle
501    }
502
503    /// A hadron-like state with zero lepton number.
504    /// Does not assume baryon number, charge, or flavor.
505    pub fn hadron() -> Self {
506        Self::unknown().with_zero_lepton_numbers()
507    }
508
509    /// A meson-like hadron with zero baryon and lepton number.
510    /// Does not assume charge or flavor.
511    pub fn meson() -> Self {
512        let mut particle = Self::hadron();
513        particle.baryon_number = Some(0);
514        particle
515    }
516
517    /// A baryon-like hadron with baryon number `b` and zero lepton number.
518    /// Usually `b = 1`; nuclei/dibaryons can use `b > 1`; antibaryons use negative values.
519    pub fn baryon(b: i32) -> Self {
520        let mut particle = Self::hadron();
521        particle.baryon_number = Some(b);
522        particle
523    }
524
525    /// Set the particle's name.
526    pub fn with_name(mut self, name: impl Into<String>) -> Self {
527        self.name = Some(name.into());
528        self
529    }
530    /// Set the particle's species.
531    ///
532    /// # Errors
533    ///
534    /// Returns [`LadduPhysicsError`] when the species conflicts with existing
535    /// self-conjugacy or antiparticle metadata.
536    pub fn with_species(mut self, species: impl Into<String>) -> LadduPhysicsResult<Self> {
537        let species = species.into();
538
539        if self.self_conjugate == Some(true) {
540            match &self.antiparticle_species {
541                Some(anti) if anti != &species => {
542                    return Err(LadduPhysicsError::invalid_relation(
543                        "self-conjugate particle cannot have distinct species and antiparticle_species",
544                    ));
545                }
546                None => self.antiparticle_species = Some(species.clone()),
547                _ => {}
548            }
549        }
550
551        self.species = Some(species);
552        self.check_invariants()?;
553        Ok(self)
554    }
555    /// Set the particle's antiparticle species.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`LadduPhysicsError`] when the antiparticle species conflicts
560    /// with existing self-conjugacy or species metadata.
561    pub fn with_antiparticle_species(
562        mut self,
563        antiparticle_species: impl Into<String>,
564    ) -> LadduPhysicsResult<Self> {
565        let antiparticle_species = antiparticle_species.into();
566
567        if self.self_conjugate == Some(true) {
568            match &self.species {
569                Some(species) if species != &antiparticle_species => {
570                    return Err(LadduPhysicsError::invalid_relation(
571                        "self-conjugate particle cannot have distinct species and antiparticle_species",
572                    ));
573                }
574                None => self.species = Some(antiparticle_species.clone()),
575                _ => {}
576            }
577        }
578
579        self.antiparticle_species = Some(antiparticle_species);
580        self.check_invariants()?;
581        Ok(self)
582    }
583
584    /// Set both particle and antiparticle species names.
585    ///
586    /// Equal names mark the particle as self-conjugate.
587    ///
588    /// # Errors
589    ///
590    /// Returns [`LadduPhysicsError`] when the resulting species and quantum
591    /// number metadata violate particle invariants.
592    pub fn with_species_names(
593        mut self,
594        species: impl Into<String>,
595        antiparticle_species: impl Into<String>,
596    ) -> LadduPhysicsResult<Self> {
597        let species = species.into();
598        let antiparticle_species = antiparticle_species.into();
599
600        self.species = Some(species.clone());
601        self.antiparticle_species = Some(antiparticle_species.clone());
602        self.self_conjugate = Some(species == antiparticle_species);
603
604        self.fill_zero_additive_qns_if_self_conjugate();
605        self.check_invariants()?;
606
607        Ok(self)
608    }
609
610    /// Set whether the particle is its own antiparticle.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`LadduPhysicsError`] when `value` conflicts with species names,
615    /// C-parity, or nonzero additive quantum numbers.
616    pub fn with_self_conjugate(mut self, value: bool) -> LadduPhysicsResult<Self> {
617        if value {
618            if let (Some(species), Some(anti)) = (&self.species, &self.antiparticle_species)
619                && species != anti
620            {
621                return Err(LadduPhysicsError::invalid_relation(
622                    "self-conjugate particle cannot have distinct species and antiparticle_species",
623                ));
624            }
625            match (&self.species, &self.antiparticle_species) {
626                (Some(species), None) => self.antiparticle_species = Some(species.clone()),
627                (None, Some(anti)) => self.species = Some(anti.clone()),
628                _ => {}
629            }
630            self.fill_zero_additive_qns_if_self_conjugate();
631        } else {
632            if self.c_parity.is_some() {
633                return Err(LadduPhysicsError::invalid_relation(
634                    "non-self-conjugate particles cannot have C-parity",
635                ));
636            }
637        }
638        self.self_conjugate = Some(value);
639
640        self.check_invariants()?;
641        Ok(self)
642    }
643
644    /// Set one species name and mark the particle as self-conjugate.
645    ///
646    /// # Errors
647    ///
648    /// Returns [`LadduPhysicsError`] when existing particle metadata is
649    /// inconsistent with self-conjugacy.
650    pub fn with_self_conjugate_species(
651        mut self,
652        species: impl Into<String>,
653    ) -> LadduPhysicsResult<Self> {
654        let species = species.into();
655
656        self.species = Some(species.clone());
657        self.antiparticle_species = Some(species);
658        self.self_conjugate = Some(true);
659
660        self.fill_zero_additive_qns_if_self_conjugate();
661        self.check_invariants()?;
662
663        Ok(self)
664    }
665
666    /// Set the particle's spin.
667    pub fn with_spin(mut self, j: J) -> Self {
668        self.spin = Some(j);
669        self.statistics = Some(Statistics::from_spin(j));
670        self
671    }
672    /// Set the particle's intrinsic parity.
673    pub fn with_parity(mut self, p: Parity) -> Self {
674        self.parity = Some(p);
675        self
676    }
677    /// Set the particle's intrinsic C-parity.
678    ///
679    /// # Errors
680    ///
681    /// Returns [`LadduPhysicsError`] when C-parity conflicts with the
682    /// particle's self-conjugacy or additive quantum numbers.
683    pub fn with_c_parity(mut self, c: Parity) -> LadduPhysicsResult<Self> {
684        if self.self_conjugate == Some(false) {
685            return Err(LadduPhysicsError::invalid_relation(
686                "C-parity is only applicable to self-conjugate particles",
687            ));
688        }
689
690        self.c_parity = Some(c);
691
692        if self.self_conjugate.is_none() {
693            self = self.with_self_conjugate(true)?;
694        }
695
696        self.check_invariants()?;
697        Ok(self)
698    }
699    /// Set the particle's intrinsic G-parity.
700    pub fn with_g_parity(mut self, g: Parity) -> Self {
701        self.g_parity = Some(g);
702        self
703    }
704    /// Set the particle's electric charge.
705    pub fn with_charge(mut self, q: i32) -> Self {
706        self.charge = Some(q);
707        self
708    }
709    /// Set the particle's isospin state.
710    pub fn with_isospin(mut self, isospin: Isospin) -> Self {
711        self.isospin = Some(isospin);
712        self
713    }
714    /// Set the particle's total strangeness.
715    ///
716    /// # Errors
717    ///
718    /// Returns [`LadduPhysicsError`] when nonzero strangeness conflicts with
719    /// self-conjugacy.
720    pub fn with_strangeness(self, s: i32) -> LadduPhysicsResult<Self> {
721        self.with_additive_qn("strangeness", |p| &mut p.strangeness, s)
722    }
723    /// Set the particle's total charm.
724    ///
725    /// # Errors
726    ///
727    /// Returns [`LadduPhysicsError`] when nonzero charm conflicts with
728    /// self-conjugacy.
729    pub fn with_charm(self, c: i32) -> LadduPhysicsResult<Self> {
730        self.with_additive_qn("charm", |p| &mut p.charm, c)
731    }
732    /// Set the particle's total bottomness.
733    ///
734    /// # Errors
735    ///
736    /// Returns [`LadduPhysicsError`] when nonzero bottomness conflicts with
737    /// self-conjugacy.
738    pub fn with_bottomness(self, b: i32) -> LadduPhysicsResult<Self> {
739        self.with_additive_qn("bottomness", |p| &mut p.bottomness, b)
740    }
741    /// Set the particle's total topness.
742    ///
743    /// # Errors
744    ///
745    /// Returns [`LadduPhysicsError`] when nonzero topness conflicts with
746    /// self-conjugacy.
747    pub fn with_topness(self, t: i32) -> LadduPhysicsResult<Self> {
748        self.with_additive_qn("topness", |p| &mut p.topness, t)
749    }
750    /// Set strangeness, charm, bottomness, and topness together.
751    ///
752    /// # Errors
753    ///
754    /// Returns [`LadduPhysicsError`] when a nonzero flavor quantum number
755    /// conflicts with self-conjugacy.
756    pub fn with_flavor(self, s: i32, c: i32, b: i32, t: i32) -> LadduPhysicsResult<Self> {
757        self.with_strangeness(s)?
758            .with_charm(c)?
759            .with_bottomness(b)?
760            .with_topness(t)
761    }
762    /// Set the particle's total baryon number.
763    ///
764    /// # Errors
765    ///
766    /// Returns [`LadduPhysicsError`] when a nonzero baryon number conflicts
767    /// with self-conjugacy.
768    pub fn with_baryon_number(self, b: i32) -> LadduPhysicsResult<Self> {
769        self.with_additive_qn("baryon_number", |p| &mut p.baryon_number, b)
770    }
771    /// Set the particle's electron lepton number.
772    ///
773    /// # Errors
774    ///
775    /// Returns [`LadduPhysicsError`] when a nonzero electron lepton number
776    /// conflicts with self-conjugacy.
777    pub fn with_electron_lepton_number(self, e: i32) -> LadduPhysicsResult<Self> {
778        self.with_additive_qn(
779            "electron_lepton_number",
780            |p| &mut p.electron_lepton_number,
781            e,
782        )
783    }
784    /// Set the particle's muon lepton number.
785    ///
786    /// # Errors
787    ///
788    /// Returns [`LadduPhysicsError`] when a nonzero muon lepton number
789    /// conflicts with self-conjugacy.
790    pub fn with_muon_lepton_number(self, m: i32) -> LadduPhysicsResult<Self> {
791        self.with_additive_qn("muon_lepton_number", |p| &mut p.muon_lepton_number, m)
792    }
793    /// Set the particle's tau lepton number.
794    ///
795    /// # Errors
796    ///
797    /// Returns [`LadduPhysicsError`] when a nonzero tau lepton number
798    /// conflicts with self-conjugacy.
799    pub fn with_tau_lepton_number(self, t: i32) -> LadduPhysicsResult<Self> {
800        self.with_additive_qn("tau_lepton_number", |p| &mut p.tau_lepton_number, t)
801    }
802
803    /// Set electron-, muon-, and tau-family lepton numbers together.
804    ///
805    /// # Errors
806    ///
807    /// Returns [`LadduPhysicsError`] when a nonzero lepton number conflicts
808    /// with self-conjugacy.
809    pub fn with_lepton_numbers(self, e: i32, m: i32, t: i32) -> LadduPhysicsResult<Self> {
810        self.with_electron_lepton_number(e)?
811            .with_muon_lepton_number(m)?
812            .with_tau_lepton_number(t)
813    }
814
815    /// Set the particle's statistical nature.
816    ///
817    /// # Errors
818    ///
819    /// Returns [`LadduPhysicsError`] if the spin and statistics do not match.
820    pub fn with_statistics(mut self, s: Statistics) -> LadduPhysicsResult<Self> {
821        if let Some(spin) = self.spin
822            && Statistics::from_spin(spin) != s
823        {
824            return Err(LadduPhysicsError::invalid_relation(
825                "spin and statistics must be consistent",
826            ));
827        }
828        self.statistics = Some(s);
829        Ok(self)
830    }
831    /// Set the particle's mass.
832    pub fn with_mass(mut self, mass: f64) -> Self {
833        self.mass = Some(mass);
834        self
835    }
836
837    /// Set every flavor quantum number to zero.
838    pub fn with_zero_flavor(mut self) -> Self {
839        self.strangeness = Some(0);
840        self.charm = Some(0);
841        self.bottomness = Some(0);
842        self.topness = Some(0);
843        self
844    }
845
846    /// Set every family lepton number to zero.
847    pub fn with_zero_lepton_numbers(mut self) -> Self {
848        self.electron_lepton_number = Some(0);
849        self.muon_lepton_number = Some(0);
850        self.tau_lepton_number = Some(0);
851        self
852    }
853
854    /// Set charge, flavor, baryon number, and all lepton numbers to zero.
855    ///
856    /// # Errors
857    ///
858    /// Returns [`LadduPhysicsError`] when the resulting metadata violates
859    /// another particle invariant.
860    pub fn with_zero_additive_quantum_numbers(mut self) -> LadduPhysicsResult<Self> {
861        self.charge = Some(0);
862        self.strangeness = Some(0);
863        self.charm = Some(0);
864        self.bottomness = Some(0);
865        self.topness = Some(0);
866        self.baryon_number = Some(0);
867        self.electron_lepton_number = Some(0);
868        self.muon_lepton_number = Some(0);
869        self.tau_lepton_number = Some(0);
870
871        self.check_invariants()?;
872        Ok(self)
873    }
874
875    /// Returns true if `self` is the antiparticle of `other`.
876    pub fn is_antiparticle_of(&self, other: &ParticleProperties) -> bool {
877        let a_species = self.species.as_ref();
878        let b_species = other.species.as_ref();
879
880        let a_anti = self.antiparticle_species.as_ref();
881        let b_anti = other.antiparticle_species.as_ref();
882
883        match (a_species, b_species, a_anti, b_anti) {
884            (Some(a), Some(b), Some(a_bar), Some(b_bar)) => a_bar == b && b_bar == a,
885            (Some(_), Some(b), Some(a_bar), None) => a_bar == b,
886            (Some(a), Some(_), None, Some(b_bar)) => b_bar == a,
887            _ => false,
888        }
889    }
890
891    /// External identifiers for the particle
892    pub fn ids(&self) -> &IndexMap<String, ExternalId> {
893        &self.ids
894    }
895
896    /// Return the first external identifier in the requested namespace.
897    pub fn id(&self, namespace: &str) -> Option<&ExternalId> {
898        self.ids.get(namespace)
899    }
900
901    /// Append an external identifier.
902    pub fn with_id<Id: Into<ExternalId>>(mut self, namespace: &str, id: Id) -> Self {
903        self.ids.insert(namespace.to_string(), id.into());
904        self
905    }
906
907    /// Replace external identifiers.
908    pub fn with_ids<I, S, Id>(mut self, ids: I) -> Self
909    where
910        I: IntoIterator<Item = (S, Id)>,
911        S: AsRef<str>,
912        Id: Into<ExternalId>,
913    {
914        self.ids = ids
915            .into_iter()
916            .map(|(s, id)| (s.as_ref().to_string(), id.into()))
917            .collect();
918        self
919    }
920}
921
922impl ParticleProperties {
923    fn additive_quantum_number_fields(&self) -> [(&'static str, Option<i32>); 9] {
924        [
925            ("charge", self.charge),
926            ("strangeness", self.strangeness),
927            ("charm", self.charm),
928            ("bottomness", self.bottomness),
929            ("topness", self.topness),
930            ("baryon_number", self.baryon_number),
931            ("electron_lepton_number", self.electron_lepton_number),
932            ("muon_lepton_number", self.muon_lepton_number),
933            ("tau_lepton_number", self.tau_lepton_number),
934        ]
935    }
936
937    fn fill_zero_additive_qns_if_self_conjugate(&mut self) {
938        if self.self_conjugate == Some(true) {
939            self.charge.get_or_insert(0);
940            self.strangeness.get_or_insert(0);
941            self.charm.get_or_insert(0);
942            self.bottomness.get_or_insert(0);
943            self.topness.get_or_insert(0);
944            self.baryon_number.get_or_insert(0);
945            self.electron_lepton_number.get_or_insert(0);
946            self.muon_lepton_number.get_or_insert(0);
947            self.tau_lepton_number.get_or_insert(0);
948        }
949    }
950
951    fn check_self_conjugate_additive_qns(&self) -> LadduPhysicsResult<()> {
952        if self.self_conjugate == Some(true) {
953            for (property, value) in self.additive_quantum_number_fields() {
954                if matches!(value, Some(v) if v != 0) {
955                    return Err(LadduPhysicsError::invalid_relation(format!(
956                        "self-conjugate particles must have {property} = 0"
957                    )));
958                }
959            }
960        }
961        Ok(())
962    }
963
964    fn check_c_parity_allowed(&self) -> LadduPhysicsResult<()> {
965        if self.c_parity.is_some() && self.self_conjugate == Some(false) {
966            return Err(LadduPhysicsError::invalid_relation(
967                "C-parity is only applicable to self-conjugate particles",
968            ));
969        }
970        Ok(())
971    }
972
973    fn check_invariants(&self) -> LadduPhysicsResult<()> {
974        self.check_self_conjugate_additive_qns()?;
975        self.check_c_parity_allowed()?;
976        Ok(())
977    }
978
979    fn with_additive_qn(
980        mut self,
981        property: &'static str,
982        field: fn(&mut Self) -> &mut Option<i32>,
983        value: i32,
984    ) -> LadduPhysicsResult<Self> {
985        if self.self_conjugate == Some(true) && value != 0 {
986            return Err(LadduPhysicsError::invalid_value(
987                property,
988                "0 for self-conjugate particles",
989                value,
990            ));
991        }
992
993        *field(&mut self) = Some(value);
994        self.check_invariants()?;
995        Ok(self)
996    }
997}
998
999fn validate_projection(spin: J, projection: M) -> LadduPhysicsResult<()> {
1000    if projection.doubled().unsigned_abs() > spin.doubled() {
1001        return Err(LadduPhysicsError::invalid_relation(format!(
1002            "spin projection must satisfy -J <= m <= J, got J = {spin}, m = {projection}"
1003        )));
1004    }
1005    if !spin.has_same_parity_as(projection) {
1006        return Err(LadduPhysicsError::invalid_relation(format!(
1007            "spin projection must have the same integer/half-integer parity as spin, got J = {spin}, m = {projection}"
1008        )));
1009    }
1010    Ok(())
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::{ExternalId, ParticleProperties};
1016
1017    #[test]
1018    fn particle_properties_store_external_ids() {
1019        let properties = ParticleProperties::unknown()
1020            .with_id("pdg", ExternalId::code(310))
1021            .with_id("gluex", ExternalId::label("ks-short"));
1022
1023        assert_eq!(properties.ids().len(), 2);
1024        assert_eq!(
1025            properties.id("pdg").and_then(ExternalId::code_value),
1026            Some(310)
1027        );
1028        assert_eq!(
1029            properties.id("gluex").and_then(ExternalId::label_value),
1030            Some("ks-short")
1031        );
1032        assert_eq!(properties.id("missing"), None);
1033
1034        let replaced = properties.with_ids([("geant", ExternalId::code(16))]);
1035        assert_eq!(replaced.ids().len(), 1);
1036        assert_eq!(
1037            replaced.id("geant").and_then(ExternalId::code_value),
1038            Some(16)
1039        );
1040    }
1041}