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}