Skip to main content

laddu_physics/quantum/
partial_waves.rs

1use std::fmt::Display;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{LadduPhysicsError, LadduPhysicsResult};
6
7use super::{J, L, Parity, ParticleProperties, RuleReport, RuleSet, S};
8
9/// A partial wave defined by a total angular momentum, `J`, an orbital angular momentum, `L`, and
10/// and intrinsic spin, `S`.
11#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
12pub struct PartialWave {
13    /// The total angular momentum of the wave
14    pub j: J,
15    /// The orbital angular momentum of the wave
16    pub l: L,
17    /// The spin of the wave
18    pub s: S,
19}
20impl PartialWave {
21    /// Construct a new partial wave from the given angular momentum quantum numbers.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`LadduPhysicsError`] when `j`, `l`, and `s` violate angular
26    /// momentum coupling rules.
27    pub fn new(j: J, l: L, s: S) -> LadduPhysicsResult<Self> {
28        PartialWave::validate_coupling(j, l, s)?;
29        Ok(Self { j, l, s })
30    }
31    /// Get the spectroscopic label for the wave in the form {2s+1}{l}{j} where l is represented by
32    /// its spectroscopic letter equivalent (`S` for `0`, `P` for `1`, etc.).
33    pub fn label(&self) -> String {
34        let multiplicity = self.s.doubled() + 1;
35        format!("{}{}{}", multiplicity, self.l, self.j)
36    }
37    /// Validate the set of angular momentum quantum numbers which define a partial wave.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`LadduPhysicsError`] when `j` lies outside the range permitted
42    /// by `l` and `s` or has incompatible integer/half-integer parity.
43    pub fn validate_coupling(j: J, l: L, s: S) -> LadduPhysicsResult<()> {
44        let l_twice = 2 * l.value();
45        let s_twice = s.doubled();
46        let j_twice = j.doubled();
47        let min = l_twice.abs_diff(s_twice);
48        let max = l_twice + s_twice;
49        if j_twice >= min && j_twice <= max && (j_twice - min).is_multiple_of(2) {
50            Ok(())
51        } else {
52            Err(LadduPhysicsError::invalid_relation(
53                "j, l, and s must be compatible",
54            ))
55        }
56    }
57}
58
59impl Display for PartialWave {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        write!(f, "{}", self.label())
62    }
63}
64
65/// A partial wave together with allowed parity and C-parity, if applicable.
66#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
67pub struct AllowedPartialWave {
68    /// The angular quantum numbers of the wave
69    pub wave: PartialWave,
70    /// The allowed parity, if applicable
71    pub parity: Option<Parity>,
72    /// The allowed C-parity, if applicable
73    pub c_parity: Option<Parity>,
74}
75
76impl AllowedPartialWave {
77    /// Take an existing [`PartialWave`] and infer parity and C-parity from its decay products.
78    pub fn new(wave: PartialWave, daughters: (&ParticleProperties, &ParticleProperties)) -> Self {
79        Self {
80            parity: infer_parity(daughters, wave.l),
81            c_parity: infer_c_parity(daughters, wave.l, wave.s),
82            wave,
83        }
84    }
85}
86
87pub(super) fn infer_parity(
88    daughters: (&ParticleProperties, &ParticleProperties),
89    l: L,
90) -> Option<Parity> {
91    Some(daughters.0.parity? * daughters.1.parity? * l.orbital_parity())
92}
93
94pub(super) fn infer_c_parity(
95    daughters: (&ParticleProperties, &ParticleProperties),
96    l: L,
97    s: S,
98) -> Option<Parity> {
99    daughters.0.is_antiparticle_of(daughters.1).then_some(())?;
100    let s_doubled = s.doubled();
101    if !s_doubled.is_multiple_of(2) {
102        return None;
103    }
104    Some(L::int(l.value() + (s_doubled / 2)).orbital_parity())
105}
106
107#[derive(Clone, Debug, Eq, PartialEq)]
108/// A generated partial-wave candidate together with its inferred properties and
109/// selection-rule report.
110pub struct PartialWaveCandidate {
111    /// Angular quantum numbers of the candidate.
112    pub wave: PartialWave,
113    /// Candidate wave plus its channel-dependent inferred parity values.
114    pub inferred: AllowedPartialWave,
115    /// Detailed outcomes from the configured rules.
116    pub report: RuleReport,
117}
118
119impl PartialWaveCandidate {
120    /// Return whether the candidate passed every enforced rule.
121    pub fn is_allowed(&self) -> bool {
122        self.report.is_allowed()
123    }
124}
125
126#[derive(Clone, Debug, Eq, PartialEq, Default)]
127/// Complete result of scanning a two-body channel for partial waves.
128pub struct PartialWaveScan {
129    /// All generated candidates, including rejected waves.
130    pub candidates: Vec<PartialWaveCandidate>,
131    /// Required properties which prevented candidate generation.
132    pub missing_inputs: Vec<String>,
133}
134
135impl PartialWaveScan {
136    /// Iterate over the inferred properties of accepted waves.
137    pub fn allowed(&self) -> impl Iterator<Item = &AllowedPartialWave> {
138        self.candidates
139            .iter()
140            .filter(|candidate| candidate.is_allowed())
141            .map(|candidate| &candidate.inferred)
142    }
143
144    /// Iterate over candidates rejected by at least one enforced rule.
145    pub fn rejected(&self) -> impl Iterator<Item = &PartialWaveCandidate> {
146        self.candidates
147            .iter()
148            .filter(|candidate| !candidate.is_allowed())
149    }
150
151    /// Consume the scan and collect its accepted waves.
152    pub fn into_allowed(self) -> Vec<AllowedPartialWave> {
153        self.candidates
154            .into_iter()
155            .filter_map(|candidate| {
156                if candidate.is_allowed() {
157                    Some(candidate.inferred)
158                } else {
159                    None
160                }
161            })
162            .collect()
163    }
164}
165
166/// Configuration for generating and filtering allowed two-body partial waves.
167///
168/// `SelectionRules` combines a maximum orbital angular momentum with a
169/// [`RuleSet`]. Candidate waves are generated from angular-momentum coupling
170/// and are then filtered by the enabled rules.
171///
172/// The generated waves satisfy
173/// $`S \in |j_a - j_b|, \ldots, j_a + j_b`$
174/// and
175/// $`J \in |L - S|, \ldots, L + S`$,
176/// with $`0 \le L \le L_\text{max}`$.
177#[derive(Clone, Debug, Eq, Hash, PartialEq)]
178pub struct SelectionRules {
179    /// Conservation and symmetry rules used to filter candidate waves.
180    ///
181    /// Angular-momentum compatibility is handled by
182    /// [`SelectionRules::allowed_partial_waves`]. The [`RuleSet`] applies
183    /// additional checks such as parity, charge, isospin, flavor quantum
184    /// numbers, $`C`$-parity, $`G`$-parity, and identical-particle symmetry.
185    pub rules: RuleSet,
186    /// Maximum orbital angular momentum $`L_\text{max}`$ considered when
187    /// generating candidate partial waves.
188    ///
189    /// The solver scans all integer values
190    /// $`L = 0, 1, \ldots, L_\text{max}`$.
191    pub max_l: L,
192}
193
194impl Default for SelectionRules {
195    fn default() -> Self {
196        Self::strong(L::int(6))
197    }
198}
199
200impl SelectionRules {
201    /// Construct a partial-wave scanner from a rule set and maximum orbital
202    /// angular momentum.
203    pub fn new(rules: RuleSet, max_l: L) -> Self {
204        Self { rules, max_l }
205    }
206
207    /// Construct a scanner which applies only angular-momentum coupling.
208    pub fn angular(max_l: L) -> Self {
209        Self::new(RuleSet::angular(), max_l)
210    }
211
212    /// Construct a scanner configured for electromagnetic decays.
213    pub fn electromagnetic(max_l: L) -> Self {
214        Self::new(RuleSet::electromagnetic(), max_l)
215    }
216
217    /// Construct a scanner configured for weak decays.
218    pub fn weak(max_l: L) -> Self {
219        Self::new(RuleSet::weak(), max_l)
220    }
221
222    /// Construct a scanner configured for strong decays.
223    pub fn strong(max_l: L) -> Self {
224        Self::new(RuleSet::strong(), max_l)
225    }
226    /// Return all possible coupled total spins from two daughter spins.
227    ///
228    /// Given daughter spins $`j_a`$ and $`j_b`$, this returns
229    /// $`S = |j_a - j_b|, |j_a - j_b| + 1, \ldots, j_a + j_b`$.
230    ///
231    /// Internally angular momenta are stored as doubled values, so the returned
232    /// sequence advances by two in the doubled representation.
233    pub fn coupled_spins(a: J, b: J) -> Vec<S> {
234        a.coupled_with(b)
235    }
236
237    /// Generate all candidates and retain detailed reports for accepted and
238    /// rejected waves.
239    pub fn scan_partial_waves(
240        &self,
241        parent: &ParticleProperties,
242        daughters: (&ParticleProperties, &ParticleProperties),
243    ) -> PartialWaveScan {
244        let mut missing_inputs = Vec::new();
245
246        let Some(parent_j) = parent.spin else {
247            missing_inputs.push("parent.spin".to_string());
248            return PartialWaveScan {
249                candidates: Vec::new(),
250                missing_inputs,
251            };
252        };
253
254        let Some(ja) = daughters.0.spin else {
255            missing_inputs.push("daughter_a.spin".to_string());
256            return PartialWaveScan {
257                candidates: Vec::new(),
258                missing_inputs,
259            };
260        };
261
262        let Some(jb) = daughters.1.spin else {
263            missing_inputs.push("daughter_b.spin".to_string());
264            return PartialWaveScan {
265                candidates: Vec::new(),
266                missing_inputs,
267            };
268        };
269
270        let mut candidates = Vec::new();
271
272        for s in Self::coupled_spins(ja, jb) {
273            for l_raw in 0..=self.max_l.value() {
274                let l = L::int(l_raw);
275
276                let Ok(wave) = PartialWave::new(parent_j, l, s) else {
277                    continue;
278                };
279
280                let report = self.rules.evaluate(parent, daughters, l, s);
281                let inferred = AllowedPartialWave::new(wave, daughters);
282
283                candidates.push(PartialWaveCandidate {
284                    wave,
285                    inferred,
286                    report,
287                });
288            }
289        }
290
291        PartialWaveScan {
292            candidates,
293            missing_inputs,
294        }
295    }
296
297    /// Generate all allowed two-body partial waves for a parent and two
298    /// daughters.
299    ///
300    /// The parent spin is interpreted as the total angular momentum $`J`$ of
301    /// the resonance. The daughter spins are coupled to possible total-spin
302    /// values $`S`$, and each $`S`$ is combined with orbital angular momenta
303    /// $`L = 0, 1, \ldots, L_\text{max}`$.
304    ///
305    /// A candidate wave is kept when:
306    ///
307    /// 1. $`L`$ and $`S`$ can couple to the parent $`J`$.
308    /// 2. The enabled [`RuleSet`] checks do not reject it.
309    ///
310    /// Returns an empty vector if the parent spin or either daughter spin is
311    /// unknown.
312    ///
313    /// The returned [`AllowedPartialWave`] includes the underlying
314    /// [`PartialWave`] together with channel-dependent inferred quantum numbers,
315    /// such as final-state parity and, when meaningful, $`C`$-parity.
316    pub fn allowed_partial_waves(
317        &self,
318        parent: &ParticleProperties,
319        daughters: (&ParticleProperties, &ParticleProperties),
320    ) -> Vec<AllowedPartialWave> {
321        self.scan_partial_waves(parent, daughters).into_allowed()
322    }
323}