Reconstructing particles: invariant & missing mass
Inclusive DIS only used the electron. Real analyses combine particles: pair two photons into a , add a hadron for SIDIS, or reconstruct an exclusive reaction by what's missing. This is where Awkward's combinatorics earn their keep.
: invariant mass
A decays to two photons before it reaches the detector, so you never see it directly — you see two photons and reconstruct their invariant mass. If a pair came from a , that mass equals GeV.
The move is ak.combinations, which forms all unordered pairs within each
event:
photons = p[p.pid == 22]
pairs = ak.combinations(photons, 2) # every γγ pair, per event
g1, g2 = ak.unzip(pairs) # two aligned photon arrays
def inv_mass_2(a, b): # photons are massless: E = |p|
Ea = np.sqrt(a.px**2 + a.py**2 + a.pz**2)
Eb = np.sqrt(b.px**2 + b.py**2 + b.pz**2)
return np.sqrt(np.maximum(0, (Ea + Eb)**2
- (a.px + b.px)**2 - (a.py + b.py)**2 - (a.pz + b.pz)**2))
m_gg = ak.flatten(inv_mass_2(g1, g2)) # all pair masses, flattened
import mplhep as hep
counts, edges = np.histogram(ak.to_numpy(m_gg), bins=80, range=(0.05, 0.35))
hep.histplot(counts, edges, histtype="fill", alpha=0.85)

A clean peak at 0.135 GeV (σ ≈ 6 MeV here; ~10–15 MeV on real data, set by the calorimeter resolution) over a combinatorial background. To use the s — count them, or feed them into a higher final state — cut a window and treat each surviving pair as a candidate:
is_pi0 = (m_gg > 0.11) & (m_gg < 0.16)
On real data you'd fit the peak (a Gaussian plus a polynomial background) and sideband-subtract, but the window is the first cut.
With three photons in an event, ak.combinations gives three pairs and at most
one is a true — the rest are the background under the peak. This is
intrinsic to combinatorics, not a bug; sideband subtraction is how you remove it.
Semi-inclusive DIS: adding a hadron
SIDIS detects the scattered electron and a hadron, and describes the hadron in variables relative to the virtual photon :
- — the fraction of the energy transfer the hadron carries,
- — the hadron momentum transverse to ,
- — the azimuth of the hadron around (the Trento angle).
M_PIP = ox.pdg_mass(211) # 0.13957039 GeV
pip = p[p.pid == 211]
lead = ak.argmax(np.sqrt(pip.px**2 + pip.py**2 + pip.pz**2), axis=1, keepdims=True)
h = ak.firsts(pip[lead]) # leading π⁺ per event (None if the event has none)
Eh = np.sqrt(h.px**2 + h.py**2 + h.pz**2 + M_PIP**2)
z = Eh / kin.nu # kin.nu from the DIS kinematics function
# q vector = beam - scattered electron
qx, qy, qz = -ele.px, -ele.py, 10.604 - ele.pz
qmag = np.sqrt(qx**2 + qy**2 + qz**2)
h_par = (h.px*qx + h.py*qy + h.pz*qz) / qmag # momentum along q
pT = np.sqrt(np.maximum(0, h.px**2 + h.py**2 + h.pz**2 - h_par**2))
z runs 0–1 (mean ≈ 0.5 on the sample) and pT up to ~1–2 GeV — the SIDIS
kinematic plane. The Trento needs the cross products of the lepton and
hadron planes; scikit-hep vector makes that a
one-liner (q.deltaphi(h) after rotating into the frame), which is the
point at which a four-vector library stops being optional.
Exclusive reactions: missing mass
The most powerful trick: if you detect all but one particle in a reaction, the undetected particle's mass is the missing mass — and it peaks at that particle's true mass. For , detecting and and missing the neutron:
M_P = ox.pdg_mass(2212)
beam_E, target_E = 10.604, M_P # target proton at rest
miss_E = beam_E + target_E - Ee_scattered - Eh # Ee_scattered = |p| of e'
miss_px = -(ele.px + h.px)
miss_py = -(ele.py + h.py)
miss_pz = beam_E - (ele.pz + h.pz)
MX = np.sqrt(np.maximum(0, miss_E**2 - miss_px**2 - miss_py**2 - miss_pz**2))
counts, edges = np.histogram(ak.to_numpy(MX[~ak.is_none(MX)]), bins=80, range=(0.5, 2.0))
hep.histplot(counts, edges, histtype="fill", alpha=0.85)

The sharp peak at the neutron mass (0.94 GeV) is the exclusive signal; the broad continuum above it is SIDIS, where extra undetected particles push the missing mass up. Isolate the exclusive channel with a missing-mass cut:
exclusive = (MX > 0.85) & (MX < 1.03) # a window around the neutron
That's the whole idea behind exclusive physics at CLAS12 — DVCS (), electroproduction, and the rest are the same recipe with more detected particles and tighter missing-mass (or missing-momentum) constraints.
Putting it together
A realistic selection chains everything you've built: trigger-electron ID
(sampling fraction + Cherenkov + vertex + fiducial), DIS cuts (, , ),
a hadron with its own PID, and a missing-mass or invariant-mass window — each an
aligned boolean mask, &-ed together, with a cutflow tracking the survivors.
That analysis object is what you then want to run over all the data, fast.