Skip to main content

laddu_core/reaction/
topology.rs

1use serde::{Deserialize, Serialize};
2
3use super::{
4    decay::Decay,
5    graph::ParticleGraph,
6    particle::{resolve_particle_direct, Particle},
7    production::Production,
8    two_to_two::{ResolvedTwoToTwo, TwoToTwoReaction},
9};
10use crate::{
11    data::EventLike,
12    kinematics::{DecayAngles, FrameAxes, RestFrame},
13    quantum::{Channel, Frame},
14    variables::{Mandelstam, Mass, PolAngle, Polarization},
15    vectors::Vec4,
16    LadduError, LadduResult,
17};
18
19/// A reaction topology.
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
21pub enum ReactionTopology {
22    /// A two-to-two reaction with `p1 + p2 -> p3 + p4` semantics.
23    TwoToTwo(TwoToTwoReaction),
24}
25
26/// A reaction with owned particle definitions and topology roles.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct Reaction {
29    graph: ParticleGraph,
30    topology: ReactionTopology,
31}
32
33impl Reaction {
34    /// Construct a reaction from a particle graph and topology.
35    pub fn new(graph: ParticleGraph, topology: ReactionTopology) -> LadduResult<Self> {
36        topology.validate(&graph)?;
37        Ok(Self { graph, topology })
38    }
39
40    /// Construct a two-to-two reaction.
41    pub fn two_to_two(
42        p1: &Particle,
43        p2: &Particle,
44        p3: &Particle,
45        p4: &Particle,
46    ) -> LadduResult<Self> {
47        Self::new(
48            ParticleGraph::new([p1.clone(), p2.clone(), p3.clone(), p4.clone()])?,
49            ReactionTopology::TwoToTwo(TwoToTwoReaction::new(p1, p2, p3, p4)?),
50        )
51    }
52
53    /// Return the owned particle graph.
54    pub const fn graph(&self) -> &ParticleGraph {
55        &self.graph
56    }
57
58    /// Return the reaction topology.
59    pub const fn topology(&self) -> &ReactionTopology {
60        &self.topology
61    }
62
63    /// Return the two-to-two topology role assignments.
64    pub fn two_to_two_topology(&self) -> LadduResult<&TwoToTwoReaction> {
65        match &self.topology {
66            ReactionTopology::TwoToTwo(topology) => Ok(topology),
67        }
68    }
69
70    /// Return the particle assigned to a named topology role.
71    pub fn role(&self, role: &str) -> LadduResult<&Particle> {
72        match &self.topology {
73            ReactionTopology::TwoToTwo(topology) => self.graph.particle(topology.role(role)?),
74        }
75    }
76
77    /// Resolve a particle p4 from an event.
78    pub fn p4(&self, event: &dyn EventLike, particle: &str) -> LadduResult<Vec4> {
79        let particle = self.particle(particle)?;
80        match &self.topology {
81            ReactionTopology::TwoToTwo(topology) => {
82                if topology.missing_particle() == Some(particle.label()) {
83                    if let Some(index) = topology.missing_index() {
84                        return Ok(match index {
85                            0 => topology.resolve(&self.graph, event)?.p1(),
86                            1 => topology.resolve(&self.graph, event)?.p2(),
87                            2 => topology.resolve(&self.graph, event)?.p3(),
88                            3 => topology.resolve(&self.graph, event)?.p4(),
89                            _ => unreachable!("validated two-to-two slot index"),
90                        });
91                    }
92                }
93                resolve_particle_direct(event, particle)?.ok_or_else(|| {
94                    LadduError::Custom(format!(
95                        "missing particle '{}' is not a missing role in this reaction",
96                        particle.label()
97                    ))
98                })
99            }
100        }
101    }
102
103    /// Resolve the two-to-two topology momenta from an event.
104    pub fn resolve_two_to_two(&self, event: &dyn EventLike) -> LadduResult<ResolvedTwoToTwo> {
105        match &self.topology {
106            ReactionTopology::TwoToTwo(topology) => topology.resolve(&self.graph, event),
107        }
108    }
109
110    /// Construct a mass variable for a particle.
111    pub fn mass(&self, particle: &str) -> Mass {
112        Mass::from_reaction(self.clone(), particle)
113    }
114
115    /// Construct an isobar decay view from a composite parent.
116    pub fn decay(&self, parent: &str) -> LadduResult<Decay> {
117        let parent_particle = self.particle(parent)?;
118        self.root_particle_for(parent)?;
119        let daughters = parent_particle.daughters();
120        if daughters.len() != 2 {
121            return Err(LadduError::Custom(
122                "isobar decays must contain exactly two ordered daughters".to_string(),
123            ));
124        }
125        Ok(Decay {
126            reaction: self.clone(),
127            parent: parent.to_string(),
128            daughter_1: daughters[0].label().to_string(),
129            daughter_2: daughters[1].label().to_string(),
130        })
131    }
132
133    /// Construct a two-to-two production view.
134    pub fn production(&self) -> LadduResult<Production> {
135        let topology = self.two_to_two_topology()?;
136        Ok(Production {
137            reaction: self.clone(),
138            produced: topology.p3().to_string(),
139            recoil: topology.p4().to_string(),
140        })
141    }
142
143    /// Construct a Mandelstam variable for this reaction.
144    pub fn mandelstam(&self, channel: Channel) -> LadduResult<Mandelstam> {
145        self.topology.require_mandelstam()?;
146        Ok(Mandelstam::new(self.clone(), channel))
147    }
148
149    /// Construct a polarization-angle variable for this reaction.
150    pub fn pol_angle<A>(&self, angle_aux: A) -> PolAngle
151    where
152        A: Into<String>,
153    {
154        PolAngle::new(self.clone(), angle_aux)
155    }
156
157    /// Construct polarization variables for this reaction.
158    pub fn polarization<M, A>(&self, magnitude_aux: M, angle_aux: A) -> Polarization
159    where
160        M: Into<String>,
161        A: Into<String>,
162    {
163        Polarization::new(self.clone(), magnitude_aux, angle_aux)
164    }
165
166    /// Compute axes for a particle in this reaction.
167    pub fn axes(
168        &self,
169        event: &dyn EventLike,
170        particle: &str,
171        frame: Frame,
172    ) -> LadduResult<FrameAxes> {
173        let (root_index, root) = self.root_particle_for(particle)?;
174        let particle_ref = self.particle(particle)?;
175        if particle_ref == root {
176            let resolved = self.resolve_two_to_two(event)?;
177            let (parent, spectator) = match root_index {
178                2 => (resolved.p3, resolved.p4),
179                3 => (resolved.p4, resolved.p3),
180                _ => {
181                    return Err(LadduError::Custom(
182                        "only outgoing p3 and p4 decay roots have frame axes".to_string(),
183                    ));
184                }
185            };
186            return FrameAxes::from_production_frame(
187                frame,
188                resolved.p1,
189                parent,
190                spectator,
191                resolved.com_boost(),
192            );
193        }
194        let parent = self.parent_of(particle).ok_or_else(|| {
195            LadduError::Custom(format!(
196                "particle '{}' is not connected to the reaction root",
197                particle
198            ))
199        })?;
200        let parent_axes = self.axes(event, &parent, frame)?;
201        parent_axes.for_daughter(self.p4_in_rest_frame_of(event, &parent, particle)?.vec3())
202    }
203
204    /// Compute a daughter's decay angles in its parent rest frame.
205    pub fn angles_value(
206        &self,
207        event: &dyn EventLike,
208        parent: &str,
209        daughter: &str,
210        frame: Frame,
211    ) -> LadduResult<DecayAngles> {
212        let parent_particle = self.particle(parent)?;
213        if !parent_particle
214            .daughters()
215            .iter()
216            .any(|candidate| candidate.label() == daughter)
217        {
218            return Err(LadduError::Custom(
219                "daughter is not an immediate child of parent".to_string(),
220            ));
221        }
222        let axes = self.axes(event, parent, frame)?;
223        axes.angles(self.p4_in_rest_frame_of(event, parent, daughter)?.vec3())
224    }
225
226    /// Compute the produced-system production angles in the overall center-of-momentum frame.
227    pub fn production_angles_value(
228        &self,
229        event: &dyn EventLike,
230        produced: &str,
231        frame: Frame,
232    ) -> LadduResult<DecayAngles> {
233        let topology = self.two_to_two_topology()?;
234        if produced != topology.p3() {
235            return Err(LadduError::Custom(format!(
236                "particle '{produced}' is not the produced p3 system"
237            )));
238        }
239        let resolved = self.resolve_two_to_two(event)?;
240        let com_boost = resolved.com_boost();
241        let reference = resolved.p1.boost(&com_boost);
242        let produced_p4 = resolved.p3.boost(&com_boost);
243        let produced_vec = produced_p4.vec3();
244        let plane_normal = reference.vec3().cross(&produced_vec);
245        let z = match frame {
246            Frame::Helicity => produced_vec,
247            Frame::GottfriedJackson => reference.vec3(),
248            Frame::Adair => {
249                return Err(LadduError::Custom(
250                    "Adair frame construction is not implemented yet".to_string(),
251                ));
252            }
253        };
254        FrameAxes::from_z_and_plane_normal(z, plane_normal)?.angles(produced_vec)
255    }
256
257    /// Return the particle with the given identifier.
258    pub fn particle(&self, particle: &str) -> LadduResult<&Particle> {
259        self.graph.particle(particle)
260    }
261
262    /// Return whether the reaction contains a particle with the given identifier.
263    pub fn contains(&self, particle: &str) -> bool {
264        self.graph.contains(particle)
265    }
266
267    /// Return all particle definitions in this reaction.
268    pub fn particles(&self) -> Vec<&Particle> {
269        self.graph.particles()
270    }
271
272    fn root_particle_for(&self, particle: &str) -> LadduResult<(usize, &Particle)> {
273        match &self.topology {
274            ReactionTopology::TwoToTwo(topology) => {
275                let p3 = self.graph.particle(topology.p3())?;
276                let p4 = self.graph.particle(topology.p4())?;
277                for (index, root) in [(2, p3), (3, p4)] {
278                    if root.contains_id(particle) {
279                        return Ok((index, root));
280                    }
281                }
282            }
283        }
284        Err(LadduError::Custom(
285            "particle is not contained in an outgoing reaction root".to_string(),
286        ))
287    }
288
289    fn parent_of(&self, child: &str) -> Option<String> {
290        match &self.topology {
291            ReactionTopology::TwoToTwo(topology) => {
292                let p3 = self.graph.particle(topology.p3()).ok()?;
293                let p4 = self.graph.particle(topology.p4()).ok()?;
294                [p3, p4].into_iter().find_map(|root| {
295                    root.parent_of_id(child)
296                        .map(|particle| particle.label().to_string())
297                })
298            }
299        }
300    }
301
302    fn p4_in_rest_frame_of(
303        &self,
304        event: &dyn EventLike,
305        frame_particle: &str,
306        target: &str,
307    ) -> LadduResult<Vec4> {
308        let (_, root) = self.root_particle_for(frame_particle)?;
309        if frame_particle == root.label() {
310            let resolved = self.resolve_two_to_two(event)?;
311            let com_boost = resolved.com_boost();
312            let root_rest = RestFrame::new(self.p4(event, root.label())?.boost(&com_boost))?;
313            return Ok(root_rest.transform(self.p4(event, target)?.boost(&com_boost)));
314        }
315        let parent = self.parent_of(frame_particle).ok_or_else(|| {
316            LadduError::Custom("frame particle is not connected to root".to_string())
317        })?;
318        let frame_particle_in_parent = self.p4_in_rest_frame_of(event, &parent, frame_particle)?;
319        let target_in_parent = self.p4_in_rest_frame_of(event, &parent, target)?;
320        Ok(RestFrame::new(frame_particle_in_parent)?.transform(target_in_parent))
321    }
322}
323
324impl ReactionTopology {
325    /// Return whether this topology supports Mandelstam variables.
326    pub const fn supports_mandelstam(&self) -> bool {
327        matches!(self, ReactionTopology::TwoToTwo(_))
328    }
329
330    /// Require this topology to support Mandelstam variables.
331    pub fn require_mandelstam(&self) -> LadduResult<()> {
332        if self.supports_mandelstam() {
333            Ok(())
334        } else {
335            Err(LadduError::Custom(
336                "Mandelstam variables require a supported production topology".to_string(),
337            ))
338        }
339    }
340
341    /// Validate topology roles against a particle graph.
342    pub fn validate(&self, graph: &ParticleGraph) -> LadduResult<()> {
343        match self {
344            ReactionTopology::TwoToTwo(topology) => {
345                for role in ["p1", "p2", "p3", "p4"] {
346                    graph.particle(topology.role(role)?)?;
347                }
348                Ok(())
349            }
350        }
351    }
352}