All in One View

Content from Introduction


Last updated on 2026-07-11 | Edit this page

Estimated time: 10 minutes

Overview

Questions

  • Why should I be interested in track-cluster matching?

Objectives

  • Understand physical motivation behind examples in tutorial

Like we noted in the Setup section, our goal in this tutorial is to understand how to use the PODIO interface in an analysis. As such, this tutorial will build on the earlier Analysis Tutorial, which discussed how to analyze our reconstruction output with the ROOT TTreeReader and RDataFrame.

Our vehicle for doing so will be two commmon and complementary analysis routines:

  1. Matching tracks and clusters; and
  2. Matching truth objects to their reconstructed counterparts.

On one hand, the former utilizes purely reconstructed objects (tracks and clusters) and will illustrate the basics of PODIO. While on the other hand, the latter utilizes purely simulated objects (generated particles) and will illustrate how to navigate relations and associations.

We’ll deploy these routines to study \(J/\psi\) decaying to \(e^{+} + e^{-}\) pairs, a common decay channel used in many areas of EIC science.

Why these?


Before diving into PODIO, though, I would like to motivate why you should care about these two routines. To start, remember that a particle is not just a track, a cluster, or so on. A particle will leave signals in several detectors. The majority of particles in a typical DIS event will create hits in our trackers and will shower — forming a cluster — in at least one of our calorimeters.

This is illustrated in the following image, where the colored bands indicate the tracking layers (orange), cherenkov detector (pink), electromagnetic calorimeter (ECal, purple), magnet solenoid (grey), and hadronic calorimeter (HCal, blue) in ePIC’s barrel.

Particle trajectory crossing tracking layers and showering in the barrel calorimeter
Illustration of a particle leaving signals in several detectors

Accurately reconstructing the particle — its momentum, energy, charge, mass — will require us to make use of all of this information. For example, an electron will create a track and will usually deposit all of its energy into an ECal. In contrast, a charged hadron like a \(\pi^{-}\)), will frequently deposit energy into HCal, while a \(\pi^{0}\) will deposit energy into an ECal without creating a track. This is illustrated in the following figure.

Different particle species penetrating to different depths of the detector
Diagram of particle types vs. typical survival depth in a detector

Therefore, track-cluster matching is a critical step in our reconstruction, which we use to identify leptons, measure neutral particles, and more. In this tutorial, we’ll use it identify decay \(e^{\pm}\) which we’ll reconstruct a \(J/\psi\) from.

To validate our reconstructed \(J/\psi\), however, we’ll need to identify the corresponding simulated particles and compare them. That’s where the truth-reco matching comes in.

References


Key Points
  • Track-cluster is a critical part of reconstructing particles
  • Truth-reconstructed matching is needed to validate reconstructed particles

Content from Using PODIO to Work With Our Data Model


Last updated on 2026-07-11 | Edit this page

Estimated time: 25 minutes

Overview

Questions

  • What is our data model?
  • What is PODIO?
  • How do I interface with our data model?

Objectives

  • Understand relationship between the data model and PODIO
  • Become familiar with PODIO concepts and synatx

The EDM4eic Data Model


A data model is how we represent our data in our software. In other words, a standardized set of data structures that we use to pass information between different parts of our software stack (DD4hep, EICrecon, etc.) and between different algorithms in those parts.

Boxes for each EDM4eic data structure connected by arrows indicating their relations
Overview of the EDM4eic data model

Our data model is EDM4eic (Event Data Model for EIC), and is summarized in the above figure. Each box corresponds to a data structure, and the arrows correspond to connections between these structures. The entire model is defined in a single YAML file, edm4eic.yaml, which we’ll break down in detail below.

Challenge

Exercise

Take a moment to scan the figure, paying attention to the names of structures. Then pick a structure and find it in the YAML file mentioned above: notice how the arrows correspond to the fields labeled OneToOneRelations or OneToManyRelations.

For example, edm4eic::Cluster has one-to-many relations to edm4eic::CalorimeterHit (the hits combined to form the cluster), to other edm4eic::Clusters, and to edm4hep::ParticleID. Each of these corresponds to an arrow leaving the cluster box in the figure.

A few things comments before we move on:

  • Using a standardized set of structures helps keep our software modular, meaning we can easily swap out parts, since all our data adheres to standardized interfaces;
  • The data model does not say anything about input/output formats: we write our data using ROOT, but we could also write it in other formats like HDF5;
  • And we want our model to be predictable and intuitive: accessing the energy of a calorimeter cluster should be identical to accessing the energy of a particle.
Callout

Note

We also utilize the EDM4hep data model in our software. This is a data model developed by the Key4hep project, which is developing common software to support the FCC, ILC/CLIC, Muon Collider, and more. Just like with our data model, the EDM4hep model is also defined in a single YAML file, edm4hep.yaml.

An Introduction to PODIO


So then what’s PODIO? PODIO (Plain-Old-Data Input/Output) is a toolkit for generating and managing a data model like EDM4eic. It reads in a YAML file like edm4eic.yaml and generates all the C++ and Python code needed to read, write, and interface with the structures defined in there.

Classes

Let’s look at one of these structures:

YAML

edm4eic::Track:
  Description: "Track information at the vertex"
  Author: "S. Joosten, J. Osborn"
  Members:
    - int32_t           type                       // Flag that defines the type of track
    - edm4hep::Vector3f position                   // Track 3-position at the vertex
    - edm4hep::Vector3f momentum                   // Track 3-momentum at the vertex [GeV]
    - edm4eic::Cov6f    positionMomentumCovariance // Covariance matrix in basis [x,y,z,px,py,pz]
    - float             time                       // Track time at the vertex [ns]
    - float             timeError                  // Error on the track vertex time
    - float             charge                     // Particle charge
    - float             chi2                       // Total chi2
    - uint32_t          ndf                        // Number of degrees of freedom
    - int32_t           pdg                        // PDG particle ID hypothesis
  OneToOneRelations:
    - edm4eic::Trajectory trajectory // Trajectory of this track
  OneToManyRelations:
    - edm4eic::Measurement2D measurements // Measurements that were used for this track
    - edm4eic::Track         tracks       // Tracks (segments) that have been
                                          // combined to create this track

This is an example of a class. At the top of the definition, we see some basic info like the name of the structure, a brief description, and the authors. Following this, we see a block labeled Members. These are your basic class members. Suppose we have an edm4eic::Track named track, how would we access its members?

PYTHON

# getting data members
trk_time = track.getTime()
trk_ndf  = track.getNdf()

# setting data members
track.setCharge(-1.0)
track.setPDG(11)

Or in C++:

CPP

// getting data members
float    trk_time = track.getTime();
uint32_t trk_ndf  = track.getNdf();

// setting data members
track.setCharge(-1.0);
track.setPDG(11);

This already highlights of the big advantages of using PODIO to work with our data: the interface is almost identical between C++ and Python. We’ll see where they diverge in later sections.

Wait! But how would I know that the accessors start with get or set? 1. First, look at the top of the YAML file: you’ll notice under options that getSyntax is set to true. This means that the accessor functions for class members will always start with get/set, letting you infer what the relevant functions are from the YAML. 2. You can also look at the generated classes on our EDM4eic doxygen page. Or while you’re in eic-shell, you can look at them in the path /opt/local/include/edm4eic.

Challenge

Exercise

Follow the link to the doxygen page, and locate the header file for edm4eic::Track. Once you found it, give it a quick scan. Then in eic-shell, find the same header file and compare it to the online version.

On the doxygen page, the header is listed under edm4eic/Track.h. In eic-shell, the same file is at /opt/local/include/edm4eic/Track.h — you can view it with, e.g., less /opt/local/include/edm4eic/Track.h. You’ll find the same getTime(), getNdf(), etc. accessors that the YAML file implies.

Collections and Frames

For reading in/writing out classes, they’ll need to be placed in a collection. Collections can be thought of as almost (but not quite!) a c++ std::vector or Python list of objects. Iterating through their contents looks like you’d expect for either of those containers:

PYTHON

tracks = frame.get("CentralCKFTracks")
for track in tracks:
    chi2_ndf = track.getChi2() / track.getNdf()

Or:

CPP

auto& tracks = frame.get<edm4eic::TrackCollection>("CentralCKFTracks");
for (const auto& track : tracks) {
  chi2_ndf = track.getChi2() / track.getNdf();
}

The frame in the above snippets is a Frame, which holds and organizes several collections. We’ll see frames and collections in action in the following episodes. However, a detailed explanation of their usage is outside the scope of this tutorial.

Caution

Warning

The big thing to note here is that collections are read-only! This means that you can’t modify an object you are reading from a collection!

Components

Notice that the position and momentum of the track are stored as an edm4hep::Vector3f. This is an example of a component. Let’s look at the definition:

YAML

edm4hep::Vector3f:
  Members:
    - float x
    - float y
    - float z
  ExtraCode:
    includes: "#include <cstddef>"
    declaration: |
      constexpr Vector3f() : x(0),y(0),z(0) {}
      constexpr Vector3f(float xx, float yy, float zz) : x(xx),y(yy),z(zz) {}
      constexpr Vector3f(const float* v) : x(v[0]),y(v[1]),z(v[2]) {}
      constexpr bool operator==(const Vector3f& v) const { return (x==v.x&&y==v.y&&z==v.z) ; }
      constexpr bool operator!=(const Vector3f& v) const { return !(*this == v) ; }
      constexpr float operator[](unsigned i) const {
        static_assert(
          (offsetof(Vector3f,x)+sizeof(Vector3f::x) == offsetof(Vector3f,y)) &&
          (offsetof(Vector3f,y)+sizeof(Vector3f::y) == offsetof(Vector3f,z)),
          "operator[] requires no padding");
        return *( &x + i ) ;
      }

It has 3 data members and some extra code, which just defines some handy functions. If you find this in edm4hep.yaml, then you’ll notice it’s defined under a block labeled components. Notice that edm4eic::Track is defined under a block labeled classes.

Components are just simple structs (in the C++ sense). This means that component accessors are not prefixed by get/set. For example:

PYTHON

import math
px = track.getMomentum().x
py = track.getMomentum().y
pt = math.hypot(px, py)

Or:

CPP

#include <cmath>
float px = track.getMomentum().x;
float py = track.getMomentum().y;
float pt = std::hypot(px, py);

Also note that components can’t be stored in a collection and so can’t be written out except as part of a class such as edm4eic::Track.

Vector Members

Let’s look at the definition of a calorimeter cluster:

YAML

edm4eic::Cluster:
  Description: "EIC hit cluster, reworked to more closely resemble EDM4hep"
  Author: "W. Armstrong, S. Joosten, C.Peng"
  Members:
    - int32_t              type                         // Flag-word that defines the
                                                        // type of the cluster
    - float                energy                       // Reconstructed energy of the
                                                        // cluster [GeV].
    - float                energyError                  // Error on the cluster energy [GeV]
    - float                time                         // [ns]
    - float                timeError                    // Error on the cluster time
    - uint32_t             nhits                        // Number of hits in the cluster.
    - edm4hep::Vector3f    position                     // Global position of the cluster [mm].
    - edm4eic::Cov3f       positionError                // Covariance matrix of the
                                                        // position (6 Parameters).
    - float                radius                       // Cluster radius [mm].
    - float                dispersion                   // Cluster dispersion [mm].
    - std::array<float, 3> principalAxesLengthsXYZ      // Lengths along the cluster's principal
                                                        // axes [mm], sorted in descending order
                                                        // (equivalent to sqrt of eigenvalues of the
                                                        // position covariance). For an XY planar
                                                        // detector one can expect this to be
                                                        // [sigma_max, sigma_min, 0].
    - std::array<float, 2> principalAxesLengthsThetaPhi // Lengths along the cluster's principal
                                                        // axes [rad], sorted in descending order.
    - float                intrinsicTheta               // Intrinsic cluster propagation direction
                                                        // polar angle [rad].
    - float                intrinsicPhi                 // Intrinsic cluster propagation direction
                                                        // azimuthal angle [rad]. For an XY planar
                                                        // detector one can expect this to be the
                                                        // tilt of "sigma_max" axis.
    - edm4eic::Cov2f        intrinsicDirectionError     // Error on the intrinsic cluster
                                                        // propagation direction
  VectorMembers:
    - float shapeParameters     // [DEPRECATED] use radius, dispersion,
                                // principalAxesLengthsXYZ/ThetaPhi instead.
    - float hitContributions    // Energy contributions of the hits. Runs parallel to ::hits()
    - float subdetectorEnergies // Energies observed in each subdetector used for this cluster.
  OneToManyRelations:
    - edm4eic::Cluster        clusters    // Clusters that have been combined to form this cluster
    - edm4eic::CalorimeterHit hits        // Hits that have been combined to form this cluster
    - edm4hep::ParticleID     particleIDs // Particle IDs sorted by likelihood

You’ll see a block labeled VectorMembers. These are just std::vectors of data. For example, hitContributions holds the weighted energy of the calorimeter cells (a.k.a hits) that make up a given cluster.

The accessors are the same as for normal members, except they’ll return a std::vector (C++) or List (Python). Suppose we have an edm4eic::Cluster named cluster:

PYTHON

total_energy = 0.0
for weighted_energy in cluster.getHitContributions():
    total_energy += weighted_energy

Or:

CPP

float total_energy = 0.0;
for (const float weighted_energy : cluster.getHitContributions()) {
  total_energy += weighted_energy;
}

Relations

Now, let’s consider the OneToOneRelations and OneToManyRelations blocks. These are examples of relations, references to objects in other collections. We use these to define a direct, necessary relationship between an object and one other object (one-to-one) or many other objects (one-to-many).

A track pointing to the single trajectory it was computed from
Diagram of a one-to-one relation

The above figure schematically illustrates a one-to-one relation. A track should correspond to a charged particle with a defined momentum and charge. It’s computed from a trajectory, a path fit to a set of points from our trackers. This relationship is expressed in the one-to-one relation from our edm4eic::Track to an edm4eic::Trajectory.

Retrieving the object from a relation is done the same way you would get a value from a member:

PYTHON

trajectory    = track.getTrajectory()
n_points_used = trajectory.getNMeasurements()

Or:

C++

auto     trajectory    = track.getTrajectory();
uint32_t n_points_used = trajectory.getNMeasurements();
A calorimeter cluster pointing to the several calorimeter hits it is built from
Diagram of a one-to-many relation

Then the above figure schematically illustrates a one-to-many relation. For example, a calorimeter cluster is group of calorimeter cells (hits). The cells that make up a given cluster are recorded in its one-to-many relation to edm4eic::CalorimeterHits.

Objects referred to in a one-to-many relation are retrieved like you’d expect:

PYTHON

for hit in cluster.getHits():
    energy = hit.getEnergy()

Or:

CPP

for (const auto& hit : cluster.getHits()) {
  double energy = hit.getEnergy();
}

When you call getHits() in the above snippets, it returns a List (Python) or std::vector (C++) of edm4eic::CalorimeterHits just like with vector members.


In contrast to relations, which express a direct connection between two or more objects, associations express an indirect connection which might or might not exist. For example, the connection between a monte carlo particle and its reconstructed counterpart:

An association object connecting a simulated particle and a reconstructed particle
Diagram of an association

This is defined in edm4eic.yaml as:

YAML

edm4eic::MCRecoParticleAssociation:
  Description: "Used to keep track of the correspondence between MC and reconstructed particles"
  Author: "S. Joosten"
  Members:
    - float weight // weight of this association
  OneToOneRelations:
    - edm4eic::ReconstructedParticle rec  // reference to the reconstructed particle
    - edm4hep::MCParticle            sim  // reference to the Monte-Carlo particle
  ExtraCode:
    includes: "
    #include <edm4eic/ReconstructedParticle.h>\n
    #include <edm4hep/MCParticle.h>\n
    "
    declaration: "
    [[deprecated(\"use getSim().getObjectID().index instead\")]]
    int getSimID() const { return getSim().getObjectID().index; }\n
    [[deprecated(\"use getRec().getObjectID().index instead\")]]
    int getRecID() const { return getRec().getObjectID().index; }\n
      "

The weight here is nominally a measure of the goodness of the correspondence between the reconstructed and MC particles, and the rec and sim fields store the references to the corresponding particles. Let’s assume we have an edm4eic::MCRecoParticleAssociation called assoc. Accessing its members is exactly like you’d expect:

PYTHON

rec_par = assoc.getRec()
sim_par = assoc.getSim()
weight  = assoc.getWeight()
e_frac  = (sim_par.getEnergy() - rec_par.getEnergy()) / sim_par.getEnergy()

Or:

CPP

auto  rec_par = assoc.getRec();
auto  sim_par = assoc.getSim();
float weight  = assoc.getWeight();
float e_frac  = (sim_par.getEnergy() - rec_par.getEnergy()) / sim_par.getEnergy();

Now let’s consider the equivalent link:

YAML


edm4eic::MCRecoParticleLink:
  Description: "Used to keep track of the correspondence between MC and reconstructed particles"
  Author: "S. Joosten"
  From: edm4eic::ReconstructedParticle
  To: edm4hep::MCParticle

There’s a lot less! Links are defined in their own specific block (labeled links), and only need you to specify which types they’re connecting (edm4eic::ReconstructedParticle and edm4hep::MCParticle in this case).

A link object pointing from a reconstructed particle to a simulated particle
Diagram of a link

They provide the same functionality as association, but there are a few key differences:

  1. Links always have the same fields: from, to, and weight (which is implied);
  2. And they have directionality (illustrated in the above figure).

Point 1 means the accessors will always be the same for any link. The link equivalents of the above snippets are:

PYTHON

rec_par = link.getFrom()
sim_par = link.getTo()
weight  = link.getWeight()
e_frac  = (sim_par.getEnergy() - rec_par.getEnergy()) / sim_par.getEnergy()

And:

CPP

auto  rec_par = link.getFrom();
auto  sim_par = link.getTo();
float weight  = link.getWeight();
float e_frac  = (sim_par.getEnergy() - rec_par.getEnergy()) / sim_par.getEnergy();

The directionality comes into play with the podio::LinkNavigator, which enables extremely fast lookup of linked objects. This is, however, outside the scope of this tutorial.

Caution

Warning

We’re in the process of deprecating of associations in favor of links (EDM4hep has already done this). Currently we write out both associations and their equivalent links where needed to not break analysis code. However, we will in the near future remove associations and write out only links

User vs. Storage Layer


Lastly, try opening the file we downloaded during Setup with a ROOT TBrowser:

BASH

$ root --web-display=off lAger3.6.1-1.0_jpsi_10x130_hiAcc_run1.0009.eicrecon.edm4eic.root
root [0] new TBrowser()

Open the tree labeled events and you’ll see hundreds of branches! Find the branch labeled CentralCKFTracks, open it and click on a few leaves. This is how PODIO data is organized in memory, as a big array of structs holding the values in the leaves. This is referred to as the POD (or Data) Layer.

As noted in the last section, the Analysis Tutorial illustrates how to work directly with the POD Layer using a ROOT TTreeReader. The syntax illustrated above is working with what’s called the User Layer, a think layer of interfaces to make working with the data easy. A third layer, the Object Layer, interfaces between the POD and User Layers.

There are clear benefits to working with the User Layer over the POD Layer:

  • Significantly less boilerplate code to write,
  • Easy, intuitive syntax to deal with relations, associations, and vector members; and
  • The User Layer synatx will remain constant, but how the POD Layer is organized may not.

The first two points will be illustrated very clearly in the following episodes. However, there may be cases where working directly with the POD Layer is actually preferable. For example:

  • You may want to histogram just one or two members directly, or
  • You’re working with a ML pipeline where you’ll need to load the data as a dataframe.

Which analysis method is ultimately up to you: our goal is to support as many methods as possible, and, between the POD Layer and the User Layer, PODIO gives us the tools to do that.

References


Key Points
  • A data model is how represent our data in software
  • PODIO is a toolkit to generate and interface with data models like EDM4eic
  • Classes have their accessors prefixed by get/set, components don’t
  • Relations are references to objects in other collections
  • Associations/links define indirect connections between objects

Content from Track-Cluster Matching


Last updated on 2026-07-12 | Edit this page

Estimated time: 45 minutes

Overview

Questions

  • How do actually I use PODIO?

Objectives

  • Use PODIO interface to find reconstructed J/psi

Exercise Goal


Now that you have some background, let’s get some hands-on experience with PODIO. Our goal in this epsiode will be to see if we can reconstruct \(J/\psi\) . The next episode will then have us compare these to truth/MC objects.

We’ll build an analysis script/macro up bit-by-bit. But if you’re having trouble placing the code snippets below, you can always refer to the full example scripts for guidance.

Step 0: Skeleton


Overall, our strategy will proceed in two steps:

  1. Match tracks to ECal clusters and identify electrons with an E/p cut; and then
  2. Form pairs of electrons and identify J/psi with an invariant mass cut.

The code below will illustrate how to do this using (1) Python and ROOT, and (2) C++ and ROOT (both cases using PODIO). In the future, we’ll expand this tutorial with some extra documents illustrating how to do the same analysis using alternative analysis techniques.

So to begin, let’s 1st create a file. We will suppose the file is called FindJPsi.py (for the Python version) or FindJPsi.cxx (for the C++ version), but you are — of course — free to name the file what you want.

Challenge

Exercise: Skeleton

Now, in that file, let’s create a skeleton. For Python:

PYTHON

import argparse as ap
import ROOT
import numpy as np

from podio.reading import get_reader

# Default options
in_file_def   = "lAger3.6.1-1.0_jpsi_10x130_hiAcc_run1.0009.eicrecon.edm4eic.root"
out_file_def  = "jpsi_search_py.analysis.root"
match_cut_def = 0.2
ep_min_def    = 0.8
ep_max_def    = 1.2
mass_min_def  = 2.5
mass_max_def  = 3.5

ROOT.TH1.SetDefaultSumw2(True)

def FindJPsi(
    in_file   = in_file_def, 
    out_file  = out_file_def,
    match_cut = match_cut_def,
    ep_min    = ep_min_def,
    ep_max    = ep_max_def,
    mass_min  = mass_min_def,
    mass_max  = mass_max_def,
):

    # Open reader and output file ============================================

    # use podio reader to open example EICrecon output
    reader = get_reader(in_file)


    # Event loop ==============================================================

    for frame in reader.get("events"):

        # Grab all the collections we'll need
        particles    = frame.get("MCParticles");
        tracks       = frame.get("CentralCKFTracks");
        clusters_neg = frame.get("EcalEndcapNClusters");
        clusters_cen = frame.get("EcalBarrelClusters");
        clusters_pos = frame.get("EcalEndcapPClusters");
        associations = frame.get("CentralCKFTrackAssociations");


# Main ========================================================================

if __name__ == "__main__":

    # set up argments
    parser = ap.ArgumentParser()
    parser.add_argument("--infile", default = in_file_def)
    parser.add_argument("--outfile", default = out_file_def)
    parser.add_argument("--matchcut", default = match_cut_def)
    parser.add_argument("--epmin", default = ep_min_def)
    parser.add_argument("--epmax", default = ep_max_def)
    parser.add_argument("--massmin", default = mass_min_def)
    parser.add_argument("--massmax", default = mass_max_def)
    args = parser.parse_args()

    # Run calculation
    FindJPsi(
        args.infile,
        args.outfile,
        args.matchcut,
        args.epmin,
        args.epmax,
        args.massmin,
        args.massmax,
    )

And for C++:

CPP

#include <edm4eic/ClusterCollection.h>
#include <edm4eic/MCRecoTrackParticleAssociationCollection.h>
#include <edm4eic/TrackCollection.h>
#include <edm4hep/MCParticleCollection.h>
#include <edm4hep/Vector3f.h>
#include <podio/Frame.h>
#include <podio/ObjectID.h>
#include <podio/ROOTReader.h>
#include <Math/Vector3D.h>
#include <Math/Vector4D.h>
#include <TFile.h>
#include <TH1.h>
#include <cmath>
#include <optional>
#include <map>
#include <memory>
#include <set>
#include <string>
#include <vector>
#include <utility>

TH1::SetDefaultSumw2(kTRUE);

void FindJPsi(
  const std::string& in_file   = "lAger3.6.1-1.0_jpsi_10x130_hiAcc_run1.0009.eicrecon.edm4eic.root",
  const std::string& out_file  = "jpsi_search_cxx.analysis.root",
  const float        match_cut = 0.2,
  const float        ep_min    = 0.8,
  const float        ep_max    = 1.2,
  const float        mass_min  = 2.5,
  const float        mass_max  = 3.5
) {

  // Open reader and output file ==============================================

  podio::ROOTReader reader = podio::ROOTReader();
  reader.openFile(in_file);

  TFile* output = new TFile(out_file.data(), "recreate");

  // Event loop ===============================================================

  while (auto entry = reader.readNextEntry(podio::Category::Event)) {

    auto frame = podio::Frame(std::move(entry));

    // Grab all the collections we'll need
    auto& particles    = frame.get<edm4hep::MCParticleCollection>("MCParticles");
    auto& tracks       = frame.get<edm4eic::TrackCollection>("CentralCKFTracks");
    auto& clusters_neg = frame.get<edm4eic::ClusterCollection>("EcalEndcapNClusters");
    auto& clusters_cen = frame.get<edm4eic::ClusterCollection>("EcalBarrelClusters");
    auto& clusters_pos = frame.get<edm4eic::ClusterCollection>("EcalEndcapPClusters");
    auto& associations = frame.get<edm4eic::MCRecoTrackParticleAssociationCollection>(
      "CentralCKFTrackAssociations");

  }  // end event loop

  // Calculate efficiencies and save output ===================================

  output->cd();
  output->Write();
  output->Close();
  return;

}

Now, just to make sure there are no typos, try running your script with either

BASH

python FindJPsi.py

for Python. Or:

BASH

root -b -q FindJPsi.cxx

for C++.

Both commands should finish after a few seconds without printing anything: the skeleton just reads each event and grabs the collections. The C++ version will also create an (almost) empty jpsi_search_cxx.analysis.root. If you see an error about a missing input file, check that you run the script from the directory holding the file downloaded during Setup (or point --infile at it).

During the event loop, notice how we 1st grab a frame and then grab all of the relevant collections we need from it. Like we mentioned in the previous episode, a PODIO Frame aggregates all of the data for all collections in a given category. In this context, we can think of a single frame as an “event” (a simulated diffractive event). In our actual data-taking operations, a frame might be a “timeframe” of streamed data.

A couple notes:

  • For thsoe following along in Python who haven’t seen this before, the chunk at the bottom under if __name__ == "__main__": is there just make it easy to specify command line arguments.
  • You might also see how the options in both cases correspond to our 2 steps up above. We’ll see them in action soon.

Next, let’s fill in our histograms. We’ll define all the ones we need for this tutorial. However, we strongly encoourage you to play around and add additional ones!

Challenge

Exercise: Histograms

Add the following code snippets somewhere near the top of your script/macro. For example right after you open the PODIO reader or right after you create your output file.

PYTHON

    # Create histograms ======================================================

    h_track_momentum_all     = ROOT.TH1D("h_track_momentum_all", "Track #it{p} (all)", 50, 0.0, 10.0)
    h_track_momentum_match   = ROOT.TH1D("h_track_momentum_match", "Track #it{p} (matched to cluster)", 50, 0.0, 10.0)
    h_track_eta_all          = ROOT.TH1D("h_track_eta_all", "Track #eta (all)", 250, -5.0, 5.0)
    h_track_eta_match        = ROOT.TH1D("h_track_eta_match", "Track #eta (matched to cluster)", 250, -5.0, 5.0)
    h_track_cluster_eop      = ROOT.TH1D("h_track_cluster_eop", "Track-cluster E/p", 40, 0.0, 2.0)
    h_invariant_mass         = ROOT.TH1D("h_invariant_mass", "Reconstructed invariant mass", 50, 0.0, 5.0)
    h_daughter_momentum_all  = ROOT.TH1D("h_daughter_momentum_all", "Daughter e^{#pm} #it{p} (all)", 50, 0.0, 10.0)
    h_daughter_momentum_reco = ROOT.TH1D("h_daughter_momentum_reco", "Daughter e^{#pm} #it{p} (reconstructed)", 50, 0.0, 10.0)
    h_daughter_eta_all       = ROOT.TH1D("h_daughter_eta_all", "Daughter e^{#pm} #eta (all)", 250, -5.0, 5.0)
    h_daughter_eta_reco      = ROOT.TH1D("h_daughter_eta_reco", "Daughter e^{#pm} #eta (reconstructed)", 250, -5.0, 5.0)
    h_jpsi_momentum_all      = ROOT.TH1D("h_jpsi_momentum_all", "J/#psi #it{p} (all e^{#pm} decays)", 50, 0.0, 10.0)
    h_jpsi_momentum_reco     = ROOT.TH1D("h_jpsi_momentum_reco", "J/#psi #it{p} (reconstructed e^{#pm} decays)", 50, 0.0, 10.0)
    h_jpsi_eta_all           = ROOT.TH1D("h_jpsi_eta_all", "J/#psi #eta (all e^{#pm} decays)", 250, -5.0, 5.0)
    h_jpsi_eta_reco          = ROOT.TH1D("h_jpsi_eta_reco", "J/#psi #eta (reconstructed e^{#pm} decays)", 250, -5.0, 5.0)

CPP

  // Create histograms ========================================================

  TH1D* h_track_momentum_all     = new TH1D("h_track_momentum_all", "Track #it{p} (all)", 50, 0.0, 10.0);
  TH1D* h_track_momentum_match   = new TH1D("h_track_momentum_match", "Track #it{p} (matched to cluster)", 50, 0.0, 10.0);
  TH1D* h_track_eta_all          = new TH1D("h_track_eta_all", "Track #eta (all)", 250, -5.0, 5.0);
  TH1D* h_track_eta_match        = new TH1D("h_track_eta_match", "Track #eta (matched to cluster)", 250, -5.0, 5.0);
  TH1D* h_track_cluster_eop      = new TH1D("h_track_cluster_eop", "Track-cluster E/p", 40, 0.0, 2.0);
  TH1D* h_invariant_mass         = new TH1D("h_invariant_mass", "Reconstructed invariant mass", 50, 0.0, 5.0);
  TH1D* h_daughter_momentum_all  = new TH1D("h_daughter_momentum_all", "Daughter e^{#pm} #it{p} (all)", 50, 0.0, 10.0);
  TH1D* h_daughter_momentum_reco = new TH1D("h_daughter_momentum_reco", "Daughter e^{#pm} #it{p} (reconstructed)", 50, 0.0, 10.0);
  TH1D* h_daughter_eta_all       = new TH1D("h_daughter_eta_all", "Daughter e^{#pm} #eta (all)", 250, -5.0, 5.0);
  TH1D* h_daughter_eta_reco      = new TH1D("h_daughter_eta_reco", "Daughter e^{#pm} #eta (reconstructed)", 250, -5.0, 5.0);
  TH1D* h_jpsi_momentum_all      = new TH1D("h_jpsi_momentum_all", "J/#psi #it{p} (all e^{#pm} decays)", 50, 0.0, 10.0);
  TH1D* h_jpsi_momentum_reco     = new TH1D("h_jpsi_momentum_reco", "J/#psi #it{p} (reconstructed e^{#pm} decays)", 50, 0.0, 10.0);
  TH1D* h_jpsi_eta_all           = new TH1D("h_jpsi_eta_all", "J/#psi #eta (all e^{#pm} decays)", 250, -5.0, 5.0);
  TH1D* h_jpsi_eta_reco          = new TH1D("h_jpsi_eta_reco", "J/#psi #eta (reconstructed e^{#pm} decays)", 250, -5.0, 5.0);

And for those using Python, add the following at the very end of your FindJPsi() function:

PYTHON


    with ROOT.TFile(out_file, "recreate") as ofile:
        ofile.WriteObject(h_track_momentum_all)
        ofile.WriteObject(h_track_momentum_match)
        ofile.WriteObject(h_track_eta_all)
        ofile.WriteObject(h_track_eta_match)
        ofile.WriteObject(h_track_cluster_eop)
        ofile.WriteObject(h_invariant_mass)
        ofile.WriteObject(h_daughter_momentum_all)
        ofile.WriteObject(h_daughter_momentum_reco)
        ofile.WriteObject(h_daughter_eta_all)
        ofile.WriteObject(h_daughter_eta_reco)
        ofile.WriteObject(h_jpsi_momentum_all)
        ofile.WriteObject(h_jpsi_momentum_reco)
        ofile.WriteObject(h_jpsi_eta_all)
        ofile.WriteObject(h_jpsi_eta_reco)

Then run your script/macro to make sure there are no typos.

The script should run exactly as before, and the output file now contains all 14 histograms — still empty, since we haven’t filled them yet.

Step 1: Find Electrons


Now let’s start filling in our skeleton. We’ll first need some helper functions.

Challenge

Exercise: Electron-Finding Helpers

Copy and paste the following snippets into your code just after you get the necessary collections.

PYTHON


        # Step 1: match tracks-to-cluster to find electrons ===================

        # Convert edm4hep::Vector3f into a ROOT::Math::XZYVector
        #    --> Useful for some vector calculations
        def convert_vector(edm_vec):
            return ROOT.Math.XYZVector(edm_vec.x, edm_vec.y, edm_vec.z)

        # Get cluster 3-momentum using its position and energy
        #   --> Assuming interaction vetex is at origin!
        def get_momentum(position, energy):
            unit = position.Unit()
            return ROOT.Math.XYZVector(  # * operator not implemented
                energy * unit.X(),
                energy * unit.Y(),
                energy * unit.Z(),
            )

        # Get distance in eta-phi space between 2 3-vectors
        #   --> For matching tracks and clusters
        def get_distance(vec_1, vec_2):
            return np.hypot(vec_1.Eta() - vec_2.Eta(), vec_1.Phi() - vec_2.Phi());

CPP


    // Step 1: match tracks-to-cluster to find electrons ======================

    // Convert edm4hep::Vector3f into a ROOT::Math::XZYVector
    //   --> Useful for some vector calculations
    auto convert_vector_float = [](const edm4hep::Vector3f& edm_vec) {
      return ROOT::Math::XYZVector(edm_vec.x, edm_vec.y, edm_vec.z);
    };

    // Get cluster 3-momentum using its position and energy
    //   --> Assuming interaction vetex is at origin!
    auto get_momentum = [](const ROOT::Math::XYZVector& position, const double energy) {
      return energy * position.Unit();
    };

    // Get distance in eta-phi space between 2 3-vectors
    //   --> For matching tracks and clusters
    auto get_distance = [](const ROOT::Math::XYZVector& vec_1, const ROOT::Math::XYZVector& vec_2) {
      return std::hypot(vec_1.Eta() - vec_2.Eta(), vec_1.Phi() - vec_2.Phi());
    };

    // Compare 2 object IDs
    //   --> Needed for bookkeeping
    auto compare_object_ids = [](const podio::ObjectID& lhs, const podio::ObjectID& rhs) {
      if (lhs.collectionID == rhs.collectionID) {
        return (lhs.index < rhs.index);
      } else {
        return (lhs.collectionID < rhs.collectionID);
      }
    };

And rerun your codes to make sure everything still works.

Nothing visible should change yet: the helpers are defined but not used until the next exercise. If Python complains about indentation or C++ about a stray semicolon, compare against the full example scripts.

For those following along in C++ who haven’t seen these before, these are examples of lambdas, “anonymous functions.” These are functions we declare inline and can use like we would other objects. They’re great for small functionality that you only need locally, or for passing functions as arguments to other functions.

Also note that the ROOT XYZVector (and similar vectors below) are examples of ROOT’s successor to the older TVector3 and related, the GenVector. There are still some quirks with their implementation, which you’ll see as we work through this.

Track-Cluster Matching

Now, let’s add our loop over tracks and start trying to match tracks and clusters. We’ll start by just considering the EEEMCal.

Challenge

Exercise: Track Loop

Copy and paste these snippets just after our helper functions above.

PYTHON

        # Loop over tracks ----------------------------------------------------

        rec_electrons = list()
        used_clusters = set()
        for track in tracks:

            trk_mom = convert_vector(track.getMomentum())
            trk_eta = trk_mom.Eta()
            trk_mag = np.sqrt(trk_mom.Mag2())  # Mag() isn't implemented for XYZVectors
            h_track_momentum_all.Fill(trk_mag)
            h_track_eta_all.Fill(trk_eta)

            best_distance = 999.0
            best_match    = None

            # Compare track against negative ECal -----------------------------

            for cluster in clusters_neg:

                if cluster in used_clusters:
                    continue

                clust_pos = convert_vector(cluster.getPosition())
                clust_mom = get_momentum(clust_pos, cluster.getEnergy())
                distance  = get_distance(trk_mom, clust_mom)

                if (distance < match_cut) and (distance < best_distance):
                    best_distance = distance
                    best_match    = cluster
                    used_clusters.add(cluster)

CPP

    // Loop over tracks -------------------------------------------------------

    std::vector<edm4eic::Track> rec_electrons;
    std::set<podio::ObjectID, decltype(compare_object_ids)> used_clusters;

    for (const auto& track : tracks) {

      const auto  trk_mom = convert_vector_float(track.getMomentum());
      const float trk_eta = trk_mom.Eta();
      const float trk_mag = std::sqrt(trk_mom.Mag2());  // Mag() isn't implemented for XYZVectors
      h_track_momentum_all->Fill(trk_mag);
      h_track_eta_all->Fill(trk_eta);

      float best_distance = 999.0;
      std::optional<edm4eic::Cluster> best_match;

      // Compare track against negative ECal ----------------------------------

      for (const auto& cluster : clusters_neg) {

        if (used_clusters.contains(cluster.getObjectID())) {
          continue;
        }

        const auto  clust_pos = convert_vector_float(cluster.getPosition());
        const auto  clust_mom = get_momentum(clust_pos, cluster.getEnergy());
        const float distance = get_distance(trk_mom, clust_mom);

        if ((distance < match_cut) && (distance < best_distance)) {
          best_distance = distance;
          best_match    = cluster;
          used_clusters.insert(cluster.getObjectID());
        }
      }
    } // end track loop

As always, run your code after the changes to make sure things work.

With the example file, h_track_momentum_all should now fill with about 3300 entries (there are 3284 central tracks in the 1159 events). The matching histograms stay empty until the electron-identification step below fills them.

You’ll see how we’re comparing the distance between the track and cluster vectors in eta-phi space. There are 3 points to emphasize on the matching here:

  1. Notice how we apply a cut on distance with `match_cut: we shouldn’t consider clusters that are too far away from our track.
  2. We also want to make sure we’re taking the best cluster (i.e. the one closest to the track in eta-phi space), not just the first one that falls in our match cut. That’s why we update the match iteratively.
  3. We also need to avoid double-counting clusters: we use a set (both in Python and C++) to keep tabs of which clusters we’ve used since it doesn’t allow duplicates.

After confirming the changes work, we can be lazy and just copy-and-paste the EEEMCal loop two more times to get the BIC and FEMC (updateing the collection names, of course):

PYTHON

            # Compare track against central ECal ------------------------------

            for cluster in clusters_cen:

                if cluster in used_clusters:
                    continue

                clust_pos = convert_vector(cluster.getPosition())
                clust_mom = get_momentum(clust_pos, cluster.getEnergy())
                distance  = get_distance(trk_mom, clust_mom)

                if (distance < match_cut) and (distance < best_distance):
                    best_distance = distance
                    best_match    = cluster
                    used_clusters.add(cluster)

            # Compare track against positive ECal -----------------------------

            for cluster in clusters_pos:

                if cluster in used_clusters:
                    continue

                clust_pos = convert_vector(cluster.getPosition())
                clust_mom = get_momentum(clust_pos, cluster.getEnergy())
                distance  = get_distance(trk_mom, clust_mom)

                if (distance < match_cut) and (distance < best_distance):
                    best_distance = distance
                    best_match    = cluster
                    used_clusters.add(cluster)

CPP

      // Compare track against central ECal -----------------------------------

      for (const auto& cluster : clusters_cen) {

        if (used_clusters.contains(cluster.getObjectID())) {
          continue;
        }

        const auto  clust_pos = convert_vector_float(cluster.getPosition());
        const auto  clust_mom = get_momentum(clust_pos, cluster.getEnergy());
        const float distance = get_distance(trk_mom, clust_mom);

        if ((distance < match_cut) && (distance < best_distance)) {
          best_distance = distance;
          best_match    = cluster;
          used_clusters.insert(cluster.getObjectID());
        }
      }

      // Compare track against positive ECal ----------------------------------

      for (const auto& cluster : clusters_pos) {

        if (used_clusters.contains(cluster.getObjectID())) {
          continue;
        }

        const auto  clust_pos = convert_vector_float(cluster.getPosition());
        const auto  clust_mom = get_momentum(clust_pos, cluster.getEnergy());
        const float distance = get_distance(trk_mom, clust_mom);

        if ((distance < match_cut) && (distance < best_distance)) {
          best_distance = distance;
          best_match    = cluster;
          used_clusters.insert(cluster.getObjectID());
        }
      }

There are a couple of caveats here:

  1. We’re just comparing the track/cluster eta, phi at the vertex in the above snippets. This isn’t necessarily the best approach: it would be more prefereable to project the tracks to the calorimeters and compare the eta, phi at the projections.
  2. And we actually have an EICrecon algorithm which does just this! The corresponding branches for the 3 calorimeters here are: - EcalEndcapNTrackClusterMatches - EcalBarrelTrackClusterMatches - EcalEndcapPTrackClusterMatches

A final note before moving on to the electron identification: if you’re following the C++ and haven’t seen std::optional before, it’s a way to express data that may or may not be present. We express the same idea in Python snippets using None. You can also do the same thing with a true/false flag.

Electron Identification

And now, let’s use our matches to identify electrons using an E/p (cluster energy over track momentum) cut:

Challenge

Exercise: Electron Identification

Copy and paste the following snippets in your code just after the last track-cluster matching loop.

PYTHON

            # Compare track vs. matched cluster, identify e- candidates -------

            if best_match is not None:
                h_track_momentum_match.Fill(trk_mag)
                h_track_eta_match.Fill(trk_eta)
            else:
                continue

            match_ene = best_match.getEnergy()
            trk_ep    = match_ene / trk_mag
            if trk_mag > 0.0:
                h_track_cluster_eop.Fill(trk_ep)
            else:
                continue
     
            if (trk_ep > ep_min) and (trk_ep < ep_max):
                rec_electrons.append(track)

CPP

      // Compare track vs. matched cluster, identify e- candidates ------------

      if (best_match.has_value()) {
        h_track_momentum_match->Fill(trk_mag);
        h_track_eta_match->Fill(trk_eta);
      } else {
        continue;
      }

      const float match_ene = best_match.value().getEnergy();
      const float trk_ep    = match_ene / trk_mag;
      if (trk_mag > 0.0) {
        h_track_cluster_eop->Fill(trk_ep);
      } else {
        continue;
      }
     
      if ((trk_ep > ep_min) && (trk_ep < ep_max)) {
        rec_electrons.push_back(track);
      }

And then confirm the changes work.

Draw h_track_cluster_eop after the run: you should see a clear peak just below 1 (around 0.95–0.98), which is exactly the electron \(E/p\) signature discussed below. With the example file about 2200 of the 3284 tracks end up matched to a cluster.

This works because electrons (and positrons) on average deposit a characteristic amount of their energy in a given ECal. Ideally, this should be roughly 100% of their energy, which would mean \(E/p \sim 1\) . You should be able to see this peak very clearly in your h_track_cluster_eop histogram.

So as a rough way to identify which tracks are electrons, we can check which tracks fall within some window around this average value. Here, we assume the average is 1.

Step 2: Find J/Psi


And now that we have our electron candidates, we can find our \(J/\psi\) candidates.

Challenge

Exercise: J/psi-Finding Helpers

Like with step 1, we’ll start with setting up some helper functions. Paste these snippets just after your track loop is done.

PYTHON

        # Step 2: reconstruct J/psi ===========================================

        # Get 4-vector for a track with a certain mass
        #   --> Will need for invariant mass!
        def get_lorentz_track(track, mass):
            momentum = track.getMomentum()
            energy   = np.sqrt(mass**2 + momentum.x**2 + momentum.y**2 + momentum.z**2)
            return ROOT.Math.XYZTVector(
                momentum.x,
                momentum.y,
                momentum.z,
                energy
            )

CPP

   // Step 2: reconstruct J/psi ==============================================

   // Get 4-vector for a track with a certain mass
   //   --> Will need for invariant mass!
   auto get_lorentz_track = [](const edm4eic::Track& track, const float mass) {
     return ROOT::Math::PxPyPzM4D(
       track.getMomentum().x,
       track.getMomentum().y,
       track.getMomentum().z,
       mass
     );
   };

And, as always, confirm that it works ;)

As with the earlier helpers, nothing visible changes yet — the lambda/function is defined but only used in the next exercise.

We’ll need the 4-vectors here to calculate the invariant mass for pairs of electrons/positrons. If the pair are the \(J/\psi\) decay \(e^{\pm}\) , then this will be mass of the \(J/\psi\) (modulo detector effects and analysis biases, of course). So we’ll calculate the invariant mass for all our electron candidates!

Challenge

Exercise: J/Psi Identification

Copy these snippets just after your new helper functions.

PYTHON

        # Loop over pairs of electrons ----------------------------------------

        # Our J/psi candidates will be pairs of identified electrons
        #   --> So need list of pairs of tracks
        used_electrons  = set()
        jpsi_candidates = list()

        for electron_1 in rec_electrons:
            for electron_2 in rec_electrons:

                # Skip diagonal, make sure we don't double count tracks
                if electron_1 == electron_2:
                    continue

                # Make sure we don't double count tracks
                if electron_1 in used_electrons:
                    continue
                if electron_2 in used_electrons:
                    continue

                # Calculate invariant mass to find J/psi ----------------------

                lorentz_1 = get_lorentz_track(electron_1, 0.000511)
                lorentz_2 = get_lorentz_track(electron_2, 0.000511)

                total_mom = ROOT.Math.XYZTVector(
                    lorentz_1.Px() + lorentz_2.Px(),
                    lorentz_1.Py() + lorentz_2.Py(),
                    lorentz_1.Pz() + lorentz_2.Pz(),
                    lorentz_1.E() + lorentz_2.E()
                )
                inv_mass = total_mom.M();
                h_invariant_mass.Fill(inv_mass);

                if (inv_mass > mass_min) and (inv_mass < mass_max):
                    jpsi_candidates.append(
                        (electron_1, electron_2)
                    )
                    used_electrons.add(electron_1)
                    used_electrons.add(electron_2)

CPP

    // Loop over pairs of electrons ------------------------------------------

    // Our J/psi candidates will be pairs of identified electrons
    //   --> So need vector of pairs of tracks
    std::set<podio::ObjectID, decltype(compare_object_ids)> used_electrons;
    std::vector<std::pair<edm4eic::Track, edm4eic::Track>> jpsi_candidates;

    for (const auto& electron_1 : rec_electrons) {
      for (const auto& electron_2 : rec_electrons) {

         // Skip diagonal, make sure we don't double count tracks
         if (electron_1.getObjectID() == electron_2.getObjectID()) {
           continue;
         }
         if (used_electrons.contains(electron_1.getObjectID())) {
           continue;
         }
         if (used_electrons.contains(electron_2.getObjectID())) {
           continue;
         }

         // Calculate invariant mass to find J/psi ----------------------------

         const auto lorentz_1 = get_lorentz_track(electron_1, 0.000511);
         const auto lorentz_2 = get_lorentz_track(electron_2, 0.000511);

         const ROOT::Math::PxPyPzE4D total_mom(
           lorentz_1.Px() + lorentz_2.Px(),
           lorentz_1.Py() + lorentz_2.Py(),
           lorentz_1.Pz() + lorentz_2.Pz(),
           lorentz_1.E() + lorentz_2.E()
         );
         const float inv_mass = total_mom.M();
         h_invariant_mass->Fill(inv_mass);

         if ((inv_mass > mass_min) && (inv_mass < mass_max)) {
           jpsi_candidates.push_back(
            {electron_1, electron_2}
           );
           used_electrons.insert(electron_1.getObjectID());
           used_electrons.insert(electron_2.getObjectID());
         }
       }
    } // end of J/Psi e+e- pair loop

Then, after rerunning the code (and ironing out any bugs), check the h_invariant_mass histogram. Do you see a peak near the \(J/\psi\) mass of 3.0969 GeV?

Yes! With the example file, h_invariant_mass collects roughly 1400 pairs and shows a prominent peak in the 3.0–3.1 GeV region, right at the \(J/\psi\) mass. The remaining combinatorial background is broad and small in comparison.

Just like with the electrons, we’ll select our reconstructed \(J/\psi\) by picking out the pairs of electrons that have an invariant mass which falls in some range including the actual mass. All three cuts applied here are parameters that you can tweak to improve things like the purity of our \(J/\psi\) candidates or our efficiency!

The next episode will fold in comparisons against the truth level, which will give you some tools to do this.

Key Points
  • We can match tracks to clusters based on distances in eta-phi space
  • Care is needed to make sure you’re picking out the relevant cluster
  • Electrons can be identified with an E/p cut
  • J/psi can be reconstructed by considering pairs of reconstructed electrons

Content from Parent-Daughter Matching


Last updated on 2026-07-11 | Edit this page

Estimated time: 20 minutes

Overview

Questions

  • How do I use relations/associations?

Objectives

  • Compare reconstructed J/psi to truth J/psi using relations/associations

Step 3: Match Truth-Reco J/psi


Now, remember how we found our \(J/\psi\) last time: by picking out pairs of \(e^{\pm}\) which had the right invariant mass. To emphasize: we never measure the \(J/\psi\) directly. We only measure their decay products. Meaning that our \(J/\psi\) are dileptons, pairs of leptons.

This means that we have 3 levels of information to keep track of when we’re trying to compare our reconstructed \(J/\psi\) against the truth ones:

  1. Which \(J/\psi\) decayed to \(e^{\pm}\) ?
  2. Which decay \(e^{\pm}\) are related?
  3. And which tracks are associated to these decay \(e^{\pm}\) ?

Helpers and Bookkeepers

So let’s set up some helpers and bookkeepers to do do that.

Challenge

Exercise: Truth Helpers

Copy the following snippets just after your loop over electron-pairs.

PYTHON

        # Step 3: Compare against truth info ==================================

        # Get 4-vector for a particle
        #   --> Will need to compare rec vs. sim momentum/eta 
        def get_lorentz_particle(particle):
            momentum = particle.getMomentum()
            mass     = particle.getMass()
            energy   = np.sqrt(mass**2 + momentum.x**2 + momentum.y**2 + momentum.z**2)
            return ROOT.Math.XYZTVector(
                momentum.x,
                momentum.y,
                momentum.z,
                energy,
            )

        # We want to check if we found BOTH daughters in our pair. So we need 3 maps:
        #   --> track to associated daughter
        #   --> one daughter to the other daughter
        #   --> daughters to j/psi
        track_to_daughter    = dict()
        daughter_to_daughter = dict()
        daughter_to_parent   = dict()

CPP

    // Step 3: Compare against truth info =====================================

    // Convert edm4hep::Vector3d into a ROOT::Math::XZYVector
    //   --> edm4hep stores momentum values as doubles, not floats
    auto convert_vector_double = [](const edm4hep::Vector3d& edm_vec) {
      return ROOT::Math::XYZVector(edm_vec.x, edm_vec.y, edm_vec.z);
    };

    // Get 4-vector for a particle
    //   --> Will need to compare rec vs. sim momentum/eta 
    auto get_lorentz_particle = [](const edm4hep::MCParticle& particle) {
      return ROOT::Math::PxPyPzM4D(
        particle.getMomentum().x,
        particle.getMomentum().y,
        particle.getMomentum().z,
        particle.getMass()
      );
    };

    // We want to check if we found BOTH daughters in our pair. So we need 3 maps:
    //   --> track to associated daughter
    //   --> one daughter to the other daughter
    //   --> daughters to j/psi
    std::map<podio::ObjectID, podio::ObjectID, decltype(compare_object_ids)> track_to_daughter;
    std::map<podio::ObjectID, podio::ObjectID, decltype(compare_object_ids)> daughter_to_daughter;
    std::map<podio::ObjectID, edm4hep::MCParticle, decltype(compare_object_ids)> daughter_to_parent;

And confirm that the changes run.

The code should run exactly as before: the maps are declared but not filled until the next exercise.

Notice the dictionaries (Python) and maps (C++): these are what we’ll use to keep track of the 3 levels of info listed above. There are many ways you can do this, and this way is somewhat clunky. But it is transparent and conveys how to use these structures for these types of problems.

Find Decay Products

Now, let’s fill these structures by finding the daughters (decay \(e^{\pm}\) ) and their corresponding tracks.

Challenge

Exercise: Find Decay Products

Copy the following snippets just after your new helpers/bookkeepers.

PYTHON

        for particle in particles:
            if particle.getPDG() == 443:

                # Find decay leptons ------------------------------------------

                is_electron_decay = False
                decays            = list()
                for daughter in particle.getDaughters():
                    if abs(daughter.getPDG()) == 11:

                        # If daughter is an electron/positron, then this is
                        # a J/psi --> e+e- decay
                        is_electron_decay = True
                        decays.append(daughter)

                        ele_mom = convert_vector(particle.getMomentum())
                        ele_eta = ele_mom.Eta()
                        ele_mag = np.sqrt(ele_mom.Mag2())  # Mag() isn't implemented for XYZVector
                        h_daughter_momentum_all.Fill(ele_mag)
                        h_daughter_eta_all.Fill(ele_eta)

                        rec_ele = None
                        for association in associations:
                            if association.getSim() == daughter:
                                rec_ele = association.getRec()
                                break

                        if rec_ele is not None:
                            h_daughter_momentum_reco.Fill(ele_mag)
                            h_daughter_eta_reco.Fill(ele_eta)
                            track_to_daughter[rec_ele] = daughter

                # Sanity check: should have found 2 leptons
                #   --> If so, add their object IDs to map and fill histograms
                if len(decays) == 2:
                    daughter_to_daughter[decays[0]]  = decays[-1]
                    daughter_to_daughter[decays[-1]] = decays[0]
                    daughter_to_parent[decays[0]]    = particle
                    daughter_to_parent[decays[-1]]   = particle

                jpsi_lorentz = get_lorentz_particle(particle)
                h_jpsi_momentum_all.Fill(jpsi_lorentz.P())
                h_jpsi_eta_all.Fill(jpsi_lorentz.Eta())

CPP

    for (const auto& particle : particles) {
      if (particle.getPDG() == 443) {

        // Find decay leptons -------------------------------------------------

        bool is_electron_decay = false;
        std::vector<edm4hep::MCParticle> decays;

        for (const auto& daughter : particle.getDaughters()) {
          if (std::abs(daughter.getPDG()) == 11) {

            // If daughter is an electron/positron, then this is
            // a J/psi --> e+e- decay
            is_electron_decay = true;
            decays.push_back(daughter);

            const auto  ele_mom = convert_vector_double(particle.getMomentum());
            const float ele_eta = ele_mom.Eta();
            // Mag() isn't implemented for XYZVector
            const float ele_mag = std::sqrt(ele_mom.Mag2());
            h_daughter_momentum_all->Fill(ele_mag);
            h_daughter_eta_all->Fill(ele_eta);

            std::optional<edm4eic::Track> rec_ele;
            for (const auto& association : associations) {
              if (association.getSim() == daughter) {
                rec_ele = association.getRec();
                break;
              }
            }

            if (rec_ele.has_value()) {
              h_daughter_momentum_reco->Fill(ele_mag);
              h_daughter_eta_reco->Fill(ele_eta);
              track_to_daughter[rec_ele.value().getObjectID()] = daughter.getObjectID();
            }
          }
        }  // end daughter loop

        // Sanity check: should have found 2 leptons
        //   --> If so, add their object IDs to map and fill histograms
        if (decays.size() == 2) {
          daughter_to_daughter[decays.front().getObjectID()] = decays.back().getObjectID();
          daughter_to_daughter[decays.back().getObjectID()]  = decays.front().getObjectID();
          daughter_to_parent[decays.front().getObjectID()]   = particle;
          daughter_to_parent[decays.back().getObjectID()]    = particle;

          const auto jpsi_lorentz = get_lorentz_particle(particle);
          h_jpsi_momentum_all->Fill(jpsi_lorentz.P());
          h_jpsi_eta_all->Fill(jpsi_lorentz.Eta());
        }
      }
    }  // end particle loop

And confirm that the code still runs!

After this step h_daughter_momentum_all should hold two entries per event (both decay leptons; 2318 in the example file), and h_daughter_momentum_reco about 92% of that — most decay electrons are successfully reconstructed as tracks.

In particular, notice how easily we were able to (1) find the relevant daughters of the \(J/\psi\) and (2) how easily we handled the mc-track associations. This is one of the biggest advantages using the PODIO interface brings over analysis methods.

Compare Truth vs. Reco

We’re now in the home stretch! All that’s left to do is compare truth vs. reco using the structures we’ve already filled. For now, all we’re going to do is calculate our \(J/\psi\) reconstruction efficiency. This folds in 3 sources of efficiency:

  1. Were our decay \(e^{\pm}\) reconstructed as tracks?
  2. Were these tracks successfully matched to clusters?
  3. And how much did our analysis cuts eat into our signal?
Challenge

Exercise: Truth-Reco Comparison

Copy and paste these snippets in your code.

PYTHON

        # Compare reco lepton pairs to truth ones -----------------------------

        for track_1, track_2 in jpsi_candidates:

            # Check if tracks correspond to decay leptons
            par_1 = None
            if track_1 in track_to_daughter:
                par_1 = track_to_daughter[track_1]

            par_2 = None
            if track_2 in track_to_daughter:
                par_2 = track_to_daughter[track_2]

            if (par_1 is None) or (par_2 is None):
                continue

            # Now check if pair of tracks correspond to
            # a valid pair of electrons
            #   --> If they do, then we should see them
            #       match in our daughter_to_daughter
            #       map
            other_par_1 = daughter_to_daughter[par_1]
            other_par_2 = daughter_to_daughter[par_2]

            if (other_par_1 == par_2) and (other_par_2 == par_1):
                sim_jpsi     = daughter_to_parent[other_par_1]
                jpsi_lorentz = get_lorentz_particle(sim_jpsi)
                h_jpsi_momentum_reco.Fill(jpsi_lorentz.P())
                h_jpsi_eta_reco.Fill(jpsi_lorentz.Eta())

CPP

    // Compare reco lepton pairs to truth ones --------------------------------

    for (const auto& [track_1, track_2] : jpsi_candidates) {

      // Check if tracks correspond to decay leptons
      std::optional<podio::ObjectID> par_id_1;
      if (track_to_daughter.count(track_1.getObjectID()) > 0) {
        par_id_1 = track_to_daughter[track_1.getObjectID()];
      }

      std::optional<podio::ObjectID> par_id_2;
      if (track_to_daughter.count(track_2.getObjectID()) > 0) {
        par_id_2 = track_to_daughter[track_2.getObjectID()];
      }

      if (!par_id_1.has_value() || !par_id_2.has_value()) {
        continue;
      }

      // Now check if pair of tracks correspond to
      // a valid pair of electrons
      //   --> If they do, then we should see their
      //       Object IDs match in our daughter_to_daughter
      //       map
      const auto other_par_id_1 = daughter_to_daughter[par_id_1.value()];
      const auto other_par_id_2 = daughter_to_daughter[par_id_2.value()];

      if ((other_par_id_1 == par_id_2.value()) && (other_par_id_2 == par_id_1.value())) {
        const auto& sim_jpsi     = daughter_to_parent[other_par_id_1];
        const auto  jpsi_lorentz = get_lorentz_particle(sim_jpsi);
        h_jpsi_momentum_reco->Fill(jpsi_lorentz.P());
        h_jpsi_eta_reco->Fill(jpsi_lorentz.Eta());
      }
    }

And check that the code runs.

With the example file, h_jpsi_momentum_reco ends up with about 190 entries out of the 1159 generated \(J/\psi\), i.e. a reconstruction efficiency of roughly 17%. The next section computes these efficiencies properly, and the following section discusses why this number is so rough.

Finally, let’s calculate the efficiencies. You can do so by copying the following snippets into your code just after the event loop.

PYTHON

    # Calculate efficiencies and save output ==================================

    h_track_momentum_efficiency = h_track_momentum_all.Clone()
    h_track_momentum_efficiency.SetNameTitle(
        "h_track_momentum_efficiency",
        "Efficiency of matching tracks to clusters vs. track #it{p}")
    h_track_momentum_efficiency.Divide(h_track_momentum_match, h_track_momentum_all)

    h_track_eta_efficiency = h_track_eta_all.Clone()
    h_track_eta_efficiency.SetNameTitle(
        "h_track_eta_efficiency",
        "Efficiency of matching of tracks to clusters vs. track #eta")
    h_track_eta_efficiency.Divide(h_track_eta_match, h_track_eta_all)

    h_daughter_momentum_efficiency = h_daughter_momentum_all.Clone()
    h_daughter_momentum_efficiency.SetNameTitle(
        "h_daughter_momentum_efficiency",
        "Efficiency of reconstructing J/#psi daughter e^{#pm} vs. daughter #it{p}")
    h_daughter_momentum_efficiency.Divide(h_daughter_momentum_reco, h_daughter_momentum_all)

    h_daughter_eta_efficiency = h_daughter_eta_all.Clone()
    h_daughter_eta_efficiency.SetNameTitle(
        "h_daughter_eta_efficiency",
        "Efficiency of reconstructing J/#psi daughter e^{#pm} vs. daughter #eta")
    h_daughter_eta_efficiency.Divide(h_daughter_eta_reco, h_daughter_eta_all)

    h_jpsi_momentum_efficiency = h_jpsi_momentum_all.Clone()
    h_jpsi_momentum_efficiency.SetNameTitle(
        "h_jpsi_momentum_efficiency",
        "Efficiency of reconstructing J/#psi #rightarrow e^{+}+e^{-} vs. J/#psi #it{p}")
    h_jpsi_momentum_efficiency.Divide(h_jpsi_momentum_reco, h_jpsi_momentum_all)

    h_jpsi_eta_efficiency = h_jpsi_eta_all.Clone()
    h_jpsi_eta_efficiency.SetNameTitle(
        "h_jpsi_eta_efficiency",
        "Efficiency of reconstructing J/#psi #rightarrow e^{+}+e^{-} vs. J/#psi #eta")
    h_jpsi_eta_efficiency.Divide(h_jpsi_eta_reco, h_jpsi_eta_all)

CPP

  // Calculate efficiencies and save output ===================================

  TH1D* h_track_momentum_efficiency = (TH1D*) h_track_momentum_all->Clone();
  h_track_momentum_efficiency->SetNameTitle(
    "h_track_momentum_efficiency",
    "Efficiency of matching tracks to clusters vs. track #it{p}");
  h_track_momentum_efficiency->Divide(h_track_momentum_match, h_track_momentum_all);

  TH1D* h_track_eta_efficiency = (TH1D*) h_track_eta_all->Clone();
  h_track_eta_efficiency->SetNameTitle(
    "h_track_eta_efficiency",
    "Efficiency of matching of tracks to clusters vs. track #eta");
  h_track_eta_efficiency->Divide(h_track_eta_match, h_track_eta_all);

  TH1D* h_daughter_momentum_efficiency = (TH1D*) h_daughter_momentum_all->Clone();
  h_daughter_momentum_efficiency->SetNameTitle(
    "h_daughter_momentum_efficiency",
    "Efficiency of reconstructing J/#psi daughter e^{#pm} vs. daughter #it{p}");
  h_daughter_momentum_efficiency->Divide(h_daughter_momentum_reco, h_daughter_momentum_all);

  TH1D* h_daughter_eta_efficiency = (TH1D*) h_daughter_eta_all->Clone();
  h_daughter_eta_efficiency->SetNameTitle(
    "h_daughter_eta_efficiency",
    "Efficiency of reconstructing J/#psi daughter e^{#pm} vs. daughter #eta");
  h_daughter_eta_efficiency->Divide(h_daughter_eta_reco, h_daughter_eta_all);

  TH1D* h_jpsi_momentum_efficiency = (TH1D*) h_jpsi_momentum_all->Clone();
  h_jpsi_momentum_efficiency->SetNameTitle(
    "h_jpsi_momentum_efficiency",
    "Efficiency of reconstructing J/#psi #rightarrow e^{+}+e^{-} vs. J/#psi #it{p}");
  h_jpsi_momentum_efficiency->Divide(h_jpsi_momentum_reco, h_jpsi_momentum_all);

  TH1D* h_jpsi_eta_efficiency = (TH1D*) h_jpsi_eta_all->Clone();
  h_jpsi_eta_efficiency->SetNameTitle(
    "h_jpsi_eta_efficiency",
    "Efficiency of reconstructing J/#psi #rightarrow e^{+}+e^{-} vs. J/#psi #eta");
  h_jpsi_eta_efficiency->Divide(h_jpsi_eta_reco, h_jpsi_eta_all);

Don’t forget to save your new histograms in the Python case! You now have a complete analysis script!

Next steps


If you look at our \(J/\psi\) reconstruction efficiency, it’s going to be pretty rough. I’d encourage you to play around with the code and see if you can improve things!

Hopefully, all of this gives you an idea of how the PODIO interface works and has shown how it help unlock relatively complicated analyses!

Key Points
  • J/psi are measured as dileptons, pairs of decay leptons
  • Need to track which J/psi decayed to which electrons and their associated tracks