diff --git a/Configuration/ProcessModifiers/python/enableTruth_cff.py b/Configuration/ProcessModifiers/python/enableTruth_cff.py new file mode 100644 index 0000000000000..ab01bb7e478e7 --- /dev/null +++ b/Configuration/ProcessModifiers/python/enableTruth_cff.py @@ -0,0 +1,7 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +import FWCore.ParameterSet.Config as cms + +enableTruth = cms.Modifier() \ No newline at end of file diff --git a/Configuration/PyReleaseValidation/python/upgradeWorkflowComponents.py b/Configuration/PyReleaseValidation/python/upgradeWorkflowComponents.py index fb1e68b543434..361c0eda89ac2 100644 --- a/Configuration/PyReleaseValidation/python/upgradeWorkflowComponents.py +++ b/Configuration/PyReleaseValidation/python/upgradeWorkflowComponents.py @@ -1006,6 +1006,49 @@ def condition(self, fragment, stepList, key, hasHarvest): upgradeWFs['ticlv5_TrackLinkingGNN'].step3 = {'--procModifiers': 'ticlv5_TrackLinkingGNN'} upgradeWFs['ticlv5_TrackLinkingGNN'].step4 = {'--procModifiers': 'ticlv5_TrackLinkingGNN'} + + +class UpgradeWorkflow_enableTruth(UpgradeWorkflow): + def setup_(self, step, stepName, stepDict, k, properties): + # enableTruth runs the truth-graph producers in RecoGlobal (step3) and, + # in GenSim (step1), keeps the full ancestor branch of every stored + # SimTrack (g4SimHits PersistencyEmin -> 0 via the modifier) so the + # truth graph stays connected to the generator. The Branch validators run + # in the RecoGlobal VALIDATION and their efficiency harvesting in + # HARVESTGlobal (step4), so the modifier must reach the harvesting step too. + if 'GenSim' in step or 'RecoGlobal' in step or 'HARVESTGlobal' in step: + stepDict[stepName][k] = deepcopy(stepDict[step][k]) + + if '--procModifiers' in stepDict[stepName][k]: + stepDict[stepName][k]['--procModifiers'] += ',enableTruth' + else: + stepDict[stepName][k]['--procModifiers'] = 'enableTruth' + + def condition(self, fragment, stepList, key, hasHarvest): + return 'Run4' in key + + +upgradeWFs['enableTruth'] = UpgradeWorkflow_enableTruth( + steps = [ + 'GenSim', + 'GenSimHLBeamSpot', + 'GenSimHLBeamSpot14', + 'GenSimHLBeamSpotCloseBy', + 'RecoGlobal', + 'HARVESTGlobal', + ], + PU = [ + 'GenSim', + 'GenSimHLBeamSpot', + 'GenSimHLBeamSpot14', + 'GenSimHLBeamSpotCloseBy', + 'RecoGlobal', + 'HARVESTGlobal', + ], + suffix = '_enableTruth', + offset = 0.88, +) + # L3 Tracker Muon Outside-In reconstruction first class UpgradeWorkflow_phase2L3MuonsOIFirst(UpgradeWorkflow): def setup_(self, step, stepName, stepDict, k, properties): diff --git a/PhysicsTools/TruthInfo/BuildFile.xml b/PhysicsTools/TruthInfo/BuildFile.xml new file mode 100644 index 0000000000000..2a81d5c337b1f --- /dev/null +++ b/PhysicsTools/TruthInfo/BuildFile.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PhysicsTools/TruthInfo/README.md b/PhysicsTools/TruthInfo/README.md new file mode 100644 index 0000000000000..a14dfd07e2302 --- /dev/null +++ b/PhysicsTools/TruthInfo/README.md @@ -0,0 +1,90 @@ +# TruthInfo prototype + +> **Status: under heavy development — not open to external contributions.** +> This is an experimental prototype: its data model, APIs, and configuration +> change frequently and without notice, and it targets **Phase-2 (Run 4) only** +> (no Phase-1/Run-2 support). Please do not submit external changes or depend on +> it in production at this stage. +> +> **Original author and maintainer:** Felice Pantaleo (CERN), +> . + +A prototype **MC-truth graph** for CMS: a single, navigable, physics-oriented +abstraction of the generator + simulation truth history of an event, with +calorimeter and tracker hit indices layered on top. It replaces the need to +cross-navigate the many low-level truth collections (HepMC, GenParticles, +SimTracks/SimVertices, TrackingParticles, SimClusters, CaloParticles, SimHits, +RecHits) by hand. + +## Documentation + +The **authoritative, maintained documentation** lives on the project website: + +> **http://cms-truth.docs.cern.ch/** + +It covers the data model, how to enable and use the graph (navigation API, the +`truth::Branch` view, the hit index, matching reco objects), the physics findings, +validation, pileup, and the roadmap. The MkDocs sources are in the companion +`cms-truth-docs` repository. Start with the **"How to use the graph"** page. + +## The three layers + +1. **`TruthGraph`** (raw) — a compact CSR graph built directly from HepMC + + `SimTrack`/`SimVertex` by `TruthGraphProducer`. +2. **`truth::Graph`** (logical) — a user-facing bipartite Particle↔Vertex graph + built by `TruthLogicalGraphProducer`; GEN and SIM are merged where robustly + associated, with navigation (`parents()`, `descendants()`, + `firstCommonAncestor()`, `hasAncestorPdgId()`, …) and the `truth::Branch` + subgraph view + `BranchSelector` selection. +3. **`truth::LogicalGraphHitIndex`** — per-particle direct vs aggregated subgraph + calorimeter and tracker hits, built by `LogicalGraphHitIndexProducer` (with the + DetId→RecHit map from `DetIdToRecHitMapProducer`). + +Producer chain (order matters): `truthGraphProducer` → `truthLogicalGraphProducer` +→ `detIdToRecHitMapProducer` → `truthLogicalGraphHitIndexProducer`. In a release +job these run behind the `enableTruth` process modifier (the +`truthGraphPrevalidation` sequence in `Validation/Configuration`). + +## Package layout + +- `interface/` + `src/` — the data formats and algorithms: `TruthGraph`, + `truth::Graph`, `Branch`, `BranchSelector`, `LogicalGraphHitIndex`, + `BranchHitAssociator`, the `truth::recoHits` adapters (`RecoHitAdapters.h`), and + `TruthLogicalGraphPostProcessor` (merge/collapse/filter; covered by the cppunit). +- `plugins/` — the producers above, the DOT dumpers, the flat-table producers, the + pileup `TruthGraphAccumulator`/`TruthGraphMixedProducer`, the association-map + producers (`TruthBranchCaloAssociationProducer`, + `TruthBranchTrackingAssociationProducer`), and the DQM validators + (`BranchHGCalValidator`, `BranchTrackingValidator`, the generic + `BranchRecoValidator`). +- `python/` — `truthGraphValidation_cff` (producers + association maps + DQM + analyzers) and `truthGraphDQMHarvester_cff` (efficiency/fake/merge harvesting). +- `scripts/` — `makeTruthGraphValidationPlots.py` (renders the Branch validation + plots / sample overlays). +- `test/` — cppunit unit tests and standalone `cmsRun` drivers (graph dumps, + topology checks, association/DQM smoke tests). + +## Build, check, test + +All commands assume the CMSSW environment (`cd $CMSSW_BASE/src && cmsenv`): + +```bash +scram b -j 8 # build +scram b code-format code-checks -j 8 # clang-format + clang-tidy (must pass) +scram b runtests # cppunit unit tests +``` + +## Run standalone on a step3.root + +```bash +# Dump per-event DOT graphs (options: -n, -m/--merge, -c/--collapse, -o, -t) +cmsRun test/dumpTruthGraphsFromGENSIMRECO_cfg.py path/to/step3.root -n 5 + +# Branch DQM validators (calo / tracking / generic reco-side) +cmsRun test/validateBranchDQM_cfg.py path/to/step3.root -n 5 +cmsRun test/validateBranchTrackingDQM_cfg.py path/to/step3.root -n 5 +cmsRun test/validateBranchRecoDQM_cfg.py path/to/step3.root -n 5 +``` + +See the website for the full configuration reference, the navigation API with +examples, and the validation results. diff --git a/PhysicsTools/TruthInfo/doc/branch_design.md b/PhysicsTools/TruthInfo/doc/branch_design.md new file mode 100644 index 0000000000000..f8c50872c71bb --- /dev/null +++ b/PhysicsTools/TruthInfo/doc/branch_design.md @@ -0,0 +1,151 @@ +# `truth::Branch` — design proposal (for discussion) + +## Concept +A `truth::Branch` is a **coherent connected subgraph** of `truth::Graph`: a chosen +root (a particle, or a small set such as `Z -> mu mu`) together with a defined +**closure** of its descendants, plus the detector footprint attached through +`LogicalGraphHitIndex`. Like `Particle`/`Vertex`, it is a lightweight **view** +into a `Graph`, not an owning copy. + +A Branch is the natural target for truth-reco association when a reconstructed +object does not map to a single truth particle: +- a jet <-> a parton's whole shower branch, +- an ECAL supercluster <-> a photon + its conversion e+e- branch, +- a tau-jet <-> the visible tau-decay branch, +- a b-jet <-> the b-quark branch (with its B-hadron sub-branch). + +It is built directly on phases 1-3: the selection (seed PDG / heavy-flavor) +chooses the root, the downstream closure defines the extent, the ISR/underlying +-event roles + genEvent/eventId give provenance, and the hit index gives the +detector footprint. + +## Construction & closure policy +```cpp +Branch b = graph.branch(rootParticle, closure); +``` +`closure` selects which members belong to the branch and is always evaluated on +the fly from the `Graph` (a Branch is never an EDM product): +- `Subtree` - the root and all descendants (default); +- `StableLeaves`- the root plus only its final-state descendants; +- `DepthN` - descendants down to N generations; +- `UntilPdgId` - stop the closure at a species (e.g. stop at stable hadrons, + or at the B hadron for a b-branch); +- `Predicate` - stop on a user predicate (stop at any heavy-flavor hadron, at a + detector-boundary crossing, on a custom lambda), so closures + are extensible without new enum values. +The phase-1-3 postprocessing already computes member sets; a Branch makes that +set a first-class, queryable object. + +**Decision:** the Branch is a **view, recomputed on demand** — stateless, no +stored member list, not an EDM product. Any caching needed for performance lives +in the *matching layer* (below), scoped to a batch of objects, not in the Branch. + +## Data model +```cpp +class Branch { + Graph const* graph_; + std::vector roots_; // usually 1 + std::vector members_; // closure (materialized) + // optional caches: p4 sums, DetId set, hit spans +}; +``` +A Branch carries provenance via its root (`genEvent`/`eventId`), so pile-up +branches stay distinguishable when graphs are overlaid. + +## Queries the Branch should answer + +### A. Matching reco objects (the substrate is detector-agnostic; metrics are pluggable) +- `members()`, `stableLeaves()`, `chargedStableLeaves()`. +- `hits(closure)` - aggregated direct/subgraph SimHits + matched RecHits over all members (LogicalGraphHitIndex already gives this per particle). +- `detIds()`, `energy(Detector)` - sim/rec energy summed over the branch in a subdetector. +- `sharedHitFraction(recoObject)` / `sharedHits(recoObject)` - tracking-style metric. +- `energyFraction(recoCluster)` - calorimeter-style metric. +- `matchScore(recoObject, Metric)` - **Metric is a strategy on the Branch**: shared-hits (tracking), energy-fraction (calo), time-aware (MTD). New detectors add a Metric without touching Branch. +- `containsSimTrack(id)`, `containsDetId(id)`. + +**Batch / many-to-many matching.** Single-object queries go through the Branch +metric strategy above. For associating *collections* — N reco <-> 1 sim (split +tracks, calo fragments -> one particle) and N sim <-> 1 reco (a jet <- a branch) +— a free `BranchMatcher(branches, recoObjects, Metric)` builds the inverted +`hit/DetId -> branch` index **once and caches it for the duration of the call**, +then emits a weighted bipartite association in both directions (cf. reco's +`RecoToSimCollection`/`SimToRecoCollection`). The Branch stays stateless; the +cache lives in the matcher. + +**Hit ranges and `std::span`.** If `LogicalGraphHitIndex` lays hits out in graph +-topological order, a `Subtree` branch's hits are a **contiguous range** — i.e. +exactly the precomputed subgraph-hit `std::span` of its root, returned with zero +gather. The matcher can then count shared hits / energy by a sorted-range +merge-join rather than hashing, which is the cache-friendly path. (This needs the +hit-index builder to guarantee the topological layout; see follow-up below.) + +### B. Tagging (flavor / origin / process) +- `rootPdgId()`, `originPdgId(targets)` (= `firstAncestorWithPdgId` from the root). +- `hasAncestorPdgId(id)` - is this branch from a top? a Z? the hard scatter? +- `heavyFlavorContent()` - does the branch contain a b/c hadron (b/c-tag truth)? (reuses the phase-2 flavor classifier). +- `isFromHardScatter()` / `isFromPileup()` - via the genEvent/eventId provenance (phase 1). +- `decayChannel()` - decay mode of the root (Z->mumu vs Z->ee, tau hadronic vs leptonic, prong count). +- `flightLength()` / `displacedVertex()` - production->decay displacement (Lxy) for b/tau lifetime tagging. + +### C. Physics performance +- `p4(MemberSelector)` - branch four-momentum over {all | stable | charged | visible}. +- `visibleEnergy()` / `invisibleEnergy()` - missing energy from neutrinos/LSP. +- `chargedFraction()`, `emFraction()`, `hadronicFraction()` - for jet response/composition. +- `response(recoObject)` = recoE / branchE; efficiency/fake bookkeeping via matching. +- boundary-crossing kinematics from `Checkpoint`s - for propagation/calibration studies. + +### D. Branch <-> Branch relations +- `commonAncestor(other)` - do two branches come from the same top / same Z? (generalizes `lowestCommonAncestor`). +- `merged(other)` - combine two branches (and their hit content) into one (e.g. the two Z-decay legs). +- `deltaR(other)`, `overlap(other)` (shared members/hits) - for splitting/merging studies. + +## Cross-cutting principles +- **Substrate vs metric**: the Branch holds structure + hits; matching *definitions* + stay detector-aware and use-case dependent (tracking != calo != timing). +- **Provenance-aware**: every Branch knows its source event (primary vs pile-up). +- **Composable**: branches merge/split; queries compose with the existing + navigation (`ancestors`, `firstCommonAncestor`, `firstAncestorWithPdgId`). +- **Built on what exists**: selection (phase 1-2), navigation (phase 3), + hit index (existing) — Branch is the unifying view, not new infrastructure. + +## Resolved decisions +1. **View, recomputed on demand** — the Branch stores no member list and is not + an EDM product. +2. **Derived on the fly** from `Graph` + closure (never persisted). Member-id + lists are cheap to recompute; hit aggregates are not stored on the Branch. +3. **Metric strategy on the Branch** for single-object scores; a free, + cache-holding `BranchMatcher` for batch many-to-many association. +4. **`merged()` unions member sets lazily** (no cached hit aggregates), + consistent with the view model. +5. **Closures include predicate-based stops**, in addition to the fixed + `{Subtree, StableLeaves, DepthN, UntilPdgId}` set. + +## Implementation status +Implemented (library level, all unit-tested): +- **Hit layout** (`LogicalGraphHitIndex`): a particle's subgraph hits are a + contiguous, detId-sorted `std::span`, so a `Subtree` branch's hits are + `subgraphHits(root)` with zero gather and are merge-join ready. +- **`truth::Branch`** (`interface/Branch.h`): the view, with closures + `Subtree / StableLeaves / DepthN / UntilPdgId / Predicate`, members/leaves, + p4 / visible / invisible energy, origin (`originWithPdgId`), heavy-flavor + content, pile-up provenance (`bunchCrossing`/`event`/`isSignal`/`isFromPileup`), + and relations (`commonAncestor`, `merged`). +- **`truth::BranchHitAssociator`** (`interface/BranchHitAssociator.h`): the + generic, batch-cached matcher. **Customization point**: any reco object that + exposes `R::truthHits()` returning a range of `truth::RecoHit` + (`{detId, energy, fraction}`) is matchable — the `HasTruthHits` concept. It + caches the inverted `detId -> roots` index once, then `bestBranches(reco)` + merge-joins the object's sorted hits against each candidate's sorted subgraph + span. Metrics: `SharedEnergy` (HGCal-style score) and `SharedHits`. +- **`truth::BranchSelector`** (`interface/BranchSelector.h`): pt/eta/pdgId/charge + + signal/in-time selection, mirroring TrackingParticleSelector/CaloParticleSelector. + +## Remaining (EDProducer wiring) +- Wrap `BranchHitAssociator` in EDProducers that consume real reco collections + (tracks, tracksters/PFclusters, jets) and emit `ticl::AssociationMap` + (`mapWithSharedEnergyAndScore`) products in both directions, mirroring + `AllTracksterToSimTracksterAssociatorsByHitsProducer` and the + TrackingParticle<->reco::Track associator, but with a Branch in place of the + SimTrackster/TrackingParticle. +- A tracker variant keyed on shared `(trackId, EncodedEventId)` SimTrack hits + (the QuickTrackAssociatorByHits metric) for track<->branch matching. diff --git a/PhysicsTools/TruthInfo/interface/Branch.h b/PhysicsTools/TruthInfo/interface/Branch.h new file mode 100644 index 0000000000000..15a83645a611c --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/Branch.h @@ -0,0 +1,93 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_interface_Branch_h +#define PhysicsTools_TruthInfo_interface_Branch_h + +#include +#include +#include +#include + +#include "DataFormats/Math/interface/LorentzVector.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" + +namespace truth { + + // How far below the root(s) a Branch extends. + enum class ClosureKind : uint8_t { Subtree, StableLeaves, DepthN, UntilPdgId, Predicate }; + + struct ClosureSpec { + ClosureKind kind = ClosureKind::Subtree; + uint32_t maxDepth = 0; // DepthN: generations kept below each root (0 = roots only) + std::vector stopPdgIds; // UntilPdgId: stop at (and include) particles with these ids + std::function stopAt; // Predicate: stop at (and include) particles where true + + static ClosureSpec subtree() { return {}; } + static ClosureSpec stableLeaves() { return {ClosureKind::StableLeaves, 0, {}, {}}; } + static ClosureSpec depth(uint32_t n) { return {ClosureKind::DepthN, n, {}, {}}; } + static ClosureSpec untilPdgId(std::vector ids) { return {ClosureKind::UntilPdgId, 0, std::move(ids), {}}; } + static ClosureSpec predicate(std::function p) { + return {ClosureKind::Predicate, 0, {}, std::move(p)}; + } + }; + + // A Branch is a lightweight, non-owning view of a coherent subgraph: one or + // more root particles plus a closure of their descendants. Members are + // recomputed on demand from the Graph; the Branch stores no graph data and is + // not an EDM product. It is the truth-side object that reco objects are matched + // to, the natural successor to the static CaloParticle/TrackingParticle. + class Branch { + public: + Branch() = default; + Branch(Graph const* graph, uint32_t rootId, ClosureSpec spec = ClosureSpec::subtree()); + Branch(Graph const* graph, std::vector rootIds, ClosureSpec spec = ClosureSpec::subtree()); + + [[nodiscard]] bool valid() const { return graph_ != nullptr && !roots_.empty(); } + [[nodiscard]] Graph const* graph() const { return graph_; } + [[nodiscard]] Particle root() const; + [[nodiscard]] std::vector roots() const; + [[nodiscard]] std::vector rootIds() const { return roots_; } + [[nodiscard]] ClosureSpec const& closure() const { return spec_; } + + // Closure members (roots + selected descendants), ascending particle id. + [[nodiscard]] std::vector memberIds() const; + [[nodiscard]] std::vector members() const; + [[nodiscard]] std::vector stableLeaves() const; + + // Kinematics, summed over the stable final-state leaves. + [[nodiscard]] math::XYZTLorentzVectorD p4() const; + [[nodiscard]] math::XYZTLorentzVectorD visibleP4() const; // excludes neutrinos + [[nodiscard]] double energy() const { return p4().energy(); } + [[nodiscard]] double visibleEnergy() const { return visibleP4().energy(); } + [[nodiscard]] double invisibleEnergy() const; + + // Tagging / origin. + [[nodiscard]] int32_t rootPdgId() const; + [[nodiscard]] std::optional originWithPdgId(int32_t pdgId) const; + [[nodiscard]] bool hasHeavyFlavor(int32_t quarkFlavor) const; // any member is a flavor-q hadron + + // Provenance (pile-up aware): the source event of the root. + [[nodiscard]] int32_t genEvent() const; + [[nodiscard]] int bunchCrossing() const; + [[nodiscard]] int event() const; + [[nodiscard]] bool isInTime() const { return bunchCrossing() == 0; } + [[nodiscard]] bool isFromPileup() const { return bunchCrossing() != 0; } + [[nodiscard]] bool isSignal() const { return bunchCrossing() == 0 && event() == 0; } + + // Relations between branches. + [[nodiscard]] std::optional commonAncestor(Branch const& other) const; + [[nodiscard]] Branch merged(Branch const& other) const; + + private: + [[nodiscard]] std::vector traverse() const; + + Graph const* graph_ = nullptr; + std::vector roots_; + ClosureSpec spec_; + }; + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h b/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h new file mode 100644 index 0000000000000..b5b632bb8234d --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/BranchHitAssociator.h @@ -0,0 +1,114 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_interface_BranchHitAssociator_h +#define PhysicsTools_TruthInfo_interface_BranchHitAssociator_h + +#include +#include +#include +#include + +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" + +namespace truth { + + // The hit format the graph matches against. Any reco object can be matched by + // exposing its hits as a range of RecoHit. + struct RecoHit { + uint32_t detId = 0; + float energy = 0.f; // the cell (rec)hit energy + float fraction = 1.f; // fraction of the cell assigned to this reco object + }; + + // Customization point: a reco object R is matchable if it exposes its hits via + // a member R::truthHits() returning a range of RecoHit. A user wanting to match + // their own reco object to the truth graph only needs to add this one method. + template + concept HasTruthHits = requires(const R& r) { + { r.truthHits() } -> std::ranges::range; + }; + + struct BranchMatch { + uint32_t rootParticleId = 0; + float sharedEnergy = 0.f; // (SharedHits metric: number of shared cells) + // Reco-normalized score: how much of the reco object the branch fails to + // cover (denominator = reco self-energy / reco hit count). Use for the + // reco->branch direction. Lower is better. + float score = 0.f; + // Branch-normalized score: how much of the branch the reco object fails to + // cover (denominator = branch subgraph self-energy / branch hit count). Use + // for the branch->reco direction. Lower is better. + float reverseScore = 0.f; + }; + + // Associates reco objects to truth branches (subtrees) by shared detector hits. + // Built once per event over a set of candidate branch roots (default: every + // particle); caches the inverted detId -> roots index and per-cell total sim + // energy as flat, sorted arrays (binary-searched, no per-event hashing). + // bestBranches() then answers any reco object via a merge-join of the object's + // (sorted) hits with each candidate's sorted subgraph-hit span. + class BranchHitAssociator { + public: + enum class Metric { SharedEnergy, SharedHits }; + + // candidateRoots restricts the branch roots considered. By default an empty + // list means "every particle" (the common unrestricted case). Pass + // emptyRootsMeansAll = false to instead treat an empty list as "no candidates" + // (match nothing) - needed when a caller asked for a restriction that happened + // to select no particle in this event, which must not silently fall back to all. + explicit BranchHitAssociator(LogicalGraphHitIndex const& hitIndex, + std::vector candidateRoots = {}, + Metric metric = Metric::SharedEnergy, + HitChannel channel = HitChannel::HGCalCalo, + bool emptyRootsMeansAll = true); + + // Best branches for a reco object's hits, sorted by score ascending. If + // maxResults > 0, only the best maxResults are returned. + [[nodiscard]] std::vector bestBranches(std::span recoHits, + std::size_t maxResults = 0) const; + + template + [[nodiscard]] std::vector bestBranches(R const& reco, std::size_t maxResults = 0) const { + std::vector hits; + for (auto const& h : reco.truthHits()) + hits.push_back(RecoHit{h.detId, h.energy, h.fraction}); + return bestBranches(std::span(hits), maxResults); + } + + private: + [[nodiscard]] std::span rootHits(uint32_t rootId) const; + + // Candidate roots whose subgraph touches a cell, by binary search; empty span + // if the cell is untouched. + [[nodiscard]] std::span rootsForCell(uint32_t detId) const; + // Total sim energy on a cell (denominator for branch fractions), 0 if none. + [[nodiscard]] float cellTotalEnergy(uint32_t detId) const; + + LogicalGraphHitIndex const* hitIndex_; + Metric metric_; + HitChannel channel_; + std::vector roots_; + + // Inverted index detId -> candidate roots, stored CSR-style: cellRootsKeys_ + // holds the distinct cell detIds (ascending); cellRootsOffsets_ indexes + // cellRoots_, which holds the root ids (ascending within each cell). + std::vector cellRootsKeys_; + std::vector cellRootsOffsets_; + std::vector cellRoots_; + + // Per-cell total sim energy as parallel sorted arrays (cellEnergyKeys_ ascending). + std::vector cellEnergyKeys_; + std::vector cellEnergyValues_; + + // Per-root branch self-energy (sum of subgraph-hit energy^2 on channel_), + // indexed by particle id; the denominator for the branch-normalized reverse + // score. Computed once with the inverted index so bestBranches() needs no + // full branch-hit scan. + std::vector rootSelfEnergySq_; + }; + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/BranchSelector.h b/PhysicsTools/TruthInfo/interface/BranchSelector.h new file mode 100644 index 0000000000000..f3d0408ae4cea --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/BranchSelector.h @@ -0,0 +1,46 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_interface_BranchSelector_h +#define PhysicsTools_TruthInfo_interface_BranchSelector_h + +#include +#include + +#include "PhysicsTools/TruthInfo/interface/Branch.h" + +namespace truth { + + // Kinematic / provenance selection of truth Branches, mirroring the cut + // surface of TrackingParticleSelector / CaloParticleSelector but applied to a + // Branch (the dynamic successor of TrackingParticle/CaloParticle). The branch + // kinematics are taken from its defining root particle. + class BranchSelector { + public: + struct Config { + double ptMin = 0.; + double ptMax = 1e100; + double etaMin = -1e100; + double etaMax = 1e100; + std::vector pdgIds; // empty = accept all; matched on signed PDG id + bool signalOnly = false; // bunchCrossing == 0 and event == 0 + bool intimeOnly = false; // bunchCrossing == 0 + bool chargedOnly = false; // root particle electrically charged + bool invertEta = false; // keep |eta| OUTSIDE [etaMin, etaMax] + }; + + BranchSelector() = default; + explicit BranchSelector(Config config) : config_(std::move(config)) {} + + [[nodiscard]] bool operator()(Branch const& branch) const; + + [[nodiscard]] Config const& config() const { return config_; } + + private: + Config config_; + }; + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h b/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h new file mode 100644 index 0000000000000..41bbdc2f30b19 --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h @@ -0,0 +1,89 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_LogicalGraphHitIndexBuilder_h +#define PhysicsTools_TruthInfo_LogicalGraphHitIndexBuilder_h + +#include +#include +#include +#include + +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" + +namespace truth { + + class LogicalGraphHitIndexBuilder { + public: + explicit LogicalGraphHitIndexBuilder(uint32_t nParticles); + + void setSimTrackForParticle(uint32_t particleId, uint32_t trackId); + void addParticleChild(uint32_t parentParticleId, uint32_t childParticleId); + + // Add a hit on `trackId`'s SimTrack to `channel`. recHitIndex defaults to "no + // recHit" for channels without a DetId->RecHit link (tracker, muon); calo/MTD + // pass the mapped global recHit index. + void addHit(HitChannel channel, + uint32_t trackId, + uint32_t detId, + float energy, + uint32_t recHitIndex = LogicalGraphHitIndex::Hit::kInvalidRecHitIndex); + + [[nodiscard]] LogicalGraphHitIndex finish(); + + private: + using Hit = LogicalGraphHitIndex::Hit; + + // Per-particle hits are accumulated as a flat, append-only list and coalesced + // (summed per detId, sorted) lazily. This keeps the hot insertion path a + // single push_back and avoids a per-particle hash table (one per particle for + // each channel), which dominated CPU and memory at high hit multiplicity. + using HitList = std::vector; + + static void appendHit(HitList& hits, uint32_t detId, uint32_t recHitIndex, float energy); + + // Sort by detId and merge entries that share a detId: energies are summed and + // the recHitIndex is the unique valid index for that detId, if any (a detId + // maps to a single recHit, so all valid entries agree). Entries that coalesce + // to non-positive energy are dropped. Idempotent on already-coalesced lists. + // Summation runs in detId order, so coalesced energies are deterministic and + // independent of hit insertion order (unlike a hash-map accumulation, whose + // sum order was bucket-dependent); cell energies can therefore differ from a + // hash-based build at the float-reassociation level (~1e-7 relative). + static void coalesce(HitList& hits); + + // Collect the particle and every distinct descendant (cycle-safe) into + // `order`. `visited`/`touched`/`stack` are reusable scratch: `touched` lists + // the ids set in `visited` so they can be cleared in O(subgraph size) between + // calls. Each descendant appears exactly once, so a particle reachable through + // several paths (a re-convergent DAG) is not double-counted when its direct + // hits are later summed into the subgraph aggregate. + void collectSubgraphParticles(uint32_t particleId, + std::vector& visited, + std::vector& touched, + std::vector& stack, + std::vector& order) const; + + // Concatenate the (already coalesced) per-particle lists into CSR storage. + static void buildHitCSR(std::vector const& lists, + std::vector& offsets, + std::vector& storage); + + uint32_t nParticles_ = 0; + + std::unordered_map trackIdToParticle_; + std::vector> children_; + + // [channel index][particle] -> direct hit list. Subgraph hits are aggregated + // in finish(). + std::array, kNumHitChannels> directHits_; + + // A channel that never received a hit (not selected, or its detector absent) + // is left empty by finish() without the per-particle subgraph aggregation. + std::array channelTouched_{}; + }; + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h b/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h new file mode 100644 index 0000000000000..39e79160d0328 --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/RecoHitAdapters.h @@ -0,0 +1,76 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_interface_RecoHitAdapters_h +#define PhysicsTools_TruthInfo_interface_RecoHitAdapters_h + +// Adapters that expose a reco object's hits as a range of truth::RecoHit so the +// generic BranchHitAssociator / BranchRecoValidator can match any reco object to +// the truth Branch graph (the customization point envisaged by the HasTruthHits +// concept in BranchHitAssociator.h). These live here, not as member methods on the +// reco data formats, for two reasons: (a) only reco::Track owns its hits - a +// Trackster/TICLCandidate/PFCandidate references layer clusters / blocks that live +// in separate event collections, which a data-format method cannot reach; and (b) +// returning a PhysicsTools type from a DataFormats class would invert the package +// dependency. Each adapter therefore takes the object plus whatever external +// collection it needs. +// +// Tracker hits carry no per-cell energy to share, so they are exposed with unit +// energy and fraction (matching is by shared-hit multiplicity). Calorimeter hits +// are exposed with unit energy and the cell fraction, matching the convention the +// calo association producer / validator already use for CaloParticle/SimCluster, so +// the shared-energy metric compares cell fractions. + +#include +#include + +#include "DataFormats/CaloRecHit/interface/CaloCluster.h" +#include "DataFormats/HGCalReco/interface/Trackster.h" +#include "DataFormats/TrackReco/interface/Track.h" +#include "DataFormats/TrackingRecHit/interface/TrackingRecHit.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" + +namespace truth { + + // reco::Track -> its valid rechit DetIds (unit weight; tracker shared-hit metric). + inline std::vector recoHits(reco::Track const& track) { + std::vector hits; + hits.reserve(track.recHitsSize()); + for (auto it = track.recHitsBegin(); it != track.recHitsEnd(); ++it) { + TrackingRecHit const* hit = &(**it); + if (hit->isValid()) + hits.push_back(RecoHit{hit->geographicalId().rawId(), 1.f, 1.f}); + } + return hits; + } + + // ticl::Trackster -> the (DetId, fraction) of its layer clusters (unit energy; the + // calo shared-energy metric then compares cell fractions). Duplicate cells across + // the trackster's layer clusters are coalesced (fractions summed) so the + // merge-join in BranchHitAssociator sees each cell once. + inline std::vector recoHits(ticl::Trackster const& trackster, + std::vector const& layerClusters) { + std::vector hits; + for (unsigned int lc : trackster.vertices()) { + if (lc >= layerClusters.size()) + continue; + for (auto const& [detId, fraction] : layerClusters[lc].hitsAndFractions()) + hits.push_back(RecoHit{detId.rawId(), 1.f, fraction}); + } + std::sort(hits.begin(), hits.end(), [](RecoHit const& a, RecoHit const& b) { return a.detId < b.detId; }); + std::vector coalesced; + coalesced.reserve(hits.size()); + for (auto const& h : hits) { + if (!coalesced.empty() && coalesced.back().detId == h.detId) + coalesced.back().fraction += h.fraction; + else + coalesced.push_back(h); + } + return coalesced; + } + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h b/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h new file mode 100644 index 0000000000000..3d89416ee855b --- /dev/null +++ b/PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h @@ -0,0 +1,136 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#ifndef PhysicsTools_TruthInfo_interface_TruthLogicalGraphPostProcessor_h +#define PhysicsTools_TruthInfo_interface_TruthLogicalGraphPostProcessor_h + +#include +#include + +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "SimDataFormats/TruthInfo/interface/Graph.h" + +namespace truth { + + struct LogicalGraphPostProcessingConfig { + bool collapseIntermediateGenParticles = true; + + // If true, every SIM logical particle whose calorimeter + tracker sim-hit + // subgraph is empty is removed together with its whole downstream subtree. + // "Empty subgraph" is defined exactly as the LogicalGraphHitIndex sees it: + // a particle has a hit only if a calo/tracker sim-hit carries its SimTrack + // trackId, so the test reduces to "no logical particle at or below this one + // carries a positive-energy calo or tracker sim-hit". GEN-only descendants + // of a removed SIM particle (e.g. neutrinos) are swept out with it, while + // the GEN skeleton outside removed SIM subtrees is preserved. This step + // only runs when the producer supplies a per-particle direct-hit presence + // vector (it needs the sim-hit collections); without it it is a no-op. + bool dropHitlessSimSubgraphs = true; + + // If empty, no seed-based graph cut is applied. + // The most upstream particle of each matching chain becomes a root of the + // selected graph. The special value 0 disables the selection and keeps the + // full graph (debugging escape hatch). + std::vector seedPdgIds; + + // Seed on hadrons by heavy-flavor content instead of (or in addition to) + // exact PDG ids: a particle whose PDG id is a hadron containing any of these + // quark flavors becomes a seed. Use 5 for b hadrons, 4 for c hadrons. This + // lets the user select e.g. all B-hadron decay subgraphs without listing + // every B species. OR-ed with seedPdgIds. + std::vector seedHadronFlavors; + + // For each selected root, keep this many generations of ancestors above it + // as context only: the ancestors and connecting vertices are kept, but not + // their other descendants. + uint32_t seedParentDepth = 0; + + // If true (default), stable final-state GEN particles outside the selected + // subgraph are kept and attached to an artificial UnderlyingEvent source + // vertex. If false, they are dropped, giving a focused subgraph that + // contains only the selection and its truncated upstream (ISR) context. + // Only meaningful when a selection is active (seedPdgIds/decayPdgIdGroups). + bool keepStableSpectators = true; + + // If true (default), kept particles whose real production vertices all fall + // outside the selection are attached to an artificial Upstream (ISR) or + // UnderlyingEvent source vertex, keeping the selected subgraph rooted in a + // summary of the activity above it. If false, those particles become true + // graph roots (no production vertex), so each selected seed yields a + // self-contained subgraph starting directly at the seed - e.g. the ten taus + // of a TenTau event come out as ten disjoint components. Only meaningful when + // a selection (seedPdgIds/seedHadronFlavors/decayPdgIdGroups) is active. + bool attachSelectionSources = true; + + // If true, also keep each selected root's hard-scatter co-products: its + // production vertex and the *other* outgoing particles of that vertex (with + // their decay subtrees). This exposes the recoiling partons that share the + // seed's production vertex - e.g. the VBF tagging quarks that become forward + // jets and "produce" the Higgs - which are siblings of the seed, not its + // ancestors, and so are never reached by seedParentDepth. Keeping the + // production vertex also means the seed is no longer at the upstream boundary, + // so the real hard vertex is shown in place of the artificial Upstream summary. + // Only meaningful when a selection (seedPdgIds/seedHadronFlavors/...) is active. + bool keepProductionSiblings = false; + + // Pile-up filter (orthogonal to the seed selection): each particle carries the + // EncodedEventId of its pp collision - (0,0) for the signal, (bunchCrossing, + // pileupIndex) for pile-up. These drop particles by that provenance *after* the + // selection, so they compose with any seed preset. + // + // If true, keep only the signal interaction (bunchCrossing 0 and event 0), + // dropping all pile-up. Default false (keep every interaction). + bool signalOnly = false; + + // If non-empty, keep only particles whose bunchCrossing is in this list (e.g. + // {0} = in-time only). Empty = keep all bunch crossings. AND-ed with signalOnly. + std::vector keepBunchCrossings; + + // Decay patterns of interest. Each group is an unordered, charge-sensitive + // multiset of PDG ids; groups are OR-ed. + // + // Without seedPdgIds: a vertex whose outgoing PDG ids contain a group as a + // sub-multiset is selected, and the matched particles plus their downstream + // subgraphs are kept. + // + // With seedPdgIds: only seed roots whose effective decay products (after + // following same-PDG radiating copy chains) contain a group are kept. If + // the event contains no particle with a seed PDG id at all, the direct + // vertex search is used as a fallback. + std::vector> decayPdgIdGroups; + + // Particles with these exact PDG ids are removed from the final logical graph. + // If such a particle is internal, its production and decay vertices are merged + // so that the graph remains navigable. + std::vector ignoredPdgIds; + + // Exact logical particle ids to remove from the final logical graph. + // These ids refer to the graph state at the moment the ignored-particle + // collapsing step is applied. + std::vector ignoredParticleIds; + }; + + class TruthLogicalGraphPostProcessor { + public: + TruthLogicalGraphPostProcessor() = default; + explicit TruthLogicalGraphPostProcessor(LogicalGraphPostProcessingConfig config); + + static edm::ParameterSetDescription psetDescription(); + static LogicalGraphPostProcessingConfig configFromPSet(edm::ParameterSet const& pset); + + // particleDirectHit[i] != 0 marks logical particle i as carrying at least one + // positive-energy calorimeter or tracker sim-hit on its own SimTrack. It must + // be aligned to the input graph's particle ids; an empty vector disables the + // hitless-subgraph pruning (see LogicalGraphPostProcessingConfig). + [[nodiscard]] Graph process(Graph input, std::vector const& particleDirectHit = {}) const; + + private: + LogicalGraphPostProcessingConfig config_; + }; + +} // namespace truth + +#endif diff --git a/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc new file mode 100644 index 0000000000000..191153df2da5e --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BranchHGCalValidator.cc @@ -0,0 +1,540 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// DQM performance plots for the truth::Branch graph as a replacement for the +// legacy HGCAL truth objects (CaloParticle, SimCluster / SimTracksters). For each +// legacy object it finds the logical Branch that should reproduce it (via the +// SimTrack trackId), compares the Branch's subgraph calo hits to the object's +// hits_and_fractions, and asks the generic BranchHitAssociator whether that same +// Branch is the best hit-based match. The booked numerator/denominator histograms +// are turned into a "reproduction efficiency vs eta/pt/energy" by the harvester +// (DQMGenericClient); purity, completeness and energy-response are booked +// directly. These sit alongside the standard SimTrackster/CaloParticle plots so +// the two truth descriptions can be compared in the same DQM output. + +#include +#include +#include +#include +#include +#include +#include + +#include "DQMServices/Core/interface/DQMEDAnalyzer.h" +#include "DQMServices/Core/interface/DQMStore.h" +#include "DQMServices/Core/interface/MonitorElement.h" + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/HGCRecHit/interface/HGCRecHitCollections.h" +#include "DataFormats/ParticleFlowReco/interface/PFRecHit.h" + +#include "SimDataFormats/CaloAnalysis/interface/CaloParticle.h" +#include "SimDataFormats/CaloAnalysis/interface/CaloParticleFwd.h" +#include "SimDataFormats/CaloAnalysis/interface/SimCluster.h" +#include "SimDataFormats/CaloAnalysis/interface/SimClusterFwd.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +class BranchHGCalValidator : public DQMEDAnalyzer { +public: + explicit BranchHGCalValidator(edm::ParameterSet const&); + void bookHistograms(DQMStore::IBooker&, edm::Run const&, edm::EventSetup const&) override; + void analyze(edm::Event const&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + // One set of monitor elements per legacy collection (CaloParticle, SimCluster). + struct Plots { + // Numerator/denominator for the harvester-computed reproduction efficiency. + MonitorElement* denomEta = nullptr; + MonitorElement* denomPt = nullptr; + MonitorElement* denomEnergy = nullptr; + MonitorElement* effNumEta = nullptr; + MonitorElement* effNumPt = nullptr; + MonitorElement* effNumEnergy = nullptr; + // Quality distributions. + MonitorElement* purity = nullptr; + MonitorElement* completenessHits = nullptr; + MonitorElement* completenessEnergy = nullptr; + MonitorElement* energyResponse = nullptr; + // Raw energy response: Branch hit energy over the object's *hit* energy (rather + // than its generator energy), on the deposited (sim) and reconstructed (rec) + // scales. ~1 when the Branch reproduces the object's calorimeter energy. + MonitorElement* rawEnergyResponseSim = nullptr; + MonitorElement* rawEnergyResponseReco = nullptr; + // Profiles vs kinematics. + MonitorElement* purityVsEta = nullptr; + MonitorElement* completenessVsEta = nullptr; + MonitorElement* responseVsEta = nullptr; + MonitorElement* responseVsEnergy = nullptr; + MonitorElement* rawResponseSimVsEnergy = nullptr; + MonitorElement* rawResponseRecoVsEnergy = nullptr; + + // "Other way around": for each truth object, its best hit-matched Branch (which + // need not be the natural, trackId-seeded one) and that Branch's performance. + MonitorElement* bestPurity = nullptr; + MonitorElement* bestCompletenessHits = nullptr; + MonitorElement* bestCompletenessEnergy = nullptr; + MonitorElement* bestResponse = nullptr; + // Self-match numerator: best hit-matched Branch == natural Branch (denom reused). + MonitorElement* selfMatchEta = nullptr; + MonitorElement* selfMatchPt = nullptr; + // Merge/split: distinct Branches sharing >=10% of the object's hits. + MonitorElement* nSharingBranches = nullptr; + }; + + void book(DQMStore::IBooker&, Plots&, std::string const& sub); + + template + void validate(Collection const& objects, + truth::Graph const& graph, + TruthGraph const& raw, + truth::LogicalGraphHitIndex const& hitIndex, + truth::BranchHitAssociator const& assoc, + std::unordered_map const& tidToParticle, + std::unordered_map const& cellSimEnergy, + std::unordered_map const& recHitEnergyByDetId, + Plots& plots); + + // Whole-cell RecHit energy keyed by DetId, rebuilt from the same RecHit + // collections (HGCal then PF, the DetIdToRecHitMapProducer order) the hit + // index was mapped against; first index kept for any duplicate DetId. + std::unordered_map collectRecHitEnergyByDetId(edm::Event const&) const; + + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT rawToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> caloParticleToken_; + const edm::EDGetTokenT> simClusterToken_; + std::vector> hgcalRecHitTokens_; + std::vector> pfRecHitTokens_; + std::vector hgcalRecHitTags_; + std::vector pfRecHitTags_; + + const std::string folder_; + const double minPt_; + const double maxEta_; + + Plots caloParticlePlots_; + Plots simClusterPlots_; +}; + +BranchHGCalValidator::BranchHGCalValidator(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + rawToken_(consumes(cfg.getParameter("rawSrc"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + caloParticleToken_(consumes>(cfg.getParameter("caloParticles"))), + simClusterToken_(consumes>(cfg.getParameter("simClusters"))), + folder_(cfg.getParameter("folder")), + minPt_(cfg.getParameter("minPt")), + maxEta_(cfg.getParameter("maxEta")) { + for (auto const& tag : cfg.getParameter>("hgcalRecHits")) { + hgcalRecHitTags_.push_back(tag); + hgcalRecHitTokens_.push_back(consumes(tag)); + } + for (auto const& tag : cfg.getParameter>("pfRecHits")) { + pfRecHitTags_.push_back(tag); + pfRecHitTokens_.push_back(consumes(tag)); + } +} + +void BranchHGCalValidator::book(DQMStore::IBooker& ib, Plots& p, std::string const& sub) { + ib.setCurrentFolder(folder_ + "/" + sub); + + constexpr int kEtaBins = 40; + constexpr double kEtaMax = 3.2; + constexpr int kPtBins = 50; + constexpr double kPtMax = 200.; + constexpr int kEBins = 50; + constexpr double kEMax = 500.; + + p.denomEta = ib.book1D("denom_eta", "Selected truth objects vs #eta;#eta;objects", kEtaBins, -kEtaMax, kEtaMax); + p.denomPt = ib.book1D("denom_pt", "Selected truth objects vs p_{T};p_{T} [GeV];objects", kPtBins, 0., kPtMax); + p.denomEnergy = ib.book1D("denom_energy", "Selected truth objects vs E;E [GeV];objects", kEBins, 0., kEMax); + p.effNumEta = + ib.book1D("effnum_eta", "Branch-reproduced truth objects vs #eta;#eta;objects", kEtaBins, -kEtaMax, kEtaMax); + p.effNumPt = + ib.book1D("effnum_pt", "Branch-reproduced truth objects vs p_{T};p_{T} [GeV];objects", kPtBins, 0., kPtMax); + p.effNumEnergy = + ib.book1D("effnum_energy", "Branch-reproduced truth objects vs E;E [GeV];objects", kEBins, 0., kEMax); + + p.purity = ib.book1D("purity", "Branch hit purity;purity;objects", 52, -0.01, 1.03); + p.completenessHits = ib.book1D("completeness_hits", "Branch hit completeness;completeness;objects", 52, -0.01, 1.03); + p.completenessEnergy = + ib.book1D("completeness_energy", "Branch energy completeness;completeness;objects", 52, -0.01, 1.03); + p.energyResponse = + ib.book1D("energy_response", "Branch sim-energy containment;E^{sim}_{Branch}/E_{gen};objects", 60, 0., 1.5); + // Deposited-scale response is a closure test: == 1 by construction (the object's + // per-cell fraction is its tracks' share of the deposit, i.e. the Branch's own + // sim energy on that cell), so any deviation flags a fraction/deposit bug in PR + // validation. Reconstructed-scale response is the informative one. + p.rawEnergyResponseSim = + ib.book1D("raw_energy_response_sim", + "Branch raw energy response (deposited, closure: ==1);E^{sim}_{Branch}/E^{sim}_{hits};objects", + 80, + 0., + 2.); + p.rawEnergyResponseReco = + ib.book1D("raw_energy_response_reco", + "Branch raw energy response (reconstructed);E^{rec}_{Branch}/E^{rec}_{hits};objects", + 80, + 0., + 4.); + + p.purityVsEta = + ib.bookProfile("purity_vs_eta", "Branch hit purity vs #eta;#eta;purity", kEtaBins, -kEtaMax, kEtaMax, 0., 1.05); + p.completenessVsEta = ib.bookProfile("completeness_vs_eta", + "Branch energy completeness vs #eta;#eta;completeness", + kEtaBins, + -kEtaMax, + kEtaMax, + 0., + 1.05); + p.responseVsEta = ib.bookProfile("response_vs_eta", + "Branch sim-energy containment vs #eta;#eta;E^{sim}_{Branch}/E_{gen}", + kEtaBins, + -kEtaMax, + kEtaMax, + 0., + 1.5); + p.responseVsEnergy = ib.bookProfile("response_vs_energy", + "Branch sim-energy containment vs E;E [GeV];E^{sim}_{Branch}/E_{gen}", + kEBins, + 0., + kEMax, + 0., + 1.5); + p.rawResponseSimVsEnergy = ib.bookProfile( + "raw_response_sim_vs_energy", + "Branch raw energy response (deposited, closure: ==1) vs E;E [GeV];E^{sim}_{Branch}/E^{sim}_{hits}", + kEBins, + 0., + kEMax, + 0., + 2.); + p.rawResponseRecoVsEnergy = + ib.bookProfile("raw_response_reco_vs_energy", + "Branch raw energy response (reconstructed) vs E;E [GeV];E^{rec}_{Branch}/E^{rec}_{hits}", + kEBins, + 0., + kEMax, + 0., + 4.); + + // Best hit-matched Branch per object (the "other way around" view). + p.bestPurity = ib.book1D("bestmatch_purity", "Best-match Branch hit purity;purity;objects", 52, -0.01, 1.03); + p.bestCompletenessHits = ib.book1D( + "bestmatch_completeness_hits", "Best-match Branch hit completeness;completeness;objects", 52, -0.01, 1.03); + p.bestCompletenessEnergy = ib.book1D( + "bestmatch_completeness_energy", "Best-match Branch energy completeness;completeness;objects", 52, -0.01, 1.03); + p.bestResponse = ib.book1D( + "bestmatch_response", "Best-match Branch sim-energy containment;E^{sim}_{Branch}/E_{gen};objects", 60, 0., 1.5); + p.selfMatchEta = ib.book1D( + "selfmatch_eta", "Objects whose best Branch is the natural one vs #eta;#eta;objects", kEtaBins, -kEtaMax, kEtaMax); + p.selfMatchPt = ib.book1D( + "selfmatch_pt", "Objects whose best Branch is the natural one vs p_{T};p_{T} [GeV];objects", kPtBins, 0., kPtMax); + p.nSharingBranches = ib.book1D( + "n_sharing_branches", "Distinct Branches sharing >=10% of the object hits;#Branches;objects", 51, -0.5, 50.5); +} + +void BranchHGCalValidator::bookHistograms(DQMStore::IBooker& ib, edm::Run const&, edm::EventSetup const&) { + book(ib, caloParticlePlots_, "CaloParticle"); + book(ib, simClusterPlots_, "SimCluster"); +} + +namespace { + // logical-particle id <- SimTrack trackId, via the raw-graph node back-reference. + std::unordered_map buildTrackIdToParticle(truth::Graph const& graph, TruthGraph const& raw) { + std::unordered_map out; + out.reserve(graph.nParticles()); + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int32_t simNode = graph.particles()[i].simNode; + if (simNode < 0 || static_cast(simNode) >= raw.nNodes()) + continue; + auto const& nr = raw.nodeRef(static_cast(simNode)); + if (nr.kind == TruthGraph::NodeKind::SimTrack) + out[static_cast(nr.key)] = i; + } + return out; + } +} // namespace + +template +void BranchHGCalValidator::validate(Collection const& objects, + truth::Graph const& graph, + TruthGraph const& raw, + truth::LogicalGraphHitIndex const& hitIndex, + truth::BranchHitAssociator const& assoc, + std::unordered_map const& tidToParticle, + std::unordered_map const& cellSimEnergy, + std::unordered_map const& recHitEnergyByDetId, + Plots& plots) { + auto recoEnergyOf = [&recHitEnergyByDetId](uint32_t detId) -> double { + auto it = recHitEnergyByDetId.find(detId); + return it != recHitEnergyByDetId.end() ? static_cast(it->second) : 0.; + }; + for (auto const& obj : objects) { + if (obj.g4Tracks().empty()) + continue; + + const double eta = obj.eta(); + const double pt = obj.pt(); + const double energy = obj.energy(); + if (pt < minPt_ || std::abs(eta) > maxEta_) + continue; + + auto const& hitsAndFractions = obj.hits_and_fractions(); + if (hitsAndFractions.empty()) + continue; + + // Selected object: fills the efficiency denominator. + plots.denomEta->Fill(eta); + plots.denomPt->Fill(pt); + plots.denomEnergy->Fill(energy); + + const uint32_t trackId = obj.g4Tracks().front().trackId(); + auto it = tidToParticle.find(trackId); + if (it == tidToParticle.end()) + continue; // unmapped -> counts as inefficiency + const uint32_t particleId = it->second; + + // Branch subgraph calo hits for the mapped logical particle. branchEnergy is the + // total deposited (sim) energy; branchCellEnergy is its per-cell breakdown, used + // to restrict the raw response to the object's own footprint (a tiny object whose + // trackId maps to a large shower would otherwise blow up the un-thresholded sim + // ratio - the reco ratio stays finite only because the extra cells lack RecHits). + std::unordered_map branchCellEnergy; + double branchEnergy = 0.; + for (auto const& hit : hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, particleId)) { + branchCellEnergy[hit.detId] += hit.energy; + branchEnergy += hit.energy; + } + + std::vector recoHits; + recoHits.reserve(hitsAndFractions.size()); + uint32_t shared = 0; + double totalFraction = 0.; + double sharedFraction = 0.; + // Raw energy response references, all on the *object's* cells: the object's own + // deposited (sim) and reconstructed (rec) energy -- fraction-weighted, the standard + // CaloParticle/SimCluster convention -- and the Branch's energy on those same cells + // (its per-cell sim deposit; the whole-cell RecHit it claims). The sim and reco + // responses then differ only by the deposited-vs-reconstructed scale. + double objectSimEnergy = 0.; + double objectRecoEnergy = 0.; + double branchSimOnObject = 0.; + double branchRecoOnObject = 0.; + for (auto const& [detId, fraction] : hitsAndFractions) { + recoHits.push_back(truth::RecoHit{detId, 1.f, fraction}); + totalFraction += fraction; + if (auto cs = cellSimEnergy.find(detId); cs != cellSimEnergy.end()) + objectSimEnergy += static_cast(fraction) * static_cast(cs->second); + objectRecoEnergy += static_cast(fraction) * recoEnergyOf(detId); + if (auto be = branchCellEnergy.find(detId); be != branchCellEnergy.end()) { + ++shared; + sharedFraction += fraction; + branchSimOnObject += be->second; + branchRecoOnObject += recoEnergyOf(detId); + } + } + + const double completenessHits = static_cast(shared) / hitsAndFractions.size(); + const double purity = branchCellEnergy.empty() ? 0. : static_cast(shared) / branchCellEnergy.size(); + const double completenessEnergy = totalFraction > 0. ? sharedFraction / totalFraction : 0.; + // Energy containment: Branch subgraph sim-hit energy over the object energy. + // (CaloParticle::simEnergy() is not populated in these samples, so the + // gen-level energy is the reference; the ratio reflects the active-material + // sampling fraction and so varies by detector region.) + const double response = energy > 0. ? branchEnergy / energy : 0.; + + plots.purity->Fill(purity); + plots.completenessHits->Fill(completenessHits); + plots.completenessEnergy->Fill(completenessEnergy); + plots.purityVsEta->Fill(eta, purity); + plots.completenessVsEta->Fill(eta, completenessEnergy); + if (energy > 0.) { + plots.energyResponse->Fill(response); + plots.responseVsEta->Fill(eta, response); + plots.responseVsEnergy->Fill(energy, response); + } + + // Raw energy response: the Branch's energy on the object's footprint normalised + // by the object's own hit energy (rather than its generator energy), on the + // deposited and reconstructed scales. The deposited ratio is == 1 by construction + // (closure / PR-validation invariant); the reconstructed ratio is informative. + if (objectSimEnergy > 0.) { + const double rawSim = branchSimOnObject / objectSimEnergy; + plots.rawEnergyResponseSim->Fill(rawSim); + plots.rawResponseSimVsEnergy->Fill(energy, rawSim); + } + if (objectRecoEnergy > 0.) { + const double rawReco = branchRecoOnObject / objectRecoEnergy; + plots.rawEnergyResponseReco->Fill(rawReco); + plots.rawResponseRecoVsEnergy->Fill(energy, rawReco); + } + + // Reproduction efficiency numerator: the associator picks this particle's + // branch as the best (tightest among equally-best-scoring) hit match. + auto matches = assoc.bestBranches(std::span(recoHits)); + if (!matches.empty()) { + const float bestScore = matches.front().score; + uint32_t tightest = matches.front().rootParticleId; + std::size_t tightestSize = hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, tightest).size(); + for (auto const& m : matches) { + if (m.score > bestScore) + break; + const std::size_t size = hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, m.rootParticleId).size(); + if (size < tightestSize) { + tightestSize = size; + tightest = m.rootParticleId; + } + } + if (tightest == particleId) { + plots.effNumEta->Fill(eta); + plots.effNumPt->Fill(pt); + plots.effNumEnergy->Fill(energy); + } + + // --- "Other way around": the best hit-matched Branch's own performance. --- + // Self-match: the best Branch is the natural (trackId-seeded) one. + if (tightest == particleId) { + plots.selfMatchEta->Fill(eta); + plots.selfMatchPt->Fill(pt); + } + + // Merge/split: how many distinct Branches share >=10% of the object's hits. + // For the SharedHits metric, BranchMatch::sharedEnergy is the shared-cell count. + const double shareThreshold = 0.1 * static_cast(hitsAndFractions.size()); + uint32_t nSharing = 0; + for (auto const& m : matches) + if (static_cast(m.sharedEnergy) >= shareThreshold) + ++nSharing; + plots.nSharingBranches->Fill(std::min(nSharing, 50)); + + // Best Branch's purity / completeness / response vs the object. + std::unordered_set bestDetIds; + double bestBranchEnergy = 0.; + for (auto const& hit : hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, tightest)) { + bestDetIds.insert(hit.detId); + bestBranchEnergy += hit.energy; + } + uint32_t bestShared = 0; + double bestSharedFraction = 0.; + for (auto const& [detId, fraction] : hitsAndFractions) { + if (bestDetIds.count(detId)) { + ++bestShared; + bestSharedFraction += fraction; + } + } + plots.bestPurity->Fill(bestDetIds.empty() ? 0. : static_cast(bestShared) / bestDetIds.size()); + plots.bestCompletenessHits->Fill(static_cast(bestShared) / hitsAndFractions.size()); + plots.bestCompletenessEnergy->Fill(totalFraction > 0. ? bestSharedFraction / totalFraction : 0.); + if (energy > 0.) + plots.bestResponse->Fill(bestBranchEnergy / energy); + } + } +} + +std::unordered_map BranchHGCalValidator::collectRecHitEnergyByDetId(edm::Event const& event) const { + std::unordered_map energies; + for (uint32_t i = 0; i < hgcalRecHitTokens_.size(); ++i) { + edm::Handle handle; + event.getByToken(hgcalRecHitTokens_[i], handle); + if (!handle.isValid()) { + edm::LogWarning("BranchHGCalValidator") + << "Missing HGCRecHit collection " << hgcalRecHitTags_[i].encode() << "; skipping it."; + continue; + } + energies.reserve(energies.size() + handle->size()); + for (auto const& hit : *handle) + energies.emplace(hit.detid().rawId(), hit.energy()); // keep first for duplicate DetIds + } + for (uint32_t i = 0; i < pfRecHitTokens_.size(); ++i) { + edm::Handle handle; + event.getByToken(pfRecHitTokens_[i], handle); + if (!handle.isValid()) { + edm::LogWarning("BranchHGCalValidator") + << "Missing reco::PFRecHitCollection " << pfRecHitTags_[i].encode() << "; skipping it."; + continue; + } + energies.reserve(energies.size() + handle->size()); + for (auto const& hit : *handle) + energies.emplace(hit.detId(), hit.energy()); + } + return energies; +} + +void BranchHGCalValidator::analyze(edm::Event const& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& raw = event.get(rawToken_); + auto const& hitIndex = event.get(hitIndexToken_); + + const auto tidToParticle = buildTrackIdToParticle(graph, raw); + truth::BranchHitAssociator assoc(hitIndex, {}, truth::BranchHitAssociator::Metric::SharedHits); + + // Per-cell deposited (sim) energy = sum of every particle's direct HGCalCalo hits + // in that cell (each PCaloHit belongs to exactly one SimTrack), and per-cell + // reconstructed energy from the RecHit collections; both keyed by DetId so the + // raw response can normalise a Branch's energy by the object's own hit energy. + std::unordered_map cellSimEnergy; + for (uint32_t p = 0; p < hitIndex.nParticles(); ++p) + for (auto const& hit : hitIndex.directHits(truth::HitChannel::HGCalCalo, p)) + cellSimEnergy[hit.detId] += hit.energy; + const auto recHitEnergyByDetId = collectRecHitEnergyByDetId(event); + + validate(event.get(caloParticleToken_), + graph, + raw, + hitIndex, + assoc, + tidToParticle, + cellSimEnergy, + recHitEnergyByDetId, + caloParticlePlots_); + validate(event.get(simClusterToken_), + graph, + raw, + hitIndex, + assoc, + tidToParticle, + cellSimEnergy, + recHitEnergyByDetId, + simClusterPlots_); +} + +void BranchHGCalValidator::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("rawSrc", edm::InputTag("truthGraphProducer")); + desc.add("hitIndex", edm::InputTag("truthLogicalGraphHitIndexProducer")); + desc.add("caloParticles", edm::InputTag("mix", "MergedCaloTruth")); + desc.add("simClusters", edm::InputTag("mix", "MergedCaloTruth")); + desc.add("folder", "HGCAL/BranchValidator"); + desc.add("minPt", 1.0); + desc.add("maxEta", 3.0); + // RecHit collections for the raw (reconstructed) energy response, in the same + // order DetIdToRecHitMapProducer used to build the DetId->RecHit map. + desc.add>("hgcalRecHits", + {edm::InputTag("HGCalRecHit", "HGCEERecHits"), + edm::InputTag("HGCalRecHit", "HGCHEFRecHits"), + edm::InputTag("HGCalRecHit", "HGCHEBRecHits")}); + desc.add>("pfRecHits", + {edm::InputTag("particleFlowRecHitECAL", "Cleaned"), + edm::InputTag("particleFlowRecHitHBHE", "Cleaned"), + edm::InputTag("particleFlowRecHitHF", "Cleaned"), + edm::InputTag("particleFlowRecHitHO", "Cleaned")}); + descriptions.addWithDefaultLabel(desc); +} + +DEFINE_FWK_MODULE(BranchHGCalValidator); diff --git a/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc new file mode 100644 index 0000000000000..e9b1bac44bdcf --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BranchRecoValidator.cc @@ -0,0 +1,319 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Generic reco-side DQM validator: matches any reco collection to the truth Branch +// graph through shared detector hits and books MultiTrackValidator / HGCalValidator +// style efficiency / fake-rate / merge / duplicate plots plus a match-purity +// distribution. The reco object is reduced to a list of truth::RecoHit by the +// truth::recoHits adapters (RecoHitAdapters.h), so adding a new reco type is just a +// new adapter + a Traits policy - no change to the validation logic. Two concrete +// modules are instantiated from the one template: +// * BranchTrackRecoValidator - reco::Track, tracker channel, shared-hit +// multiplicity (no per-cell energy in the tracker). +// * BranchTracksterRecoValidator - ticl::Trackster, calo channel, shared energy. +// The truth side (efficiency denominator) is the set of "interesting" branches +// (interestingPdgIds, empty = all) that carry hits in the relevant channel and pass +// a kinematic selection; the harvester (DQMGenericClient) forms the ratios. + +#include +#include +#include +#include +#include + +#include "DQMServices/Core/interface/DQMEDAnalyzer.h" +#include "DQMServices/Core/interface/DQMStore.h" +#include "DQMServices/Core/interface/MonitorElement.h" + +#include "FWCore/Framework/interface/ConsumesCollector.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/CaloRecHit/interface/CaloCluster.h" +#include "DataFormats/Common/interface/View.h" +#include "DataFormats/HGCalReco/interface/Trackster.h" +#include "DataFormats/TrackReco/interface/Track.h" +#include "DataFormats/TrackReco/interface/TrackFwd.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "PhysicsTools/TruthInfo/interface/RecoHitAdapters.h" + +namespace { + // One reco object reduced to what the matcher needs: its hits + kinematics. The + // second kinematic axis x is p_T for tracks and (raw) energy for tracksters. + struct RecoObject { + std::vector hits; + double eta = 0.; + double x = 0.; + }; + + // --- Traits: per-reco-type EDM access, metric and kinematics policy. --- + + // reco::Track: tracker channel, shared-hit multiplicity, x = p_T. + class TrackRecoTraits { + public: + TrackRecoTraits(edm::ParameterSet const& cfg, edm::ConsumesCollector cc) + : token_(cc.consumes>(cfg.getParameter("recoCollection"))) {} + + std::vector objects(edm::Event const& event) const { + std::vector out; + auto const& tracks = event.get(token_); + out.reserve(tracks.size()); + for (auto const& t : tracks) + out.push_back(RecoObject{truth::recoHits(t), t.eta(), t.pt()}); + return out; + } + static constexpr truth::BranchHitAssociator::Metric metric() { + return truth::BranchHitAssociator::Metric::SharedHits; + } + static constexpr truth::HitChannel channel() { return truth::HitChannel::Tracker; } + static double particleX(math::XYZTLorentzVectorD const& p) { return p.pt(); } + static void fillDescriptions(edm::ParameterSetDescription& desc) { + desc.add("recoCollection", edm::InputTag("generalTracks")); + } + + private: + edm::EDGetTokenT> token_; + }; + + // ticl::Trackster: calo channel, shared energy (cell fractions), x = raw energy. + class TracksterRecoTraits { + public: + TracksterRecoTraits(edm::ParameterSet const& cfg, edm::ConsumesCollector cc) + : tracksterToken_(cc.consumes>(cfg.getParameter("recoCollection"))), + layerClusterToken_( + cc.consumes>(cfg.getParameter("layerClusters"))) {} + + std::vector objects(edm::Event const& event) const { + std::vector out; + auto const& tracksters = event.get(tracksterToken_); + auto const& layerClusters = event.get(layerClusterToken_); + out.reserve(tracksters.size()); + for (auto const& t : tracksters) + out.push_back(RecoObject{truth::recoHits(t, layerClusters), t.barycenter().eta(), t.raw_energy()}); + return out; + } + static constexpr truth::BranchHitAssociator::Metric metric() { + return truth::BranchHitAssociator::Metric::SharedEnergy; + } + static constexpr truth::HitChannel channel() { return truth::HitChannel::HGCalCalo; } + static double particleX(math::XYZTLorentzVectorD const& p) { return p.energy(); } + static void fillDescriptions(edm::ParameterSetDescription& desc) { + desc.add("recoCollection", edm::InputTag("ticlTrackstersCLUE3DHigh")); + desc.add("layerClusters", edm::InputTag("hgcalMergeLayerClusters")); + } + + private: + edm::EDGetTokenT> tracksterToken_; + edm::EDGetTokenT> layerClusterToken_; + }; +} // namespace + +template +class BranchRecoValidatorT : public DQMEDAnalyzer { +public: + explicit BranchRecoValidatorT(edm::ParameterSet const&); + void bookHistograms(DQMStore::IBooker&, edm::Run const&, edm::EventSetup const&) override; + void analyze(edm::Event const&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + [[nodiscard]] bool selected(double eta, double x) const { + return std::abs(eta) >= minAbsEta_ && std::abs(eta) <= maxAbsEta_ && x >= minX_; + } + // Per-object span hits in the relevant channel (calo subgraph or tracker subgraph). + [[nodiscard]] std::span channelHits( + truth::LogicalGraphHitIndex const& hitIndex, uint32_t root) const { + return hitIndex.subgraphHits(Traits::channel(), root); + } + + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT hitIndexToken_; + const std::vector interestingPdgIds_; + const std::string folder_; + const std::string xName_; + const std::string xTitle_; + const double xMax_; + const double minX_; + const double minAbsEta_; + const double maxAbsEta_; + const double matchThreshold_; + const double mergeThreshold_; + Traits traits_; + + // Truth (sim) side, vs eta and vs x. + MonitorElement* denomEta_ = nullptr; + MonitorElement* denomX_ = nullptr; + MonitorElement* effNumEta_ = nullptr; + MonitorElement* effNumX_ = nullptr; + MonitorElement* dupNumEta_ = nullptr; + MonitorElement* dupNumX_ = nullptr; + // Reco side, vs eta and vs x. + MonitorElement* recoDenomEta_ = nullptr; + MonitorElement* recoDenomX_ = nullptr; + MonitorElement* fakeNumEta_ = nullptr; + MonitorElement* fakeNumX_ = nullptr; + MonitorElement* mergeNumEta_ = nullptr; + MonitorElement* mergeNumX_ = nullptr; + MonitorElement* purity_ = nullptr; +}; + +template +BranchRecoValidatorT::BranchRecoValidatorT(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + interestingPdgIds_(cfg.getParameter>("interestingPdgIds")), + folder_(cfg.getParameter("folder")), + xName_(cfg.getParameter("xName")), + xTitle_(cfg.getParameter("xTitle")), + xMax_(cfg.getParameter("xMax")), + minX_(cfg.getParameter("minX")), + minAbsEta_(cfg.getParameter("minAbsEta")), + maxAbsEta_(cfg.getParameter("maxAbsEta")), + matchThreshold_(cfg.getParameter("matchThreshold")), + mergeThreshold_(cfg.getParameter("mergeThreshold")), + traits_(cfg, consumesCollector()) {} + +template +void BranchRecoValidatorT::bookHistograms(DQMStore::IBooker& ib, edm::Run const&, edm::EventSetup const&) { + ib.setCurrentFolder(folder_); + constexpr int kEtaBins = 40; + const double etaMax = maxAbsEta_ + 0.2; + constexpr int kXBins = 50; + const char* xt = xTitle_.c_str(); + + auto bookEta = [&](char const* n, char const* t) { return ib.book1D(n, t, kEtaBins, -etaMax, etaMax); }; + auto bookX = [&](std::string const& n, std::string const& t) { return ib.book1D(n, t, kXBins, 0., xMax_); }; + + denomEta_ = bookEta("denom_eta", "Selected branches vs #eta;#eta;branches"); + denomX_ = bookX("denom_" + xName_, std::string("Selected branches vs ") + xt + ";" + xt + ";branches"); + effNumEta_ = bookEta("effnum_eta", "Branches matched by reco vs #eta;#eta;branches"); + effNumX_ = bookX("effnum_" + xName_, std::string("Branches matched by reco vs ") + xt + ";" + xt + ";branches"); + dupNumEta_ = bookEta("dupnum_eta", "Branches matched by >1 reco vs #eta;#eta;branches"); + dupNumX_ = bookX("dupnum_" + xName_, std::string("Branches matched by >1 reco vs ") + xt + ";" + xt + ";branches"); + + recoDenomEta_ = bookEta("recodenom_eta", "Reco objects vs #eta;#eta;reco objects"); + recoDenomX_ = bookX("recodenom_" + xName_, std::string("Reco objects vs ") + xt + ";" + xt + ";reco objects"); + fakeNumEta_ = bookEta("fakenum_eta", "Unmatched (fake) reco vs #eta;#eta;reco objects"); + fakeNumX_ = bookX("fakenum_" + xName_, std::string("Unmatched (fake) reco vs ") + xt + ";" + xt + ";reco objects"); + mergeNumEta_ = bookEta("mergenum_eta", "Reco matched to >1 branch vs #eta;#eta;reco objects"); + mergeNumX_ = + bookX("mergenum_" + xName_, std::string("Reco matched to >1 branch vs ") + xt + ";" + xt + ";reco objects"); + + purity_ = ib.book1D("purity", "Best-branch match purity;purity;reco objects", 52, -0.01, 1.03); +} + +template +void BranchRecoValidatorT::analyze(edm::Event const& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& hitIndex = event.get(hitIndexToken_); + + // Candidate / associator roots = the interesting particles (empty config -> all). + std::vector roots; + if (!interestingPdgIds_.empty()) { + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int pdgId = graph.particles()[i].pdgId; + if (std::find(interestingPdgIds_.begin(), interestingPdgIds_.end(), pdgId) != interestingPdgIds_.end()) + roots.push_back(i); + } + } + truth::BranchHitAssociator assoc(hitIndex, roots, Traits::metric(), Traits::channel()); + + // Reco -> sim: match every reco object, accumulate per-branch match multiplicity. + std::unordered_map recoMatchCount; + for (auto const& obj : traits_.objects(event)) { + if (obj.hits.empty() || !selected(obj.eta, obj.x)) + continue; + recoDenomEta_->Fill(obj.eta); + recoDenomX_->Fill(obj.x); + + double objWeight = 0.; + for (auto const& h : obj.hits) + objWeight += static_cast(h.fraction) * h.energy; + if (objWeight <= 0.) + objWeight = 1.; + + auto matches = assoc.bestBranches(std::span(obj.hits)); + const double bestPurity = matches.empty() ? 0. : matches.front().sharedEnergy / objWeight; + purity_->Fill(bestPurity); + + int sharedBranches = 0; + for (auto const& m : matches) + if (m.sharedEnergy / objWeight >= mergeThreshold_) + ++sharedBranches; + if (sharedBranches >= 2) { + mergeNumEta_->Fill(obj.eta); + mergeNumX_->Fill(obj.x); + } + + if (bestPurity >= matchThreshold_) + ++recoMatchCount[matches.front().rootParticleId]; + else { + fakeNumEta_->Fill(obj.eta); + fakeNumX_->Fill(obj.x); + } + } + + // Sim -> reco: efficiency / duplicate over the selected branches that carry hits. + const uint32_t nP = graph.nParticles(); + auto considerRoot = [&](uint32_t r) { + if (interestingPdgIds_.empty()) + return true; + return std::find(interestingPdgIds_.begin(), interestingPdgIds_.end(), graph.particles()[r].pdgId) != + interestingPdgIds_.end(); + }; + for (uint32_t r = 0; r < nP; ++r) { + if (!considerRoot(r) || channelHits(hitIndex, r).empty()) + continue; + auto const& p = graph.particles()[r].momentum; + const double eta = p.eta(); + const double x = Traits::particleX(p); + if (!selected(eta, x)) + continue; + denomEta_->Fill(eta); + denomX_->Fill(x); + auto it = recoMatchCount.find(r); + const int n = it != recoMatchCount.end() ? it->second : 0; + if (n >= 1) { + effNumEta_->Fill(eta); + effNumX_->Fill(x); + } + if (n >= 2) { + dupNumEta_->Fill(eta); + dupNumX_->Fill(x); + } + } +} + +template +void BranchRecoValidatorT::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("hitIndex", edm::InputTag("truthLogicalGraphHitIndexProducer")); + desc.add>("interestingPdgIds", {}) + ->setComment("Restrict the branch side to these PDG ids (empty = all branches)."); + desc.add("folder", "BranchValidator/Reco"); + desc.add("xName", "pt")->setComment("Second-axis ME name suffix (e.g. pt or energy)."); + desc.add("xTitle", "p_{T} [GeV]"); + desc.add("xMax", 200.); + desc.add("minX", 0.); + desc.add("minAbsEta", 0.); + desc.add("maxAbsEta", 3.0); + desc.add("matchThreshold", 0.5)->setComment("Min best-branch purity for a reco object to count as matched."); + desc.add("mergeThreshold", 0.3) + ->setComment("Min shared fraction for a branch to count toward a merge (>=2 -> merged reco object)."); + Traits::fillDescriptions(desc); + descriptions.addWithDefaultLabel(desc); +} + +using BranchTrackRecoValidator = BranchRecoValidatorT; +using BranchTracksterRecoValidator = BranchRecoValidatorT; +DEFINE_FWK_MODULE(BranchTrackRecoValidator); +DEFINE_FWK_MODULE(BranchTracksterRecoValidator); diff --git a/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc new file mode 100644 index 0000000000000..7d978a082622b --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BranchTrackerReplacementValidator.cc @@ -0,0 +1,166 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Validates that a truth::Branch can stand in for a TrackingParticle for +// track<->truth association. For each reco track it (a) matches the track to a +// Branch via shared tracker hits (our PSimHit-based tracker hit index, DetId +// keyed) and (b) matches it to a TrackingParticle via the existing +// ClusterTPAssociation (cluster->TP). If the Branch and the TP correspond to the +// same truth particle, the Branch reproduces the TP-based association. + +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/one/EDAnalyzer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" + +#include "DataFormats/TrackReco/interface/Track.h" +#include "DataFormats/TrackReco/interface/TrackFwd.h" +#include "SimDataFormats/TrackingAnalysis/interface/TrackingParticle.h" +#include "SimTracker/TrackAssociation/interface/trackHitsToClusterRefs.h" +#include "SimTracker/TrackerHitAssociation/interface/ClusterTPAssociation.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +class BranchTrackerReplacementValidator : public edm::one::EDAnalyzer<> { +public: + explicit BranchTrackerReplacementValidator(edm::ParameterSet const&); + void analyze(edm::Event const&, edm::EventSetup const&) override; + void endJob() override; + +private: + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT rawToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> trackToken_; + const edm::EDGetTokenT clusterTPToken_; + + uint64_t nTracks_ = 0; + uint64_t nBothMatched_ = 0; + uint64_t nAgree_ = 0; + uint64_t nBranchOnly_ = 0; + uint64_t nTPOnly_ = 0; +}; + +BranchTrackerReplacementValidator::BranchTrackerReplacementValidator(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + rawToken_(consumes(cfg.getParameter("rawSrc"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + trackToken_(consumes>(cfg.getParameter("tracks"))), + clusterTPToken_(consumes(cfg.getParameter("clusterTPMap"))) {} + +namespace { + std::unordered_map buildTrackIdToParticle(truth::Graph const& graph, TruthGraph const& raw) { + std::unordered_map out; + out.reserve(graph.nParticles()); + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int32_t simNode = graph.particles()[i].simNode; + if (simNode < 0 || static_cast(simNode) >= raw.nNodes()) + continue; + auto const& nr = raw.nodeRef(static_cast(simNode)); + if (nr.kind == TruthGraph::NodeKind::SimTrack) + out[static_cast(nr.key)] = i; + } + return out; + } + + // tightest (smallest tracker subgraph) among the best-scoring matches. + int tightestBest(std::vector const& matches, truth::LogicalGraphHitIndex const& hitIndex) { + if (matches.empty()) + return -1; + const float best = matches.front().score; + int tightest = static_cast(matches.front().rootParticleId); + std::size_t tightestSize = hitIndex.subgraphHits(truth::HitChannel::Tracker, matches.front().rootParticleId).size(); + for (auto const& m : matches) { + if (m.score > best) + break; + const std::size_t size = hitIndex.subgraphHits(truth::HitChannel::Tracker, m.rootParticleId).size(); + if (size < tightestSize) { + tightestSize = size; + tightest = static_cast(m.rootParticleId); + } + } + return tightest; + } +} // namespace + +void BranchTrackerReplacementValidator::analyze(edm::Event const& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& raw = event.get(rawToken_); + auto const& hitIndex = event.get(hitIndexToken_); + auto const& tracks = event.get(trackToken_); + auto const& clusterTP = event.get(clusterTPToken_); + + const auto tidToParticle = buildTrackIdToParticle(graph, raw); + truth::BranchHitAssociator assoc( + hitIndex, {}, truth::BranchHitAssociator::Metric::SharedHits, truth::HitChannel::Tracker); + + for (auto const& track : tracks) { + ++nTracks_; + + // Branch side: reco-track rechit DetIds -> best tracker branch. + std::vector trackHits; + for (auto it = track.recHitsBegin(); it != track.recHitsEnd(); ++it) { + const TrackingRecHit* hit = &(**it); + if (hit->isValid()) + trackHits.push_back(truth::RecoHit{hit->geographicalId().rawId(), 1.f, 1.f}); + } + int branchParticle = -1; + if (!trackHits.empty()) + branchParticle = tightestBest(assoc.bestBranches(std::span(trackHits)), hitIndex); + + // TP side: shared clusters via ClusterTPAssociation -> dominant TP -> particle. + auto clusters = track_associator::hitsToClusterRefs(track.recHitsBegin(), track.recHitsEnd()); + std::unordered_map tpClusters; + std::unordered_map tpTrackId; + for (auto const& omni : clusters) { + auto range = clusterTP.equal_range(omni); + for (auto i = range.first; i != range.second; ++i) { + const auto& tpRef = i->second; + const uint32_t key = tpRef.key(); + ++tpClusters[key]; + if (!tpTrackId.count(key) && !tpRef->g4Tracks().empty()) + tpTrackId[key] = tpRef->g4Tracks().front().trackId(); + } + } + int expectedParticle = -1; + int bestShared = 0; + for (auto const& [key, count] : tpClusters) { + if (count > bestShared) { + bestShared = count; + auto tit = tidToParticle.find(tpTrackId[key]); + expectedParticle = tit != tidToParticle.end() ? static_cast(tit->second) : -1; + } + } + + if (branchParticle >= 0 && expectedParticle >= 0) { + ++nBothMatched_; + if (branchParticle == expectedParticle) + ++nAgree_; + } else if (branchParticle >= 0) { + ++nBranchOnly_; + } else if (expectedParticle >= 0) { + ++nTPOnly_; + } + } +} + +void BranchTrackerReplacementValidator::endJob() { + const double agree = nBothMatched_ > 0 ? static_cast(nAgree_) / nBothMatched_ : 0.0; + edm::LogPrint("BranchTrackerReplacementValidator") + << "=== Branch vs TrackingParticle (track->truth) replacement ===\n" + << "tracks=" << nTracks_ << " bothMatched=" << nBothMatched_ << " branchOnly=" << nBranchOnly_ + << " tpOnly=" << nTPOnly_ << " | Branch-TP agreement=" << agree; +} + +DEFINE_FWK_MODULE(BranchTrackerReplacementValidator); diff --git a/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc new file mode 100644 index 0000000000000..acb145877c70b --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BranchTrackingValidator.cc @@ -0,0 +1,279 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// DQM performance plots for the truth::Branch graph as a replacement for the +// TrackingParticle in track->truth association - the tracker counterpart of +// BranchHGCalValidator. A TrackingParticle has no hits of its own, so the +// comparison is mediated by the reco track: for each reco track it (a) matches the +// track to a branch through the tracker simhit index (shared DetIds) and (b) +// matches it to a TrackingParticle through the standard ClusterTPAssociation, then +// maps that TP back to its logical particle via the SimTrack trackId. The branch +// reproduces the TP-based assignment when both point at the same logical particle. +// The booked numerator/denominator (TP-matched tracks vs Branch-and-TP-agree) are +// turned into a "reproduction efficiency vs eta/pt" by the harvester +// (DQMGenericClient); the shared-hit completeness is booked directly. This is the +// DQM form of BranchTrackerReplacementValidator and sits alongside the standard +// tracking validation so the two truth descriptions can be compared. + +#include +#include +#include +#include +#include +#include + +#include "DQMServices/Core/interface/DQMEDAnalyzer.h" +#include "DQMServices/Core/interface/DQMStore.h" +#include "DQMServices/Core/interface/MonitorElement.h" + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/Common/interface/View.h" +#include "DataFormats/TrackReco/interface/Track.h" +#include "DataFormats/TrackReco/interface/TrackFwd.h" +#include "SimDataFormats/TrackingAnalysis/interface/TrackingParticle.h" +#include "SimTracker/TrackAssociation/interface/trackHitsToClusterRefs.h" +#include "SimTracker/TrackerHitAssociation/interface/ClusterTPAssociation.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +class BranchTrackingValidator : public DQMEDAnalyzer { +public: + explicit BranchTrackingValidator(edm::ParameterSet const&); + void bookHistograms(DQMStore::IBooker&, edm::Run const&, edm::EventSetup const&) override; + void analyze(edm::Event const&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + struct Plots { + // Numerator/denominator for the harvester-computed reproduction efficiency. + MonitorElement* denomEta = nullptr; + MonitorElement* denomPt = nullptr; + MonitorElement* effNumEta = nullptr; + MonitorElement* effNumPt = nullptr; + // Quality distributions for the best branch match. (For tracking the best-match + // Branch performance is exactly these, and the self-match rate is the efficiency + // above: effnum = best Branch is the TrackingParticle's natural Branch.) + MonitorElement* completenessHits = nullptr; + MonitorElement* sharedHits = nullptr; + MonitorElement* completenessVsEta = nullptr; + MonitorElement* completenessVsPt = nullptr; + // Merge/split: distinct Branches sharing >=10% of the track's hits. + MonitorElement* nSharingBranches = nullptr; + }; + + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT rawToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> trackToken_; + const edm::EDGetTokenT clusterTPToken_; + + const std::string folder_; + const double minPt_; + const double maxEta_; + + Plots plots_; +}; + +BranchTrackingValidator::BranchTrackingValidator(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + rawToken_(consumes(cfg.getParameter("rawSrc"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + trackToken_(consumes>(cfg.getParameter("tracks"))), + clusterTPToken_(consumes(cfg.getParameter("clusterTPMap"))), + folder_(cfg.getParameter("folder")), + minPt_(cfg.getParameter("minPt")), + maxEta_(cfg.getParameter("maxEta")) {} + +void BranchTrackingValidator::bookHistograms(DQMStore::IBooker& ib, edm::Run const&, edm::EventSetup const&) { + ib.setCurrentFolder(folder_ + "/TrackingParticle"); + + constexpr int kEtaBins = 40; + constexpr double kEtaMax = 3.2; + constexpr int kPtBins = 50; + constexpr double kPtMax = 200.; + + plots_.denomEta = ib.book1D("denom_eta", "TP-matched tracks vs #eta;#eta;tracks", kEtaBins, -kEtaMax, kEtaMax); + plots_.denomPt = ib.book1D("denom_pt", "TP-matched tracks vs p_{T};p_{T} [GeV];tracks", kPtBins, 0., kPtMax); + plots_.effNumEta = + ib.book1D("effnum_eta", "Branch-reproduced TP assignment vs #eta;#eta;tracks", kEtaBins, -kEtaMax, kEtaMax); + plots_.effNumPt = + ib.book1D("effnum_pt", "Branch-reproduced TP assignment vs p_{T};p_{T} [GeV];tracks", kPtBins, 0., kPtMax); + + plots_.completenessHits = + ib.book1D("completeness_hits", "Branch shared-hit completeness;shared hits / track hits;tracks", 52, -0.01, 1.03); + plots_.sharedHits = ib.book1D("shared_hits", "Branch shared tracker hits;shared hits;tracks", 40, 0., 40.); + plots_.completenessVsEta = ib.bookProfile("completeness_vs_eta", + "Branch shared-hit completeness vs #eta;#eta;completeness", + kEtaBins, + -kEtaMax, + kEtaMax, + 0., + 1.05); + plots_.completenessVsPt = ib.bookProfile("completeness_vs_pt", + "Branch shared-hit completeness vs p_{T};p_{T} [GeV];completeness", + kPtBins, + 0., + kPtMax, + 0., + 1.05); + plots_.nSharingBranches = ib.book1D( + "n_sharing_branches", "Distinct Branches sharing >=10% of the track hits;#Branches;tracks", 51, -0.5, 50.5); +} + +namespace { + // logical-particle id <- SimTrack trackId, via the raw-graph node back-reference. + std::unordered_map buildTrackIdToParticle(truth::Graph const& graph, TruthGraph const& raw) { + std::unordered_map out; + out.reserve(graph.nParticles()); + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int32_t simNode = graph.particles()[i].simNode; + if (simNode < 0 || static_cast(simNode) >= raw.nNodes()) + continue; + auto const& nr = raw.nodeRef(static_cast(simNode)); + if (nr.kind == TruthGraph::NodeKind::SimTrack) + out[static_cast(nr.key)] = i; + } + return out; + } + + // tightest (smallest tracker subgraph) among the best-scoring matches, and its + // shared-hit count. Returns {-1, 0} when there is no match. + struct BestMatch { + int particle = -1; + uint32_t sharedHits = 0; + }; + + BestMatch tightestBest(std::vector const& matches, truth::LogicalGraphHitIndex const& hitIndex) { + if (matches.empty()) + return {}; + const float best = matches.front().score; + BestMatch out{static_cast(matches.front().rootParticleId), + static_cast(matches.front().sharedEnergy)}; + std::size_t tightestSize = hitIndex.subgraphHits(truth::HitChannel::Tracker, matches.front().rootParticleId).size(); + for (auto const& m : matches) { + if (m.score > best) + break; + const std::size_t size = hitIndex.subgraphHits(truth::HitChannel::Tracker, m.rootParticleId).size(); + if (size < tightestSize) { + tightestSize = size; + out.particle = static_cast(m.rootParticleId); + out.sharedHits = static_cast(m.sharedEnergy); + } + } + return out; + } +} // namespace + +void BranchTrackingValidator::analyze(edm::Event const& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& raw = event.get(rawToken_); + auto const& hitIndex = event.get(hitIndexToken_); + auto const& tracks = event.get(trackToken_); + auto const& clusterTP = event.get(clusterTPToken_); + + const auto tidToParticle = buildTrackIdToParticle(graph, raw); + truth::BranchHitAssociator assoc( + hitIndex, {}, truth::BranchHitAssociator::Metric::SharedHits, truth::HitChannel::Tracker); + + for (auto const& track : tracks) { + const double eta = track.eta(); + const double pt = track.pt(); + if (pt < minPt_ || std::abs(eta) > maxEta_) + continue; + + // Branch side: reco-track rechit DetIds -> best (tightest) tracker branch. + std::vector trackHits; + uint32_t nTrackHits = 0; + for (auto it = track.recHitsBegin(); it != track.recHitsEnd(); ++it) { + const TrackingRecHit* hit = &(**it); + if (hit->isValid()) { + trackHits.push_back(truth::RecoHit{hit->geographicalId().rawId(), 1.f, 1.f}); + ++nTrackHits; + } + } + BestMatch branch; + std::vector matches; + if (!trackHits.empty()) { + matches = assoc.bestBranches(std::span(trackHits)); + branch = tightestBest(matches, hitIndex); + } + + // TP side: shared clusters via ClusterTPAssociation -> dominant TP -> particle. + auto clusters = track_associator::hitsToClusterRefs(track.recHitsBegin(), track.recHitsEnd()); + std::unordered_map tpClusters; + std::unordered_map tpTrackId; + for (auto const& omni : clusters) { + auto range = clusterTP.equal_range(omni); + for (auto i = range.first; i != range.second; ++i) { + const auto& tpRef = i->second; + const uint32_t key = tpRef.key(); + ++tpClusters[key]; + if (!tpTrackId.count(key) && !tpRef->g4Tracks().empty()) + tpTrackId[key] = tpRef->g4Tracks().front().trackId(); + } + } + int expectedParticle = -1; + int bestShared = 0; + for (auto const& [key, count] : tpClusters) { + if (count > bestShared) { + bestShared = count; + auto tit = tidToParticle.find(tpTrackId[key]); + expectedParticle = tit != tidToParticle.end() ? static_cast(tit->second) : -1; + } + } + + // The TrackingParticle assignment is the reference the Branch should reproduce. + if (expectedParticle < 0) + continue; + plots_.denomEta->Fill(eta); + plots_.denomPt->Fill(pt); + + if (branch.particle >= 0 && nTrackHits > 0) { + const double completeness = static_cast(branch.sharedHits) / nTrackHits; + plots_.completenessHits->Fill(completeness); + plots_.sharedHits->Fill(branch.sharedHits); + plots_.completenessVsEta->Fill(eta, completeness); + plots_.completenessVsPt->Fill(pt, completeness); + } + + // Merge/split: distinct Branches sharing >=10% of the track's hits. + if (nTrackHits > 0) { + const double shareThreshold = 0.1 * static_cast(nTrackHits); + uint32_t nSharing = 0; + for (auto const& m : matches) + if (static_cast(m.sharedEnergy) >= shareThreshold) + ++nSharing; + plots_.nSharingBranches->Fill(std::min(nSharing, 50)); + } + + if (branch.particle == expectedParticle) { + plots_.effNumEta->Fill(eta); + plots_.effNumPt->Fill(pt); + } + } +} + +void BranchTrackingValidator::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("rawSrc", edm::InputTag("truthGraphProducer")); + desc.add("hitIndex", edm::InputTag("truthLogicalGraphHitIndexProducer")); + desc.add("tracks", edm::InputTag("generalTracks")); + desc.add("clusterTPMap", edm::InputTag("tpClusterProducer")); + desc.add("folder", "Tracking/BranchValidator"); + desc.add("minPt", 0.9); + desc.add("maxEta", 3.0); + descriptions.addWithDefaultLabel(desc); +} + +DEFINE_FWK_MODULE(BranchTrackingValidator); diff --git a/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc b/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc new file mode 100644 index 0000000000000..c20afe1200511 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BranchTruthReplacementValidator.cc @@ -0,0 +1,202 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Validates that a truth::Branch can stand in for the legacy calo truth objects +// (CaloParticle, SimCluster): for each legacy object it maps the object to its +// logical particle (via the SimTrack trackId), compares the Branch's subgraph +// calo hits to the object's hits_and_fractions (completeness/purity), and checks +// that the generic BranchHitAssociator picks that same Branch as the best match. +// A high completeness + correct best-match rate means the Branch reproduces the +// legacy object and can replace it. + +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/one/EDAnalyzer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" + +#include "SimDataFormats/CaloAnalysis/interface/CaloParticle.h" +#include "SimDataFormats/CaloAnalysis/interface/CaloParticleFwd.h" +#include "SimDataFormats/CaloAnalysis/interface/SimCluster.h" +#include "SimDataFormats/CaloAnalysis/interface/SimClusterFwd.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +class BranchTruthReplacementValidator : public edm::one::EDAnalyzer<> { +public: + explicit BranchTruthReplacementValidator(edm::ParameterSet const&); + void analyze(edm::Event const&, edm::EventSetup const&) override; + void endJob() override; + +private: + struct Stats { + uint64_t n = 0; + uint64_t unmapped = 0; + uint64_t bestMatchCorrect = 0; + double sumCompletenessHits = 0.; + double sumPurityHits = 0.; + double sumCompletenessEnergy = 0.; + void print(const char* name) const; + }; + + template + void validate(Collection const& objects, + truth::Graph const& graph, + TruthGraph const& raw, + truth::LogicalGraphHitIndex const& hitIndex, + truth::BranchHitAssociator const& assoc, + std::unordered_map const& tidToParticle, + Stats& stats); + + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT rawToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> caloParticleToken_; + const edm::EDGetTokenT> simClusterToken_; + + Stats caloParticleStats_; + Stats simClusterStats_; +}; + +BranchTruthReplacementValidator::BranchTruthReplacementValidator(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + rawToken_(consumes(cfg.getParameter("rawSrc"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + caloParticleToken_(consumes>(cfg.getParameter("caloParticles"))), + simClusterToken_(consumes>(cfg.getParameter("simClusters"))) {} + +namespace { + // logical-particle id <- SimTrack trackId, via the raw-graph node back-reference. + std::unordered_map buildTrackIdToParticle(truth::Graph const& graph, TruthGraph const& raw) { + std::unordered_map out; + out.reserve(graph.nParticles()); + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int32_t simNode = graph.particles()[i].simNode; + if (simNode < 0 || static_cast(simNode) >= raw.nNodes()) + continue; + auto const& nr = raw.nodeRef(static_cast(simNode)); + if (nr.kind == TruthGraph::NodeKind::SimTrack) + out[static_cast(nr.key)] = i; + } + return out; + } +} // namespace + +template +void BranchTruthReplacementValidator::validate(Collection const& objects, + truth::Graph const& graph, + TruthGraph const& raw, + truth::LogicalGraphHitIndex const& hitIndex, + truth::BranchHitAssociator const& assoc, + std::unordered_map const& tidToParticle, + Stats& stats) { + for (auto const& obj : objects) { + if (obj.g4Tracks().empty()) + continue; + ++stats.n; + + const uint32_t trackId = obj.g4Tracks().front().trackId(); + auto it = tidToParticle.find(trackId); + if (it == tidToParticle.end()) { + ++stats.unmapped; + continue; + } + const uint32_t particleId = it->second; + + // Branch subgraph calo hits for the mapped logical particle. + std::unordered_set branchDetIds; + for (auto const& hit : hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, particleId)) + branchDetIds.insert(hit.detId); + + // Legacy object hits, and the reco-like hit list for the matcher. + auto hitsAndFractions = obj.hits_and_fractions(); + std::vector recoHits; + recoHits.reserve(hitsAndFractions.size()); + uint32_t shared = 0; + double totalFraction = 0.; + double sharedFraction = 0.; + for (auto const& [detId, fraction] : hitsAndFractions) { + recoHits.push_back(truth::RecoHit{detId, 1.f, fraction}); + totalFraction += fraction; + if (branchDetIds.count(detId)) { + ++shared; + sharedFraction += fraction; + } + } + if (hitsAndFractions.empty()) + continue; + + stats.sumCompletenessHits += static_cast(shared) / hitsAndFractions.size(); + stats.sumPurityHits += branchDetIds.empty() ? 0. : static_cast(shared) / branchDetIds.size(); + stats.sumCompletenessEnergy += totalFraction > 0. ? sharedFraction / totalFraction : 0.; + + // Does the associator identify this particle's branch? Many ancestor + // branches fully contain the object (same best score), so among the + // best-scoring matches pick the tightest (smallest subgraph) -- that is the + // particle itself, not a broader ancestor. + auto matches = assoc.bestBranches(std::span(recoHits)); + if (!matches.empty()) { + const float bestScore = matches.front().score; + uint32_t tightest = matches.front().rootParticleId; + std::size_t tightestSize = hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, tightest).size(); + for (auto const& m : matches) { + if (m.score > bestScore) + break; + const std::size_t size = hitIndex.subgraphHits(truth::HitChannel::HGCalCalo, m.rootParticleId).size(); + if (size < tightestSize) { + tightestSize = size; + tightest = m.rootParticleId; + } + } + if (tightest == particleId) + ++stats.bestMatchCorrect; + } + } +} + +void BranchTruthReplacementValidator::analyze(edm::Event const& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& raw = event.get(rawToken_); + auto const& hitIndex = event.get(hitIndexToken_); + + const auto tidToParticle = buildTrackIdToParticle(graph, raw); + + // SharedHits metric: best branch = the one sharing the most calo cells. + truth::BranchHitAssociator assoc(hitIndex, {}, truth::BranchHitAssociator::Metric::SharedHits); + + validate(event.get(caloParticleToken_), graph, raw, hitIndex, assoc, tidToParticle, caloParticleStats_); + validate(event.get(simClusterToken_), graph, raw, hitIndex, assoc, tidToParticle, simClusterStats_); +} + +void BranchTruthReplacementValidator::Stats::print(const char* name) const { + if (n == 0) { + edm::LogPrint("BranchTruthReplacementValidator") << name << ": no objects"; + return; + } + const uint64_t mapped = n - unmapped; + const double inv = mapped > 0 ? 1.0 / static_cast(mapped) : 0.0; + edm::LogPrint("BranchTruthReplacementValidator") + << name << ": N=" << n << " mapped=" << mapped << " unmapped=" << unmapped + << " | mean hit-completeness=" << sumCompletenessHits * inv + << " mean energy-completeness=" << sumCompletenessEnergy * inv << " mean purity=" << sumPurityHits * inv + << " | best-branch-correct=" << (mapped > 0 ? static_cast(bestMatchCorrect) / mapped : 0.0); +} + +void BranchTruthReplacementValidator::endJob() { + edm::LogPrint("BranchTruthReplacementValidator") << "=== Branch vs legacy calo truth replacement ==="; + caloParticleStats_.print("CaloParticle"); + simClusterStats_.print("SimCluster"); +} + +DEFINE_FWK_MODULE(BranchTruthReplacementValidator); diff --git a/PhysicsTools/TruthInfo/plugins/BuildFile.xml b/PhysicsTools/TruthInfo/plugins/BuildFile.xml new file mode 100644 index 0000000000000..bfac713ab153e --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/BuildFile.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc b/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc new file mode 100644 index 0000000000000..9bc8e4bf7ae2d --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/LogicalGraphHitIndexProducer.cc @@ -0,0 +1,586 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/global/EDProducer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/EDGetToken.h" +#include "FWCore/Utilities/interface/InputTag.h" + +#include "DataFormats/DetId/interface/DetId.h" +#include "DataFormats/ForwardDetId/interface/HGCalDetId.h" +#include "Geometry/CaloGeometry/interface/CaloGeometry.h" +#include "Geometry/HGCalGeometry/interface/HGCalGeometry.h" +#include "Geometry/HcalCommonData/interface/HcalHitRelabeller.h" +#include "Geometry/HcalTowerAlgo/interface/HcalGeometry.h" +#include "Geometry/Records/interface/CaloGeometryRecord.h" +#include "SimDataFormats/CaloHit/interface/PCaloHit.h" +#include "SimDataFormats/CaloTest/interface/HGCalTestNumbering.h" +#include "SimDataFormats/TrackingHit/interface/PSimHitContainer.h" +#include "SimDataFormats/CaloAnalysis/interface/MtdSimLayerClusterFwd.h" +#include "SimDataFormats/Associations/interface/MtdSimLayerClusterToRecoClusterAssociationMap.h" +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" +#include "DataFormats/FTLRecHit/interface/FTLClusterCollections.h" + +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +#include "SimCalorimetry/HGCalAssociatorProducers/interface/DetIdRecHitMap.h" + +namespace { + + struct LogicalGraphView { + explicit LogicalGraphView(truth::Graph const& graph) : graph_(graph) {} + + uint32_t nParticles() const { return graph_.nParticles(); } + + bool particleHasSim(uint32_t particleId) const { + return particleId < graph_.particles().size() && graph_.particles()[particleId].hasSim(); + } + + int32_t particleSimNode(uint32_t particleId) const { return graph_.particles()[particleId].simNode; } + + template + void forEachParticleChild(uint32_t parentParticleId, F&& f) const { + if (parentParticleId >= graph_.nParticles()) + return; + + for (const uint32_t vertexId : graph_.decayVertices(parentParticleId)) { + if (vertexId >= graph_.nVertices()) + continue; + + for (const uint32_t childId : graph_.outgoingParticles(vertexId)) { + f(childId); + } + } + } + + truth::Graph const& graph_; + }; + + uint32_t checkedTrackId(int64_t key) { + if (key < 0 || key > static_cast(std::numeric_limits::max())) + return 0; + + return static_cast(key); + } + + // Map a config channel name to its HitChannel; false if unknown. + bool channelFromName(std::string const& name, truth::HitChannel& out) { + if (name == "HGCalCalo") { + out = truth::HitChannel::HGCalCalo; + return true; + } + if (name == "Tracker") { + out = truth::HitChannel::Tracker; + return true; + } + if (name == "MTD") { + out = truth::HitChannel::MTD; + return true; + } + if (name == "Muon") { + out = truth::HitChannel::Muon; + return true; + } + return false; + } + + bool inputTagLooksLikeHGCal(edm::InputTag const& tag) { + const std::string& instance = tag.instance(); + return instance.find("HGCHits") != std::string::npos || instance.find("HGCEE") != std::string::npos || + instance.find("HGCHE") != std::string::npos; + } + + bool inputTagLooksLikeHcal(edm::InputTag const& tag) { + const std::string& instance = tag.instance(); + return instance.find("HcalHits") != std::string::npos || instance.find("Hcal") != std::string::npos; + } + + struct RelabelContext { + int geometryType = -1; + + std::array hgTopologies = {nullptr, nullptr, nullptr}; + std::array hgConstants = {nullptr, nullptr, nullptr}; + + HcalDDDRecConstants const* hcalConstants = nullptr; + }; + +} // namespace + +class TruthLogicalGraphHitIndexProducer : public edm::global::EDProducer<> { +public: + explicit TruthLogicalGraphHitIndexProducer(edm::ParameterSet const& cfg); + ~TruthLogicalGraphHitIndexProducer() override = default; + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions); + +private: + void produce(edm::StreamID, edm::Event&, edm::EventSetup const&) const override; + + void fillTrackToParticleMap(LogicalGraphView const& graph, + TruthGraph const& rawGraph, + truth::LogicalGraphHitIndexBuilder& builder) const; + + void fillSimHits(edm::Event& event, + edm::EventSetup const& setup, + truth::LogicalGraphHitIndexBuilder& builder, + hgcal::DetIdRecHitMap const* recHitMap) const; + + void fillTrackerSimHits(edm::Event& event, truth::LogicalGraphHitIndexBuilder& builder) const; + + // Muon chambers (DT/CSC/RPC/GEM/ME0): PSimHits keyed by trackId, like the tracker + // channel (energy = energyLoss, no recHit link). + void fillMuonSimHits(edm::Event& event, truth::LogicalGraphHitIndexBuilder& builder) const; + + // MTD (BTL/ETL): fill the MTD channel from the trackId-keyed MtdSimLayerClusters, + // restricted to the signal interaction (the logical graph is signal-only). + void fillMtdHits(edm::Event& event, truth::LogicalGraphHitIndexBuilder& builder) const; + + RelabelContext makeRelabelContext(edm::EventSetup const& setup) const; + + uint32_t recoDetIdForSimHit(PCaloHit const& simHit, + bool isHGCalCollection, + bool isHcalCollection, + RelabelContext const& context) const; + + edm::EDGetTokenT graphToken_; + edm::EDGetTokenT rawGraphToken_; + edm::EDGetTokenT recHitMapToken_; + + std::vector simHitTags_; + std::vector>> simHitTokens_; + + std::vector trackerSimHitTags_; + std::vector> trackerSimHitTokens_; + + std::vector muonSimHitTags_; + std::vector> muonSimHitTokens_; + + edm::EDGetTokenT mtdSimLayerClusterToken_; + edm::EDGetTokenT mtdSimToRecoAssocToken_; + edm::EDGetTokenT mtdBarrelClusterToken_; + edm::EDGetTokenT mtdEndcapClusterToken_; + + edm::ESGetToken geomToken_; + + std::array fillChannel_{}; + + bool doHGCalRelabelling_ = true; +}; + +TruthLogicalGraphHitIndexProducer::TruthLogicalGraphHitIndexProducer(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + rawGraphToken_(consumes(cfg.getParameter("rawSrc"))), + recHitMapToken_(consumes(cfg.getParameter("recHitMap"))), + simHitTags_(cfg.getParameter>("simHitCollections")), + trackerSimHitTags_(cfg.getParameter>("trackerSimHitCollections")), + muonSimHitTags_(cfg.getParameter>("muonSimHitCollections")), + geomToken_(esConsumes()), + doHGCalRelabelling_(cfg.getParameter("doHGCalRelabelling")) { + simHitTokens_.reserve(simHitTags_.size()); + for (auto const& tag : simHitTags_) { + simHitTokens_.push_back(consumes>(tag)); + } + + trackerSimHitTokens_.reserve(trackerSimHitTags_.size()); + for (auto const& tag : trackerSimHitTags_) { + trackerSimHitTokens_.push_back(consumes(tag)); + } + + muonSimHitTokens_.reserve(muonSimHitTags_.size()); + for (auto const& tag : muonSimHitTags_) { + muonSimHitTokens_.push_back(consumes(tag)); + } + + mtdSimLayerClusterToken_ = + consumes(cfg.getParameter("mtdSimLayerClusters")); + mtdSimToRecoAssocToken_ = consumes( + cfg.getParameter("mtdRecoClusterAssociation")); + mtdBarrelClusterToken_ = consumes(cfg.getParameter("mtdBarrelClusters")); + mtdEndcapClusterToken_ = consumes(cfg.getParameter("mtdEndcapClusters")); + + for (auto const& name : cfg.getParameter>("subdetectors")) { + truth::HitChannel channel; + if (channelFromName(name, channel)) + fillChannel_[static_cast(channel)] = true; + else + edm::LogWarning("TruthLogicalGraphHitIndexProducer") + << "Unknown subdetector channel '" << name << "'; ignoring it."; + } + + produces(); +} + +void TruthLogicalGraphHitIndexProducer::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("rawSrc", edm::InputTag("truthGraphProducer")); + desc.add("recHitMap", edm::InputTag("detIdToRecHitMapProducer")); + + desc.add>("subdetectors", {"HGCalCalo", "Tracker", "MTD", "Muon"}) + ->setComment( + "Detector channels to fill (subdetector selection): any of HGCalCalo, Tracker, MTD, Muon. Each reads its " + "own per-subdetector hit collections below; channels left out of this list stay empty in the index."); + + desc.add>("simHitCollections", + {edm::InputTag("g4SimHits", "HGCHitsEE"), + edm::InputTag("g4SimHits", "HGCHitsHEfront"), + edm::InputTag("g4SimHits", "HGCHitsHEback")}); + + desc.add>("trackerSimHitCollections", + {edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECHighTof")}) + ->setComment("Tracker PSimHit collections matched to particles via PSimHit::trackId()"); + + desc.add>("muonSimHitCollections", + {edm::InputTag("g4SimHits", "MuonDTHits"), + edm::InputTag("g4SimHits", "MuonCSCHits"), + edm::InputTag("g4SimHits", "MuonRPCHits"), + edm::InputTag("g4SimHits", "MuonGEMHits"), + edm::InputTag("g4SimHits", "MuonME0Hits")}) + ->setComment("Muon-chamber PSimHit collections matched to particles via PSimHit::trackId()"); + + desc.add("doHGCalRelabelling", true) + ->setComment("Convert old HGCAL simulation DetIds to reco DetIds before looking up recHits"); + + desc.add("mtdSimLayerClusters", edm::InputTag("mix", "MergedMtdTruthLC")) + ->setComment( + "MtdSimLayerCluster collection (BTL/ETL); keyed by SimTrack trackId via particleId(). The signal " + "interaction is selected by EncodedEventId; pile-up clusters are skipped."); + desc.add("mtdRecoClusterAssociation", edm::InputTag("mtdRecoClusterToSimLayerClusterAssociation")) + ->setComment("MtdSimLayerCluster -> FTLCluster association; sets the MTD recHitIndex when available."); + desc.add("mtdBarrelClusters", edm::InputTag("mtdClusters", "FTLBarrel")); + desc.add("mtdEndcapClusters", edm::InputTag("mtdClusters", "FTLEndcap")) + ->setComment( + "Reco FTLClusters; the MTD recHitIndex is the global index in the barrel-then-endcap concatenation."); + + descriptions.addWithDefaultLabel(desc); +} + +void TruthLogicalGraphHitIndexProducer::produce(edm::StreamID, edm::Event& event, edm::EventSetup const& setup) const { + auto const& graph = event.get(graphToken_); + auto const& rawGraph = event.get(rawGraphToken_); + + edm::Handle hRecHitMap; + event.getByToken(recHitMapToken_, hRecHitMap); + auto const* recHitMap = hRecHitMap.isValid() ? &(*hRecHitMap) : nullptr; + + LogicalGraphView graphView(graph); + + truth::LogicalGraphHitIndexBuilder builder(graphView.nParticles()); + + fillTrackToParticleMap(graphView, rawGraph, builder); + + // Each subdetector channel is filled only when selected (see "subdetectors"). + if (fillChannel_[static_cast(truth::HitChannel::HGCalCalo)]) + fillSimHits(event, setup, builder, recHitMap); + if (fillChannel_[static_cast(truth::HitChannel::Tracker)]) + fillTrackerSimHits(event, builder); + if (fillChannel_[static_cast(truth::HitChannel::Muon)]) + fillMuonSimHits(event, builder); + if (fillChannel_[static_cast(truth::HitChannel::MTD)]) + fillMtdHits(event, builder); + + auto output = std::make_unique(builder.finish()); + event.put(std::move(output)); +} + +void TruthLogicalGraphHitIndexProducer::fillTrackToParticleMap(LogicalGraphView const& graph, + TruthGraph const& rawGraph, + truth::LogicalGraphHitIndexBuilder& builder) const { + for (uint32_t particleId = 0; particleId < graph.nParticles(); ++particleId) { + if (!graph.particleHasSim(particleId)) + continue; + + const int32_t simNode = graph.particleSimNode(particleId); + if (simNode < 0) + continue; + + const uint32_t simNodeU32 = static_cast(simNode); + if (simNodeU32 >= rawGraph.nNodes()) + continue; + + auto const& ref = rawGraph.nodeRef(simNodeU32); + if (ref.kind != TruthGraph::NodeKind::SimTrack) + continue; + + const uint32_t trackId = checkedTrackId(ref.key); + if (trackId == 0) + continue; + + builder.setSimTrackForParticle(particleId, trackId); + } + + for (uint32_t parentId = 0; parentId < graph.nParticles(); ++parentId) { + graph.forEachParticleChild(parentId, [&](uint32_t childId) { builder.addParticleChild(parentId, childId); }); + } +} + +RelabelContext TruthLogicalGraphHitIndexProducer::makeRelabelContext(edm::EventSetup const& setup) const { + RelabelContext context; + + if (!doHGCalRelabelling_) + return context; + + auto const& geom = setup.getData(geomToken_); + + auto const* hcalGeometry = static_cast(geom.getSubdetectorGeometry(DetId::Hcal, HcalEndcap)); + if (hcalGeometry != nullptr) { + context.hcalConstants = hcalGeometry->topology().dddConstants(); + } + + auto const* eeGeometry = + static_cast(geom.getSubdetectorGeometry(DetId::HGCalEE, ForwardSubdetector::ForwardEmpty)); + + if (eeGeometry != nullptr) { + context.geometryType = 1; + + auto const* fhGeometry = static_cast( + geom.getSubdetectorGeometry(DetId::HGCalHSi, ForwardSubdetector::ForwardEmpty)); + auto const* bhGeometry = static_cast( + geom.getSubdetectorGeometry(DetId::HGCalHSc, ForwardSubdetector::ForwardEmpty)); + + context.hgTopologies[0] = &eeGeometry->topology(); + context.hgTopologies[1] = fhGeometry != nullptr ? &fhGeometry->topology() : nullptr; + context.hgTopologies[2] = bhGeometry != nullptr ? &bhGeometry->topology() : nullptr; + + for (unsigned i = 0; i < context.hgTopologies.size(); ++i) { + if (context.hgTopologies[i] != nullptr) + context.hgConstants[i] = &context.hgTopologies[i]->dddConstants(); + } + + return context; + } + + context.geometryType = 0; + + eeGeometry = static_cast(geom.getSubdetectorGeometry(DetId::Forward, HGCEE)); + auto const* fhGeometry = static_cast(geom.getSubdetectorGeometry(DetId::Forward, HGCHEF)); + + context.hgTopologies[0] = eeGeometry != nullptr ? &eeGeometry->topology() : nullptr; + context.hgTopologies[1] = fhGeometry != nullptr ? &fhGeometry->topology() : nullptr; + + for (unsigned i = 0; i < context.hgTopologies.size(); ++i) { + if (context.hgTopologies[i] != nullptr) + context.hgConstants[i] = &context.hgTopologies[i]->dddConstants(); + } + + return context; +} + +uint32_t TruthLogicalGraphHitIndexProducer::recoDetIdForSimHit(PCaloHit const& simHit, + bool isHGCalCollection, + bool isHcalCollection, + RelabelContext const& context) const { + const uint32_t simId = simHit.id(); + + if (!doHGCalRelabelling_) { + return simId; + } + + if (isHGCalCollection) { + if (context.geometryType == 1) { + return simId; + } + + int subdet = 0; + int layer = 0; + int cell = 0; + int sec = 0; + int subsec = 0; + int zp = 0; + + HGCalTestNumbering::unpackHexagonIndex(simId, subdet, zp, layer, sec, subsec, cell); + + const int hgcalIndex = subdet - 3; + if (hgcalIndex < 0 || hgcalIndex >= static_cast(context.hgConstants.size())) + return 0; + + auto const* constants = context.hgConstants[hgcalIndex]; + auto const* topology = context.hgTopologies[hgcalIndex]; + + if (constants == nullptr || topology == nullptr) + return 0; + + const auto recoLayerCell = constants->simToReco(cell, layer, sec, topology->detectorType()); + cell = recoLayerCell.first; + layer = recoLayerCell.second; + + if (layer < 0) + return 0; + + return HGCalDetId(static_cast(subdet), zp, layer, subsec, sec, cell).rawId(); + } + + if (isHcalCollection && context.hcalConstants != nullptr) { + return HcalHitRelabeller::relabel(simId, context.hcalConstants).rawId(); + } + + return simId; +} + +void TruthLogicalGraphHitIndexProducer::fillSimHits(edm::Event& event, + edm::EventSetup const& setup, + truth::LogicalGraphHitIndexBuilder& builder, + hgcal::DetIdRecHitMap const* recHitMap) const { + const RelabelContext relabelContext = makeRelabelContext(setup); + + for (uint32_t tokenIndex = 0; tokenIndex < simHitTokens_.size(); ++tokenIndex) { + auto const& token = simHitTokens_[tokenIndex]; + auto const& tag = simHitTags_[tokenIndex]; + + edm::Handle> hSimHits; + event.getByToken(token, hSimHits); + + if (!hSimHits.isValid()) { + edm::LogWarning("TruthLogicalGraphHitIndexProducer") + << "Missing PCaloHit collection " << tag.encode() << ". Skipping it."; + continue; + } + + const bool isHGCalCollection = inputTagLooksLikeHGCal(tag); + const bool isHcalCollection = inputTagLooksLikeHcal(tag); + + for (auto const& simHit : *hSimHits) { + const int geantTrackId = simHit.geantTrackId(); + if (geantTrackId <= 0) + continue; + + const uint32_t detId = recoDetIdForSimHit(simHit, isHGCalCollection, isHcalCollection, relabelContext); + if (detId == 0) + continue; + + uint32_t recHitIndex = truth::LogicalGraphHitIndex::Hit::kInvalidRecHitIndex; + + if (recHitMap != nullptr) { + const auto it = recHitMap->find(detId); + if (it != recHitMap->end()) { + recHitIndex = it->second; + } + } + + builder.addHit( + truth::HitChannel::HGCalCalo, static_cast(geantTrackId), detId, simHit.energy(), recHitIndex); + } + } +} + +void TruthLogicalGraphHitIndexProducer::fillTrackerSimHits(edm::Event& event, + truth::LogicalGraphHitIndexBuilder& builder) const { + for (uint32_t tokenIndex = 0; tokenIndex < trackerSimHitTokens_.size(); ++tokenIndex) { + edm::Handle hSimHits; + event.getByToken(trackerSimHitTokens_[tokenIndex], hSimHits); + + if (!hSimHits.isValid()) { + edm::LogWarning("TruthLogicalGraphHitIndexProducer") + << "Missing tracker PSimHit collection " << trackerSimHitTags_[tokenIndex].encode() << ". Skipping it."; + continue; + } + + for (auto const& simHit : *hSimHits) { + // PSimHit::trackId() is the G4 trackId of the SimTrack that made the hit, + // the same id space used to associate calorimeter simhits to particles. + builder.addHit(truth::HitChannel::Tracker, simHit.trackId(), simHit.detUnitId(), simHit.energyLoss()); + } + } +} + +void TruthLogicalGraphHitIndexProducer::fillMuonSimHits(edm::Event& event, + truth::LogicalGraphHitIndexBuilder& builder) const { + for (uint32_t tokenIndex = 0; tokenIndex < muonSimHitTokens_.size(); ++tokenIndex) { + edm::Handle hSimHits; + event.getByToken(muonSimHitTokens_[tokenIndex], hSimHits); + + // Phase-2 D120 does not populate every muon subsystem; missing ones are skipped. + if (!hSimHits.isValid()) + continue; + + for (auto const& simHit : *hSimHits) { + builder.addHit(truth::HitChannel::Muon, simHit.trackId(), simHit.detUnitId(), simHit.energyLoss()); + } + } +} + +void TruthLogicalGraphHitIndexProducer::fillMtdHits(edm::Event& event, + truth::LogicalGraphHitIndexBuilder& builder) const { + edm::Handle hClusters; + event.getByToken(mtdSimLayerClusterToken_, hClusters); + if (!hClusters.isValid()) + return; + + // Optional reco-cluster link: the MtdSimLayerCluster -> FTLCluster association plus + // the two FTLCluster collections give the MTD recHitIndex as the global index in the + // barrel-then-endcap concatenation. Absent (e.g. no MTD reco) -> recHitIndex invalid. + edm::Handle hAssoc; + event.getByToken(mtdSimToRecoAssocToken_, hAssoc); + edm::Handle hBarrel; + event.getByToken(mtdBarrelClusterToken_, hBarrel); + edm::Handle hEndcap; + event.getByToken(mtdEndcapClusterToken_, hEndcap); + const bool haveReco = hAssoc.isValid() && hBarrel.isValid() && hEndcap.isValid(); + const uint32_t nBarrelClusters = haveReco ? static_cast(hBarrel->dataSize()) : 0; + + for (uint32_t i = 0; i < hClusters->size(); ++i) { + auto const& cluster = (*hClusters)[i]; + + // Only the signal interaction (bx 0, event 0): the logical graph is signal-only, + // so its trackId space matches the signal MtdSimLayerClusters; pile-up clusters + // (different EncodedEventId) could collide numerically and are skipped. + const EncodedEventId eid = cluster.eventId(); + if (eid.bunchCrossing() != 0 || eid.event() != 0) + continue; + + // The best-matched reco FTLCluster -> a global index across the two collections. + uint32_t recHitIndex = truth::LogicalGraphHitIndex::Hit::kInvalidRecHitIndex; + if (haveReco) { + const MtdSimLayerClusterRef simRef(hClusters, i); + const auto range = hAssoc->equal_range(simRef); + if (range.first != range.second && !range.first->second.empty()) { + FTLClusterRef const& recoRef = range.first->second.front(); + if (recoRef.id() == hBarrel.id()) + recHitIndex = static_cast(recoRef.key()); + else if (recoRef.id() == hEndcap.id()) + recHitIndex = nBarrelClusters + static_cast(recoRef.key()); + } + } + + // particleId() carries the producing SimTrack trackId; hits_and_energies() returns + // (packed sensor-module DetId << 32 | row << 16 | col, energy). The builder + // coalesces per module DetId; every hit of the cluster shares the matched FTLCluster. + const auto trackId = static_cast(cluster.particleId()); + for (auto const& [packedHit, energy] : cluster.hits_and_energies()) { + const uint32_t moduleDetId = static_cast(packedHit >> 32); + builder.addHit(truth::HitChannel::MTD, trackId, moduleDetId, energy, recHitIndex); + } + } +} + +DEFINE_FWK_MODULE(TruthLogicalGraphHitIndexProducer); diff --git a/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc new file mode 100644 index 0000000000000..74666c7cb3672 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/PFRecHitFlatTableProducer.cc @@ -0,0 +1,111 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Author: Felice Pantaleo - CERN +// Flat-table dump of reco::PFRecHit collections (barrel/forward calorimeters: +// ECAL, HBHE, HF, HO). HGCal rechits are dumped separately by +// RecHitFlatTableProducer. +// +// NOTE: reco::PFRecHit::position()/positionREP() read the cached CaloCellGeometry, +// which is NOT persisted; calling them on rechits read back from a file segfaults. +// Positions are therefore recomputed from CaloGeometry using the (persisted) detId. + +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Utilities/interface/transform.h" +#include "FWCore/Utilities/interface/ESGetToken.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/NanoAOD/interface/FlatTable.h" +#include "DataFormats/DetId/interface/DetId.h" +#include "DataFormats/ParticleFlowReco/interface/PFRecHit.h" +#include "DataFormats/ParticleFlowReco/interface/PFRecHitFwd.h" +#include "Geometry/CaloGeometry/interface/CaloGeometry.h" +#include "Geometry/Records/interface/CaloGeometryRecord.h" + +#include +#include + +class PFRecHitFlatTableProducer : public edm::stream::EDProducer<> { +public: + explicit PFRecHitFlatTableProducer(edm::ParameterSet const& params) + : objName_(params.getParameter("objName")), + rechits_tokens_{edm::vector_transform( + params.getParameter>("label_rechits"), + [this](edm::InputTag const& tag) { return consumes(tag); })}, + geomToken_(esConsumes()) { + produces(); + } + + void produce(edm::Event& event, edm::EventSetup const& setup) override { + auto const& geom = setup.getData(geomToken_); + + std::vector rechit_ID; + std::vector rechit_energy; + std::vector rechit_time; + std::vector rechit_x; + std::vector rechit_y; + std::vector rechit_z; + std::vector rechit_eta; + std::vector rechit_phi; + std::vector rechit_depth; + + for (auto const& token : rechits_tokens_) { + edm::Handle handle; + event.getByToken(token, handle); + if (!handle.isValid()) + continue; + + for (auto const& rh : *handle) { + const GlobalPoint pos = geom.getPosition(DetId(rh.detId())); + rechit_ID.push_back(rh.detId()); + rechit_energy.push_back(rh.energy()); + rechit_time.push_back(rh.time()); + rechit_x.push_back(pos.x()); + rechit_y.push_back(pos.y()); + rechit_z.push_back(pos.z()); + rechit_eta.push_back(pos.eta()); + rechit_phi.push_back(pos.phi()); + rechit_depth.push_back(rh.depth()); + } + } + + auto tab = std::make_unique(rechit_ID.size(), objName_, false, false); + tab->addColumn("rechit_ID", rechit_ID, "PFRecHit DetId rawId"); + tab->addColumn("rechit_energy", rechit_energy, "PFRecHit energy [GeV]"); + tab->addColumn("rechit_time", rechit_time, "PFRecHit time [ns]"); + tab->addColumn("rechit_x", rechit_x, "Global x from CaloGeometry [cm]"); + tab->addColumn("rechit_y", rechit_y, "Global y from CaloGeometry [cm]"); + tab->addColumn("rechit_z", rechit_z, "Global z from CaloGeometry [cm]"); + tab->addColumn("rechit_eta", rechit_eta, "PFRecHit eta"); + tab->addColumn("rechit_phi", rechit_phi, "PFRecHit phi"); + tab->addColumn("rechit_depth", rechit_depth, "PFRecHit depth"); + + event.put(std::move(tab)); + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("objName", "pfrechits") + ->setComment("name of the nanoaod::FlatTable produced for barrel/forward calo PFRecHits"); + desc.add>("label_rechits", + {edm::InputTag("hltParticleFlowRecHitECALUnseeded", "", "HLT"), + edm::InputTag("hltParticleFlowRecHitHBHE", "", "HLT"), + edm::InputTag("hltParticleFlowRecHitHF", "", "HLT"), + edm::InputTag("hltParticleFlowRecHitHO", "", "HLT")}) + ->setComment("reco::PFRecHit collections to dump (barrel/forward calorimeters)"); + descriptions.add("pfRecHitTable", desc); + } + +private: + const std::string objName_; + const std::vector> rechits_tokens_; + const edm::ESGetToken geomToken_; +}; + +DEFINE_FWK_MODULE(PFRecHitFlatTableProducer); diff --git a/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc new file mode 100644 index 0000000000000..67d517e6a2855 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/RecHitFlatTableProducer.cc @@ -0,0 +1,97 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/stream/moduleAbilities.h" +#include "FWCore/Utilities/interface/ESGetToken.h" +#include "FWCore/Utilities/interface/transform.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "DataFormats/NanoAOD/interface/FlatTable.h" + +#include "RecoLocalCalo/HGCalRecAlgos/interface/RecHitTools.h" +#include "DataFormats/HGCRecHit/interface/HGCRecHitCollections.h" + +#include +#include + +class RecHitFlatTableProducer : public edm::stream::EDProducer { +public: + RecHitFlatTableProducer(edm::ParameterSet const& params) + : objName_(params.getParameter("objName")), + rechits_tokens_{ + edm::vector_transform(params.getParameter>("label_rechits"), + [this](const edm::InputTag& lab) { return consumes(lab); })}, + caloGeometry_token_(esConsumes()) { + produces(); + } + + ~RecHitFlatTableProducer() override {} + + void produce(edm::Event& event, edm::EventSetup const& iSetup) override { + std::vector rechit_ID; + std::vector rechit_energy; + std::vector rechit_x; + std::vector rechit_y; + std::vector rechit_z; + std::vector rechit_time; + std::vector rechit_radius; + + for (auto const& rh_token : rechits_tokens_) { + edm::Handle rechit_handle; + event.getByToken(rh_token, rechit_handle); + if (!rechit_handle.isValid()) + continue; + for (auto const& rh : *rechit_handle) { + rechit_energy.push_back(rh.energy()); + auto const rhPosition = rhtools_.getPosition(rh.detid()); + rechit_x.push_back(rhPosition.x()); + rechit_y.push_back(rhPosition.y()); + rechit_z.push_back(rhPosition.z()); + rechit_ID.push_back(rh.detid().rawId()); + rechit_time.push_back(rh.time()); + rechit_radius.push_back(rhtools_.getRadiusToSide(rh.detid())); + } + } + + auto tab = std::make_unique(rechit_ID.size(), objName_, false, false); + tab->addColumn("rechit_ID", rechit_ID, "Rechit ID"); + tab->addColumn("rechit_energy", rechit_energy, "Rechit energy"); + tab->addColumn("rechit_x", rechit_x, "Rechit X from rechittools"); + tab->addColumn("rechit_y", rechit_y, "Rechit Y from rechittools"); + tab->addColumn("rechit_z", rechit_z, "Rechit Z from rechittools"); + tab->addColumn("rechit_time", rechit_time, "Rechit time"); + tab->addColumn("rechit_radius", rechit_radius, "Rechit radius to side from rechittools"); + + event.put(std::move(tab)); + } + + void beginRun(edm::Run const&, edm::EventSetup const& es) override { + edm::ESHandle geom = es.getHandle(caloGeometry_token_); + rhtools_.setGeometry(*geom); + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("objName", "rechits")->setComment("name of the nanoaod::FlatTable to extend with this table"); + desc.add>("label_rechits", + {edm::InputTag("HGCalRecHit", "HGCEERecHits"), + edm::InputTag("HGCalRecHit", "HGCHEFRecHits"), + edm::InputTag("HGCalRecHit", "HGCHEBRecHits")}); + descriptions.add("recHitTable", desc); + } + +protected: + const std::string objName_; + // const edm::EDGetTokenT> src_; + const std::vector> rechits_tokens_; + + edm::ESGetToken caloGeometry_token_; + hgcal::RecHitTools rhtools_; +}; + +#include "FWCore/Framework/interface/MakerMacros.h" +DEFINE_FWK_MODULE(RecHitFlatTableProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc b/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc new file mode 100644 index 0000000000000..e9905046a6f4c --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TrackerSimHitFlatTableProducer.cc @@ -0,0 +1,126 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Author: Felice Pantaleo - CERN +// Flat-table dump of tracker PSimHit collections (g4SimHits TrackerHits*). +// Global positions are computed from the local PSimHit position using the +// TrackerGeometry. trackId() links each hit back to a SimTrack, i.e. to a +// truth-graph particle. + +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Utilities/interface/transform.h" +#include "FWCore/Utilities/interface/ESGetToken.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/NanoAOD/interface/FlatTable.h" +#include "DataFormats/DetId/interface/DetId.h" +#include "SimDataFormats/TrackingHit/interface/PSimHitContainer.h" + +#include "Geometry/TrackerGeometryBuilder/interface/TrackerGeometry.h" +#include "Geometry/CommonTopologies/interface/GeomDet.h" +#include "Geometry/Records/interface/TrackerDigiGeometryRecord.h" + +#include +#include + +class TrackerSimHitFlatTableProducer : public edm::stream::EDProducer<> { +public: + explicit TrackerSimHitFlatTableProducer(edm::ParameterSet const& params) + : objName_(params.getParameter("objName")), + simhits_tokens_{ + edm::vector_transform(params.getParameter>("label_simhits"), + [this](edm::InputTag const& tag) { return consumes(tag); })}, + trackerGeomToken_(esConsumes()) { + produces(); + } + + void produce(edm::Event& event, edm::EventSetup const& setup) override { + auto const& trackerGeom = setup.getData(trackerGeomToken_); + + std::vector simhit_detId; + std::vector simhit_trackId; + std::vector simhit_pdgId; + std::vector simhit_energyLoss; + std::vector simhit_tof; + std::vector simhit_pabs; + std::vector simhit_processType; + std::vector simhit_x; + std::vector simhit_y; + std::vector simhit_z; + + for (auto const& token : simhits_tokens_) { + edm::Handle handle; + event.getByToken(token, handle); + if (!handle.isValid()) + continue; + + for (auto const& hit : *handle) { + const DetId detId(hit.detUnitId()); + auto const* det = trackerGeom.idToDet(detId); + + GlobalPoint gp; + if (det != nullptr) + gp = det->surface().toGlobal(hit.localPosition()); + + simhit_detId.push_back(hit.detUnitId()); + simhit_trackId.push_back(hit.trackId()); + simhit_pdgId.push_back(hit.particleType()); + simhit_energyLoss.push_back(hit.energyLoss()); + simhit_tof.push_back(hit.tof()); + simhit_pabs.push_back(hit.pabs()); + simhit_processType.push_back(hit.processType()); + simhit_x.push_back(gp.x()); + simhit_y.push_back(gp.y()); + simhit_z.push_back(gp.z()); + } + } + + auto tab = std::make_unique(simhit_detId.size(), objName_, false, false); + tab->addColumn("simhit_detId", simhit_detId, "Tracker PSimHit detUnitId rawId"); + tab->addColumn("simhit_trackId", simhit_trackId, "G4 trackId of the SimTrack that made the hit"); + tab->addColumn("simhit_pdgId", simhit_pdgId, "PDG id of the particle (particleType)"); + tab->addColumn("simhit_energyLoss", simhit_energyLoss, "Energy loss in the sensor [GeV]"); + tab->addColumn("simhit_tof", simhit_tof, "Time of flight [ns]"); + tab->addColumn("simhit_pabs", simhit_pabs, "Momentum magnitude at entry [GeV]"); + tab->addColumn("simhit_processType", simhit_processType, "Geant process type"); + tab->addColumn("simhit_x", simhit_x, "Global x of the hit center [cm]"); + tab->addColumn("simhit_y", simhit_y, "Global y of the hit center [cm]"); + tab->addColumn("simhit_z", simhit_z, "Global z of the hit center [cm]"); + + event.put(std::move(tab)); + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("objName", "trackersimhits") + ->setComment("name of the nanoaod::FlatTable produced for tracker PSimHits"); + desc.add>("label_simhits", + {edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECHighTof")}) + ->setComment("Tracker PSimHit collections to dump"); + descriptions.add("trackerSimHitTable", desc); + } + +private: + const std::string objName_; + const std::vector> simhits_tokens_; + const edm::ESGetToken trackerGeomToken_; +}; + +DEFINE_FWK_MODULE(TrackerSimHitFlatTableProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc new file mode 100644 index 0000000000000..a1ea9e34c31c5 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthBranchCaloAssociationProducer.cc @@ -0,0 +1,164 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Builds TICL-style AssociationMaps between the truth::Branch graph and the legacy +// HGCAL calo truth objects (CaloParticle, SimCluster), in the same fashion as the +// TICL trackster<->simTrackster associators. For each direction it stores, per +// object, the matched branches with their shared energy and score (lower = better), +// sorted so the best-matched branch is first. The branch side is restricted to the +// "interesting" particles configured via interestingPdgIds (empty = all), so the +// association metrics are computed against the particles of interest. Downstream +// DQM validators consume these maps to compute efficiency / fake / merge / +// duplicate / purity, exactly like HGCalValidator consumes its association maps. + +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "SimDataFormats/Associations/interface/TICLAssociationMap.h" +#include "SimDataFormats/CaloAnalysis/interface/CaloParticle.h" +#include "SimDataFormats/CaloAnalysis/interface/CaloParticleFwd.h" +#include "SimDataFormats/CaloAnalysis/interface/SimCluster.h" +#include "SimDataFormats/CaloAnalysis/interface/SimClusterFwd.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" + +namespace { + // Raw-index AssociationMap (object index -> [(branch id, sharedEnergy, score)]). + using BranchAssociationMap = ticl::AssociationMap; +} // namespace + +class TruthBranchCaloAssociationProducer : public edm::stream::EDProducer<> { +public: + explicit TruthBranchCaloAssociationProducer(edm::ParameterSet const&); + void produce(edm::Event&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + template + void associate(Collection const& objects, + truth::Graph const& graph, + truth::BranchHitAssociator const& assoc, + std::string const& recoToBranchLabel, + std::string const& branchToRecoLabel, + edm::Event& event) const; + + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> caloParticleToken_; + const edm::EDGetTokenT> simClusterToken_; + const std::vector interestingPdgIds_; +}; + +TruthBranchCaloAssociationProducer::TruthBranchCaloAssociationProducer(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + caloParticleToken_(consumes>(cfg.getParameter("caloParticles"))), + simClusterToken_(consumes>(cfg.getParameter("simClusters"))), + interestingPdgIds_(cfg.getParameter>("interestingPdgIds")) { + produces("caloParticleToBranch"); + produces("branchToCaloParticle"); + produces("simClusterToBranch"); + produces("branchToSimCluster"); +} + +template +void TruthBranchCaloAssociationProducer::associate(Collection const& objects, + truth::Graph const& graph, + truth::BranchHitAssociator const& assoc, + std::string const& recoToBranchLabel, + std::string const& branchToRecoLabel, + edm::Event& event) const { + auto recoToBranch = std::make_unique(static_cast(objects.size())); + auto branchToReco = std::make_unique(graph.nParticles()); + + std::vector recoHits; + unsigned int objIndex = 0; + for (auto const& obj : objects) { + recoHits.clear(); + for (auto const& [detId, fraction] : obj.hits_and_fractions()) + recoHits.push_back(truth::RecoHit{detId, 1.f, fraction}); + + if (!recoHits.empty()) { + for (auto const& m : assoc.bestBranches(std::span(recoHits))) { + recoToBranch->insert(objIndex, m.rootParticleId, m.sharedEnergy, m.score); + // Branch-normalized score for the sim->reco direction (how much of the + // branch this reco object captures), not the reco-normalized one. + branchToReco->insert(m.rootParticleId, objIndex, m.sharedEnergy, m.reverseScore); + } + } + ++objIndex; + } + + // Sort each row by score in ascending order so the best-matched branch/object + // is first. TICLAssociationMap::sort(true) sorts *descending* by score, but the + // association score is lower-is-better, so we pass an explicit ascending + // comparator (matching the standard HGCal associators). + auto byAscendingScore = [](auto const& a, auto const& b) { + if (a.score() != b.score()) + return a.score() < b.score(); + return a.index() < b.index(); + }; + recoToBranch->sort(byAscendingScore); + branchToReco->sort(byAscendingScore); + + event.put(std::move(recoToBranch), recoToBranchLabel); + event.put(std::move(branchToReco), branchToRecoLabel); +} + +void TruthBranchCaloAssociationProducer::produce(edm::Event& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& hitIndex = event.get(hitIndexToken_); + + // Candidate branch roots = the interesting particles. No configured restriction + // means "all branches"; a configured restriction that matches nothing in this + // event means "no branches" (not all), so the empty-roots fallback is disabled + // whenever interestingPdgIds_ is set. + const bool restrictRoots = !interestingPdgIds_.empty(); + std::vector roots; + if (restrictRoots) { + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int pdgId = graph.particles()[i].pdgId; + if (std::find(interestingPdgIds_.begin(), interestingPdgIds_.end(), pdgId) != interestingPdgIds_.end()) + roots.push_back(i); + } + } + + // SharedEnergy metric -> score is the normalized shared-energy penalty (lower is + // better), matching the TICL association-score convention. + truth::BranchHitAssociator assoc(hitIndex, + roots, + truth::BranchHitAssociator::Metric::SharedEnergy, + truth::HitChannel::HGCalCalo, + /*emptyRootsMeansAll=*/!restrictRoots); + + associate(event.get(caloParticleToken_), graph, assoc, "caloParticleToBranch", "branchToCaloParticle", event); + associate(event.get(simClusterToken_), graph, assoc, "simClusterToBranch", "branchToSimCluster", event); +} + +void TruthBranchCaloAssociationProducer::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("hitIndex", edm::InputTag("truthLogicalGraphHitIndexProducer")); + desc.add("caloParticles", edm::InputTag("mix", "MergedCaloTruth")); + desc.add("simClusters", edm::InputTag("mix", "MergedCaloTruth")); + desc.add>("interestingPdgIds", {}) + ->setComment("Restrict the branch side to these PDG ids (the interesting particles); empty = all branches."); + descriptions.addWithDefaultLabel(desc); +} + +DEFINE_FWK_MODULE(TruthBranchCaloAssociationProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc new file mode 100644 index 0000000000000..eb6626d43b621 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthBranchTrackingAssociationProducer.cc @@ -0,0 +1,153 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Builds TICL-style AssociationMaps between reco tracks and the truth::Branch +// graph, the tracker counterpart of TruthBranchCaloAssociationProducer. Unlike the +// calo truth objects (CaloParticle / SimCluster), a TrackingParticle carries no +// hits of its own - it is just a bundle of SimTracks - so it cannot be matched to a +// branch by shared cells directly. The hit-bearing probe is therefore the +// reco::Track: each track is matched to a branch through the tracker simhit index +// (PSimHit DetIds, the same channel the Branch tracker subgraph is built from), +// using the shared-hit-multiplicity metric (the tracker has no per-cell energy to +// share, unlike the calorimeter). The track<->TrackingParticle link that closes the +// Branch<->TrackingParticle comparison is provided by the standard ClusterTPAssociation +// (tpClusterProducer) and consumed downstream by BranchTrackingValidator; here we +// produce only the genuinely new, hit-based reco-track<->branch association. The +// branch side is restricted to the "interesting" particles via interestingPdgIds +// (empty = all). For each direction the matched branches/tracks are stored with +// their shared-hit count (in the sharedEnergy slot) and score (lower = better, i.e. +// more of the track's hits captured), sorted best first. + +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" + +#include "DataFormats/Common/interface/View.h" +#include "DataFormats/TrackReco/interface/Track.h" +#include "DataFormats/TrackReco/interface/TrackFwd.h" + +#include "SimDataFormats/Associations/interface/TICLAssociationMap.h" + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" + +namespace { + // Raw-index AssociationMap (object index -> [(branch id, sharedHits, score)]). + using BranchAssociationMap = ticl::AssociationMap; +} // namespace + +class TruthBranchTrackingAssociationProducer : public edm::stream::EDProducer<> { +public: + explicit TruthBranchTrackingAssociationProducer(edm::ParameterSet const&); + void produce(edm::Event&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + const edm::EDGetTokenT graphToken_; + const edm::EDGetTokenT hitIndexToken_; + const edm::EDGetTokenT> trackToken_; + const std::vector interestingPdgIds_; +}; + +TruthBranchTrackingAssociationProducer::TruthBranchTrackingAssociationProducer(edm::ParameterSet const& cfg) + : graphToken_(consumes(cfg.getParameter("src"))), + hitIndexToken_(consumes(cfg.getParameter("hitIndex"))), + trackToken_(consumes>(cfg.getParameter("tracks"))), + interestingPdgIds_(cfg.getParameter>("interestingPdgIds")) { + produces("trackToBranch"); + produces("branchToTrack"); +} + +void TruthBranchTrackingAssociationProducer::produce(edm::Event& event, edm::EventSetup const&) { + auto const& graph = event.get(graphToken_); + auto const& hitIndex = event.get(hitIndexToken_); + auto const& tracks = event.get(trackToken_); + + // Candidate branch roots = the interesting particles. No configured restriction + // means "all branches"; a configured restriction that matches nothing in this + // event means "no branches" (not all), so the empty-roots fallback is disabled + // whenever interestingPdgIds_ is set. + const bool restrictRoots = !interestingPdgIds_.empty(); + std::vector roots; + if (restrictRoots) { + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + const int pdgId = graph.particles()[i].pdgId; + if (std::find(interestingPdgIds_.begin(), interestingPdgIds_.end(), pdgId) != interestingPdgIds_.end()) + roots.push_back(i); + } + } + + // SharedHits metric on the tracker channel: the tracker carries no per-cell + // energy to share, so matches are ranked by the multiplicity of shared simhit + // DetIds (score = fraction of the track's hits left uncaptured, lower = better). + truth::BranchHitAssociator assoc(hitIndex, + roots, + truth::BranchHitAssociator::Metric::SharedHits, + truth::HitChannel::Tracker, + /*emptyRootsMeansAll=*/!restrictRoots); + + auto trackToBranch = std::make_unique(static_cast(tracks.size())); + auto branchToTrack = std::make_unique(graph.nParticles()); + + std::vector recoHits; + for (unsigned int trackIndex = 0; trackIndex < tracks.size(); ++trackIndex) { + auto const& track = tracks[trackIndex]; + + // The track's hits as DetIds; the tracker channel has no per-cell energy, so + // every hit weighs the same (energy = fraction = 1). + recoHits.clear(); + for (auto it = track.recHitsBegin(); it != track.recHitsEnd(); ++it) { + const TrackingRecHit* hit = &(**it); + if (hit->isValid()) + recoHits.push_back(truth::RecoHit{hit->geographicalId().rawId(), 1.f, 1.f}); + } + + if (recoHits.empty()) + continue; + + for (auto const& m : assoc.bestBranches(std::span(recoHits))) { + trackToBranch->insert(trackIndex, m.rootParticleId, m.sharedEnergy, m.score); + // Branch-normalized score for the sim->reco direction (how much of the branch + // this track captures), not the reco-normalized one. + branchToTrack->insert(m.rootParticleId, trackIndex, m.sharedEnergy, m.reverseScore); + } + } + + // Sort each row by score in ascending order so the best-matched branch/track + // is first. TICLAssociationMap::sort(true) sorts *descending* by score, but the + // association score is lower-is-better, so we pass an explicit ascending + // comparator (matching the standard HGCal associators). + auto byAscendingScore = [](auto const& a, auto const& b) { + if (a.score() != b.score()) + return a.score() < b.score(); + return a.index() < b.index(); + }; + trackToBranch->sort(byAscendingScore); + branchToTrack->sort(byAscendingScore); + + event.put(std::move(trackToBranch), "trackToBranch"); + event.put(std::move(branchToTrack), "branchToTrack"); +} + +void TruthBranchTrackingAssociationProducer::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("hitIndex", edm::InputTag("truthLogicalGraphHitIndexProducer")); + desc.add("tracks", edm::InputTag("generalTracks")); + desc.add>("interestingPdgIds", {}) + ->setComment("Restrict the branch side to these PDG ids (the interesting particles); empty = all branches."); + descriptions.addWithDefaultLabel(desc); +} + +DEFINE_FWK_MODULE(TruthBranchTrackingAssociationProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc new file mode 100644 index 0000000000000..7d76d54c61f80 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphAccumulator.cc @@ -0,0 +1,345 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Phase-B (B1): build the mixed (signal + pileup) raw TruthGraph as a +// DigiAccumulatorMixMod, like TrackingTruthAccumulator / CaloTruthAccumulator. +// The framework hands us one sub-event at a time with its NATIVE +// SimTrack/SimVertex/HepMC collections, so trackId/vertIndex/parentIndex are used +// in their original local context (no flattening, no cross-pileup keying); the +// graph does not fragment the way the Phase-A MixCollection prototype did, it is +// identical for standard mixing and premixing, and it is consistent with the +// digis by construction. +// +// GEN handling is configurable per realm: +// collapsePileupGen (default true) : for pileup, collapse the GEN decay chain to +// the stable (status 1) GEN particles on a single gen vertex, keep the SIM +// continuation (GenToSim links). This is the compact default the user asked +// for; it also connects each pileup interaction into one component. +// collapseSignalGen (default false): the signal keeps its full graph. Full GEN+SIM +// for the signal reuses the standard TruthGraphProducer build and is staged; +// until then collapseSignalGen=false leaves the signal as SIM-only here. +// +// pileupBunchCrossings (default {0} = in-time pileup only): which bunch crossings +// to include for pileup. +// +// Each node carries an EncodedEventId: (0,0) for the signal, (bunchCrossing, +// pileupIndex) for pileup, so signal and pileup stay distinguishable. + +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/ConsumesCollector.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/ProducesCollector.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/Utilities/interface/Exception.h" +#include "FWCore/Utilities/interface/InputTag.h" +#include "FWCore/Utilities/interface/StreamID.h" + +#include "SimGeneral/MixingModule/interface/DigiAccumulatorMixMod.h" +#include "SimGeneral/MixingModule/interface/DigiAccumulatorMixModFactory.h" +#include "SimGeneral/MixingModule/interface/PileUpEventPrincipal.h" + +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" +#include "SimDataFormats/Track/interface/SimTrackContainer.h" +#include "SimDataFormats/Vertex/interface/SimVertexContainer.h" + +#include "SimDataFormats/GeneratorProducts/interface/HepMCProduct.h" +#include "HepMC/GenEvent.h" +#include "HepMC/GenParticle.h" +#include "SimDataFormats/GeneratorProducts/interface/HepMC3Product.h" +#include "HepMC3/GenEvent.h" +#include "HepMC3/GenParticle.h" + +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + uint64_t packEventId(EncodedEventId const& id) { + uint64_t out = 0; + std::memcpy(&out, &id, sizeof(EncodedEventId)); + return out; + } + + // Stable (status 1) GEN particles as (barcode, pdgId). Used to collapse the GEN + // part to "stable particles on a single gen vertex". + std::vector> stableFromHepMC2(HepMC::GenEvent const& ev) { + std::vector> out; + for (auto p = ev.particles_begin(); p != ev.particles_end(); ++p) { + if (*p != nullptr && (*p)->status() == 1) + out.emplace_back((*p)->barcode(), (*p)->pdg_id()); + } + return out; + } + + std::vector> stableFromHepMC3(HepMC3::GenEvent const& ev) { + std::vector> out; + for (auto const& p : ev.particles()) { + if (p && p->status() == 1) + out.emplace_back(p->id(), p->pid()); + } + return out; + } + + // Read the stable GEN particles from a signal Event or a PileUpEventPrincipal + // (both expose getByLabel), preferring HepMC3. + template + std::vector> readStableGen(EvT const& ev, + edm::InputTag const& hepmc3Tag, + edm::InputTag const& hepmc2Tag) { + edm::Handle h3; + if (ev.getByLabel(hepmc3Tag, h3) && h3.isValid() && h3->GetEvent() != nullptr) { + HepMC3::GenEvent ev3; + ev3.read_data(*h3->GetEvent()); + return stableFromHepMC3(ev3); + } + edm::Handle h2; + if (ev.getByLabel(hepmc2Tag, h2) && h2.isValid() && h2->GetEvent() != nullptr) + return stableFromHepMC2(*h2->GetEvent()); + return {}; + } +} // namespace + +class TruthGraphAccumulator : public DigiAccumulatorMixMod { +public: + TruthGraphAccumulator(edm::ParameterSet const&, edm::ProducesCollector, edm::ConsumesCollector&); + + void initializeEvent(edm::Event const&, edm::EventSetup const&) override; + void accumulate(edm::Event const&, edm::EventSetup const&) override; + void accumulate(PileUpEventPrincipal const&, edm::EventSetup const&, edm::StreamID const&) override; + void finalizeEvent(edm::Event&, edm::EventSetup const&) override; + +private: + // Append one sub-event. SimTrack/SimVertex ids are local to this sub-event. If + // `stableGen` is non-empty, also add a single collapsed gen vertex with those + // stable particles and GenToSim links to the primary SimTracks. + void addSubEvent(std::vector> const& stableGen, + edm::SimTrackContainer const& tracks, + edm::SimVertexContainer const& vertices, + EncodedEventId const& eid); + + const edm::InputTag simTrackTag_; + const edm::InputTag simVertexTag_; + const edm::InputTag hepmc3Tag_; + const edm::InputTag hepmc2Tag_; + const std::vector pileupBunchCrossings_; + const bool collapsePileupGen_; + const bool collapseSignalGen_; + + std::unordered_map pileupCountByBx_; + + std::vector nodes_; + std::vector pdgId_; + std::vector status_; + std::vector eventId_; + std::vector simTrackToVtx_; + std::vector simTrackToGen_; + std::vector> edges_; + std::vector edgeKinds_; + std::vector simVertexProcessType_; // node-parallel; G4 process subtype (SimVertex only) + std::vector simTrackBackscattered_; // node-parallel; albedo flag (SimTrack only) + + [[nodiscard]] bool keepBx(int bx) const { + return std::find(pileupBunchCrossings_.begin(), pileupBunchCrossings_.end(), bx) != pileupBunchCrossings_.end(); + } +}; + +TruthGraphAccumulator::TruthGraphAccumulator(edm::ParameterSet const& cfg, + edm::ProducesCollector producesCollector, + edm::ConsumesCollector& iC) + : simTrackTag_(cfg.getParameter("simTracks")), + simVertexTag_(cfg.getParameter("simVertices")), + hepmc3Tag_(cfg.getParameter("genEventHepMC3")), + hepmc2Tag_(cfg.getParameter("genEventHepMC")), + pileupBunchCrossings_(cfg.getParameter>("pileupBunchCrossings")), + collapsePileupGen_(cfg.getParameter("collapsePileupGen")), + collapseSignalGen_(cfg.getParameter("collapseSignalGen")) { + producesCollector.produces(); + iC.consumes(simTrackTag_); + iC.consumes(simVertexTag_); + iC.mayConsume(hepmc3Tag_); + iC.mayConsume(hepmc2Tag_); +} + +void TruthGraphAccumulator::initializeEvent(edm::Event const&, edm::EventSetup const&) { + pileupCountByBx_.clear(); + nodes_.clear(); + pdgId_.clear(); + status_.clear(); + eventId_.clear(); + simTrackToVtx_.clear(); + simTrackToGen_.clear(); + edges_.clear(); + edgeKinds_.clear(); + simVertexProcessType_.clear(); + simTrackBackscattered_.clear(); +} + +void TruthGraphAccumulator::addSubEvent(std::vector> const& stableGen, + edm::SimTrackContainer const& tracks, + edm::SimVertexContainer const& vertices, + EncodedEventId const& eid) { + const uint64_t packed = packEventId(eid); + auto pushNode = [&](TruthGraph::NodeKind kind, int64_t key, int32_t pdg, int16_t st) { + const uint32_t node = static_cast(nodes_.size()); + nodes_.push_back(TruthGraph::NodeRef{kind, key}); + pdgId_.push_back(pdg); + status_.push_back(st); + eventId_.push_back(packed); + simTrackToVtx_.push_back(-1); + simTrackToGen_.push_back(-1); + simVertexProcessType_.push_back(0); + simTrackBackscattered_.push_back(0); + return node; + }; + auto pushEdge = [&](uint32_t src, uint32_t dst, TruthGraph::EdgeKind k) { + edges_.emplace_back(src, dst); + edgeKinds_.push_back(static_cast(k)); + }; + + // Collapsed GEN: one gen vertex + the stable gen particles. barcode -> node. + std::unordered_map genBarcodeToNode; + int32_t genVtxNode = -1; + if (!stableGen.empty()) { + genVtxNode = static_cast(pushNode(TruthGraph::NodeKind::GenVertex, 0, 0, 0)); + genBarcodeToNode.reserve(stableGen.size() * 2); + for (auto const& [barcode, pdg] : stableGen) { + const uint32_t pn = pushNode(TruthGraph::NodeKind::GenParticle, barcode, pdg, 1); + pushEdge(static_cast(genVtxNode), pn, TruthGraph::EdgeKind::Gen); + genBarcodeToNode.emplace(barcode, pn); + } + } + + // SIM realm (native local ids). + std::unordered_map vertexIdToNode; + vertexIdToNode.reserve(vertices.size() * 2); + const uint32_t baseVtx = static_cast(nodes_.size()); + for (auto const& v : vertices) { + const uint32_t node = pushNode(TruthGraph::NodeKind::SimVertex, static_cast(v.vertexId()), 0, 0); + simVertexProcessType_[node] = static_cast(v.processType()); + vertexIdToNode.emplace(static_cast(v.vertexId()), node); + } + const uint32_t baseTrk = static_cast(nodes_.size()); + std::unordered_map trackIdToNode; + trackIdToNode.reserve(tracks.size() * 2); + for (auto const& t : tracks) { + const uint32_t node = pushNode(TruthGraph::NodeKind::SimTrack, static_cast(t.trackId()), t.type(), 0); + simTrackBackscattered_[node] = t.isFromBackScattering() ? 1 : 0; + trackIdToNode.emplace(t.trackId(), node); + } + + // Production edge: track.vertIndex() is the local vector index into `vertices`. + for (std::size_t i = 0; i < tracks.size(); ++i) { + const int vi = tracks[i].vertIndex(); + if (vi < 0 || static_cast(vi) >= vertices.size()) + continue; + const uint32_t trkNode = baseTrk + static_cast(i); + const uint32_t prodVtxNode = baseVtx + static_cast(vi); + pushEdge(prodVtxNode, trkNode, TruthGraph::EdgeKind::Sim); + simTrackToVtx_[trkNode] = static_cast(prodVtxNode); + } + + // Decay edge: vertex.parentIndex() is the trackId of the parent track. + for (auto const& v : vertices) { + if (v.parentIndex() < 0) + continue; + auto pIt = trackIdToNode.find(static_cast(v.parentIndex())); + auto vIt = vertexIdToNode.find(static_cast(v.vertexId())); + if (pIt != trackIdToNode.end() && vIt != vertexIdToNode.end()) + pushEdge(pIt->second, vIt->second, TruthGraph::EdgeKind::Sim); + } + + // GenToSim: a primary SimTrack's genpartIndex is the stable particle's barcode. + if (!genBarcodeToNode.empty()) { + for (auto const& t : tracks) { + auto gIt = genBarcodeToNode.find(t.genpartIndex()); + if (gIt == genBarcodeToNode.end()) + continue; + auto sIt = trackIdToNode.find(t.trackId()); + if (sIt == trackIdToNode.end()) + continue; + pushEdge(gIt->second, sIt->second, TruthGraph::EdgeKind::GenToSim); + simTrackToGen_[sIt->second] = static_cast(gIt->second); + } + } +} + +void TruthGraphAccumulator::accumulate(edm::Event const& event, edm::EventSetup const&) { + edm::Handle tracks; + edm::Handle vertices; + event.getByLabel(simTrackTag_, tracks); + event.getByLabel(simVertexTag_, vertices); + if (!tracks.isValid() || !vertices.isValid()) + return; + std::vector> stableGen; + if (collapseSignalGen_) + stableGen = readStableGen(event, hepmc3Tag_, hepmc2Tag_); + addSubEvent(stableGen, *tracks, *vertices, EncodedEventId(0, 0)); +} + +void TruthGraphAccumulator::accumulate(PileUpEventPrincipal const& pep, edm::EventSetup const&, edm::StreamID const&) { + const int bx = pep.bunchCrossing(); + if (!keepBx(bx)) + return; + + edm::Handle tracks; + edm::Handle vertices; + pep.getByLabel(simTrackTag_, tracks); + pep.getByLabel(simVertexTag_, vertices); + if (!tracks.isValid() || !vertices.isValid()) + return; + + std::vector> stableGen; + if (collapsePileupGen_) + stableGen = readStableGen(pep, hepmc3Tag_, hepmc2Tag_); + + const int puIndex = ++pileupCountByBx_[bx]; + addSubEvent(stableGen, *tracks, *vertices, EncodedEventId(bx, puIndex)); +} + +void TruthGraphAccumulator::finalizeEvent(edm::Event& event, edm::EventSetup const&) { + auto out = std::make_unique(); + const uint32_t nNodes = static_cast(nodes_.size()); + + out->nodes() = std::move(nodes_); + out->pdgId() = std::move(pdgId_); + out->status() = std::move(status_); + out->eventId() = std::move(eventId_); + out->simTrackToVtx() = std::move(simTrackToVtx_); + out->simTrackToGen() = std::move(simTrackToGen_); + out->simVertexProcessType() = std::move(simVertexProcessType_); + out->simTrackBackscattered() = std::move(simTrackBackscattered_); + out->statusFlags().assign(nNodes, 0); + out->genEventOfNode().assign(nNodes, -1); + out->simVtxToGen().assign(nNodes, -1); + + // CSR out-edges via the counting-sort cursor scatter: each edge lands in its + // source's range, by construction (no sort, no permutation vector). + out->offsets().assign(nNodes + 1, 0); + for (auto const& e : edges_) + ++out->offsets()[e.first + 1]; + for (uint32_t i = 1; i <= nNodes; ++i) + out->offsets()[i] += out->offsets()[i - 1]; + + out->edges().resize(edges_.size()); + out->edgeKind().resize(edges_.size()); + std::vector cursor = out->offsets(); + for (std::size_t e = 0; e < edges_.size(); ++e) { + const uint32_t pos = cursor[edges_[e].first]++; + out->edges()[pos] = edges_[e].second; + out->edgeKind()[pos] = edgeKinds_[e]; + } + + if (!out->isConsistent()) + throw cms::Exception("TruthGraphAccumulator") << "Produced TruthGraph is not consistent"; + + event.put(std::move(out)); +} + +DEFINE_DIGI_ACCUMULATOR(TruthGraphAccumulator); diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc new file mode 100644 index 0000000000000..a112e3f179e30 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphDumper.cc @@ -0,0 +1,662 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/one/EDAnalyzer.h" +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/EDGetToken.h" +#include "FWCore/Utilities/interface/InputTag.h" + +#include "SimDataFormats/Track/interface/SimTrackContainer.h" +#include "SimDataFormats/Vertex/interface/SimVertexContainer.h" + +#include "SimDataFormats/GeneratorProducts/interface/HepMCProduct.h" +#include "HepMC/GenEvent.h" +#include "HepMC/GenParticle.h" +#include "HepMC/GenVertex.h" + +#include "SimDataFormats/GeneratorProducts/interface/HepMC3Product.h" +#include "HepMC3/GenEvent.h" +#include "HepMC3/GenParticle.h" +#include "HepMC3/GenVertex.h" + +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + + // --- PDG naming (UTF-8) + std::string pdgNameUtf8(int pdgId) { + const int ap = std::abs(pdgId); + + if (pdgId == 11) + return "e⁻"; + if (pdgId == -11) + return "e⁺"; + if (pdgId == 13) + return "μ⁻"; + if (pdgId == -13) + return "μ⁺"; + if (pdgId == 15) + return "τ⁻"; + if (pdgId == -15) + return "τ⁺"; + + if (pdgId == 12) + return "νₑ"; + if (pdgId == -12) + return "ν̄ₑ"; + if (pdgId == 14) + return "ν_μ"; + if (pdgId == -14) + return "ν̄_μ"; + if (pdgId == 16) + return "ν_τ"; + if (pdgId == -16) + return "ν̄_τ"; + + if (pdgId == 22) + return "γ"; + if (pdgId == 21) + return "g"; + if (pdgId == 23) + return "Z⁰"; + if (pdgId == 24) + return "W⁺"; + if (pdgId == -24) + return "W⁻"; + if (pdgId == 25) + return "H"; + + if (pdgId == 2212) + return "p"; + if (pdgId == -2212) + return "p̄"; + if (pdgId == 2112) + return "n"; + if (pdgId == -2112) + return "n̄"; + + if (pdgId == 111) + return "π⁰"; + if (pdgId == 211) + return "π⁺"; + if (pdgId == -211) + return "π⁻"; + if (pdgId == 321) + return "K⁺"; + if (pdgId == -321) + return "K⁻"; + if (pdgId == 130) + return "K⁰_L"; + if (pdgId == 310) + return "K⁰_S"; + + if (ap >= 1 && ap <= 6) { + static const char* qname[7] = {"", "d", "u", "s", "c", "b", "t"}; + std::string s = qname[ap]; + if (pdgId < 0) + s = "anti-" + s; + return s; + } + + return "pdg"; + } + + std::string pdgLabel(int pdgId) { + std::ostringstream ss; + const std::string name = pdgNameUtf8(pdgId); + if (name == "pdg") + ss << "pdg(" << pdgId << ")"; + else + ss << name << " (" << pdgId << ")"; + return ss.str(); + } + + template + std::string fmtP4(const P4T& p4) { + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss << std::setprecision(3) << "(" << p4.px() << ", " << p4.py() << ", " << p4.pz() << ", " << p4.e() << ")"; + return ss.str(); + } + + template + std::string fmtX4(const X4T& x4) { + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss << std::setprecision(3) << "(" << x4.x() << ", " << x4.y() << ", " << x4.z() << ", " << x4.t() << ")"; + return ss.str(); + } + + // SimVertex position is in cm but stores time in seconds; display as (cm, ns). + std::string fmtSimVertexX4(SimVertex const& sv) { + auto const& p = sv.position(); + constexpr double sToNs = 1e9; + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss << std::setprecision(3) << "(" << p.x() << ", " << p.y() << ", " << p.z() << ", " << p.t() * sToNs << ")"; + return ss.str(); + } + + const char* kindName(TruthGraph::NodeKind k) { + switch (k) { + case TruthGraph::NodeKind::GenEvent: + return "GenEvent"; + case TruthGraph::NodeKind::GenVertex: + return "GenVertex"; + case TruthGraph::NodeKind::GenParticle: + return "GenParticle"; + case TruthGraph::NodeKind::SimVertex: + return "SimVertex"; + case TruthGraph::NodeKind::SimTrack: + return "SimTrack"; + } + return "Unknown"; + } + + const char* shapeFor(TruthGraph::NodeKind k) { + switch (k) { + case TruthGraph::NodeKind::GenEvent: + return "box"; + case TruthGraph::NodeKind::GenVertex: + return "diamond"; + case TruthGraph::NodeKind::GenParticle: + return "box"; + case TruthGraph::NodeKind::SimVertex: + return "diamond"; + case TruthGraph::NodeKind::SimTrack: + return "ellipse"; + } + return "box"; + } + + std::string edgeAttrs(TruthGraph::EdgeKind kind) { + using EdgeKind = TruthGraph::EdgeKind; + + switch (kind) { + case EdgeKind::Gen: + return " [style=solid, edgeType=\"Gen\"]"; + + case EdgeKind::Sim: + return " [style=solid, edgeType=\"Sim\"]"; + + case EdgeKind::GenToSim: + return " [dir=both, style=dashed, label=\"GenToSim\", edgeType=\"GenToSim\"]"; + + case EdgeKind::SimToGen: + return " [dir=both, style=dotted, label=\"SimToGen\", edgeType=\"SimToGen\"]"; + } + + return " [style=solid, edgeType=\"Unknown\"]"; + } + + std::string statusFlagsLabel(uint16_t flags) { + struct FlagInfo { + uint16_t bit; + const char* name; + }; + + static constexpr FlagInfo flagInfos[] = { + {1u << 0, "isPrompt"}, + {1u << 1, "isDecayedLeptonHadron"}, + {1u << 2, "isTauDecayProduct"}, + {1u << 3, "isPromptTauDecayProduct"}, + {1u << 4, "isDirectTauDecayProduct"}, + {1u << 5, "isDirectPromptTauDecayProduct"}, + {1u << 6, "isDirectHadronDecayProduct"}, + {1u << 7, "isHardProcess"}, + {1u << 8, "fromHardProcess"}, + {1u << 9, "isHardProcessTauDecayProduct"}, + {1u << 10, "isDirectHardProcessTauDecayProduct"}, + {1u << 11, "fromHardProcessBeforeFSR"}, + {1u << 12, "isFirstCopy"}, + {1u << 13, "isLastCopy"}, + {1u << 14, "isLastCopyBeforeFSR"}, + }; + + std::ostringstream ss; + bool first = true; + + for (auto const& flag : flagInfos) { + if ((flags & flag.bit) == 0) + continue; + + if (!first) + ss << ", "; + ss << flag.name; + first = false; + } + + if (first) + return "none"; + + return ss.str(); + } + + std::string dotQuote(std::string const& input) { + std::string out; + out.reserve(input.size() + 2); + + out.push_back('"'); + for (char c : input) { + switch (c) { + case '\\': + out += "\\\\"; + break; + case '"': + out += "\\\""; + break; + case '\n': + out += "\\n"; + break; + default: + out.push_back(c); + break; + } + } + out.push_back('"'); + + return out; + } + + std::string appendEventIdToFilename(std::string const& filename, edm::EventID const& id) { + const auto dotPos = filename.rfind('.'); + + std::ostringstream ss; + if (dotPos == std::string::npos) { + ss << filename; + ss << "_run" << id.run(); + ss << "_lumi" << id.luminosityBlock(); + ss << "_event" << id.event(); + return ss.str(); + } + + ss << filename.substr(0, dotPos); + ss << "_run" << id.run(); + ss << "_lumi" << id.luminosityBlock(); + ss << "_event" << id.event(); + ss << filename.substr(dotPos); + + return ss.str(); + } +} // anonymous namespace + +class TruthGraphDumper : public edm::one::EDAnalyzer<> { +public: + explicit TruthGraphDumper(const edm::ParameterSet& cfg) + : token_(consumes(cfg.getParameter("src"))), + dotFile_(cfg.getParameter("dotFile")), + maxNodes_(cfg.getParameter("maxNodes")), + maxEdgesPerNode_(cfg.getParameter("maxEdgesPerNode")), + simTracksToken_(mayConsume(cfg.getParameter("simTracks"))), + simVerticesToken_(mayConsume(cfg.getParameter("simVertices"))), + hepmc2Token_(mayConsume(cfg.getParameter("genEventHepMC"))), + hepmc3Token_(mayConsume(cfg.getParameter("genEventHepMC3"))) {} + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("src", edm::InputTag("truthGraphProducer")); + desc.add("dotFile", "truthgraph.dot"); + desc.add("maxNodes", 5000)->setComment("Truncate to keep DOT manageable"); + desc.add("maxEdgesPerNode", 200)->setComment("Truncate fanout per node"); + + desc.add("simTracks", edm::InputTag("g4SimHits")) + ->setComment("SimTrackContainer (optional, used to enrich SimTrack nodes)"); + desc.add("simVertices", edm::InputTag("g4SimHits")) + ->setComment("SimVertexContainer (optional, used for future enrichment)"); + + // GEN record (for enriching GenParticle/GenVertex nodes) + desc.add("genEventHepMC", edm::InputTag("generatorSmeared")) + ->setComment("edm::HepMCProduct label (your step1.root shows this is present)"); + desc.add("genEventHepMC3", edm::InputTag("generatorSmeared")) + ->setComment("edm::HepMC3Product label (optional)"); + + descriptions.addWithDefaultLabel(desc); + } + + void analyze(const edm::Event& evt, const edm::EventSetup&) override { + auto const& g = evt.get(token_); + + // --- SIM handles (optional) + edm::Handle hSimTracks; + evt.getByToken(simTracksToken_, hSimTracks); + + // SimVertexContainer was already consumed; fetch it to enrich SimVertex nodes. + edm::Handle hSimVertices; + evt.getByToken(simVerticesToken_, hSimVertices); + + std::unordered_map tidToIndex; + if (hSimTracks.isValid()) { + tidToIndex.reserve(hSimTracks->size() * 2); + for (uint32_t i = 0; i < hSimTracks->size(); ++i) { + tidToIndex.emplace((*hSimTracks)[i].trackId(), i); + } + } + + // --- GEN handles (optional) + // Prefer HepMC2 if present (it is in your step1.root); else HepMC3. + edm::Handle hHepMC2; + evt.getByToken(hepmc2Token_, hHepMC2); + + edm::Handle hHepMC3; + evt.getByToken(hepmc3Token_, hHepMC3); + + // HepMC2 lookup maps + std::unordered_map bc2p; + std::unordered_map bc2v; + HepMC::GenEvent const* ev2 = nullptr; + + if (hHepMC2.isValid() && hHepMC2->GetEvent() != nullptr) { + ev2 = hHepMC2->GetEvent(); + bc2p.reserve(ev2->particles_size() * 2); + bc2v.reserve(ev2->vertices_size() * 2); + + for (auto p = ev2->particles_begin(); p != ev2->particles_end(); ++p) { + bc2p.emplace((*p)->barcode(), *p); + } + for (auto v = ev2->vertices_begin(); v != ev2->vertices_end(); ++v) { + bc2v.emplace((*v)->barcode(), *v); + } + } + + // HepMC3 reconstruction + maps + HepMC3::GenEvent ev3; + std::unordered_map id3p; + std::unordered_map id3v; + bool have3 = false; + + if (!ev2 && hHepMC3.isValid() && hHepMC3->GetEvent() != nullptr) { + have3 = true; + const HepMC3::GenEventData* data = hHepMC3->GetEvent(); + ev3.read_data(*data); + + id3p.reserve(ev3.particles().size() * 2); + id3v.reserve(ev3.vertices().size() * 2); + + for (auto const& pptr : ev3.particles()) { + if (pptr) + id3p.emplace(pptr->id(), pptr); + } + for (auto const& vptr : ev3.vertices()) { + if (vptr) + id3v.emplace(vptr->id(), vptr); + } + } + + const std::string eventDotFile = appendEventIdToFilename(dotFile_, evt.id()); + std::ofstream os(eventDotFile); + os << "digraph TruthGraph {\n"; + os << " rankdir=LR;\n"; + os << " node [fontsize=10];\n"; + + const uint32_t n = std::min(g.nNodes(), maxNodes_); + + // nodes + for (uint32_t i = 0; i < n; ++i) { + auto const& r = g.nodeRef(i); + const auto pdg = g.nodePdgId(i); + const auto st = g.nodeStatus(i); + const auto eid = g.nodeEventId(i); + const auto flags = g.nodeStatusFlags(i); + const std::string flagsLabel = statusFlagsLabel(flags); + + // SimTrack enrichment + bool crossedBoundary = false; + bool haveSim = false; + SimTrack const* simt = nullptr; + + if (r.kind == TruthGraph::NodeKind::SimTrack && hSimTracks.isValid()) { + const int64_t key = r.key; // trackId + if (key >= 0 && key <= static_cast(std::numeric_limits::max())) { + auto it = tidToIndex.find(static_cast(key)); + if (it != tidToIndex.end()) { + simt = &(*hSimTracks)[it->second]; + haveSim = true; + crossedBoundary = simt->crossedBoundary(); + } + } + } + + // Node style + os << " n" << i << " [shape=" << shapeFor(r.kind) << ", type=" << dotQuote(kindName(r.kind)) << ", "; + os << "crossedBoundary=" << crossedBoundary << ","; + if (crossedBoundary) + os << "color=\"red\", penwidth=2, "; + + os << "pdg=" << pdg << ", status=" << st << ", statusFlags=" << flags + << ", statusFlagsLabel=" << dotQuote(flagsLabel) << ", eid=" << eid << ","; // --- GEN enrichment + if (r.kind == TruthGraph::NodeKind::GenEvent) { + if (ev2) { + os << "HepMCversion=2, event=" << ev2->event_number() << ", spid=" << ev2->signal_process_id() << ","; + } else if (have3) { + os << "HepMCversion=3, event=" << ev3.event_number() << ","; + } + } else if (r.kind == TruthGraph::NodeKind::GenParticle) { + const int bc = static_cast(r.key); + if (ev2) { + auto it = bc2p.find(bc); + if (it != bc2p.end()) { + auto const* p = it->second; + const int prod = p->production_vertex() ? p->production_vertex()->barcode() : 0; + const int endv = p->end_vertex() ? p->end_vertex()->barcode() : 0; + os << "pid=" << p->pdg_id() << ", status=" << p->status() << ", p4=" << dotQuote(fmtP4(p->momentum())) + << ", m=" << std::fixed << std::setprecision(3) << p->generated_mass() << ", prodVtx=" << prod + << ", endVtx=" << endv << ","; + } + } else if (have3) { + auto it = id3p.find(bc); + if (it != id3p.end() && it->second) { + auto const& p = it->second; + const int prod = p->production_vertex() ? p->production_vertex()->id() : 0; + const int endv = p->end_vertex() ? p->end_vertex()->id() : 0; + os << "pid=" << p->pid() << ", status=" << p->status() << ", p4=" << dotQuote(fmtP4(p->momentum())) + << ", prodVtx=" << prod << ", endVtx=" << endv << ","; + } + } + } else if (r.kind == TruthGraph::NodeKind::GenVertex) { + const int bc = static_cast(r.key); + if (ev2) { + auto it = bc2v.find(bc); + if (it != bc2v.end()) { + auto const* v = it->second; + os << "barcode=" << v->barcode() << ", x4=" << dotQuote(fmtX4(v->position())) + << ", nIn=" << v->particles_in_size() << ", nOut=" << v->particles_out_size() << ","; + } + } else if (have3) { + auto it = id3v.find(bc); + if (it != id3v.end() && it->second) { + auto const& v = it->second; + os << "status=" << v->status() << ", x4=" << dotQuote(fmtX4(v->position())) + << ", nIn=" << v->particles_in().size() << ", nOut=" << v->particles_out().size() << ","; + } + } + } else if (r.kind == TruthGraph::NodeKind::SimVertex && hSimVertices.isValid()) { + // SimVertex node key == index into the SimVertexContainer (see TruthGraphProducer). + const int64_t idx = r.key; + if (idx >= 0 && static_cast(idx) < hSimVertices->size()) { + auto const& sv = (*hSimVertices)[static_cast(idx)]; + os << "x4=" << dotQuote(fmtSimVertexX4(sv)) << ", vertexId=" << sv.vertexId() + << ", processType=" << sv.processType() << ", parentTrackId=" << sv.parentIndex() + << ", noParent=" << sv.noParent() << ", bx=" << sv.eventId().bunchCrossing() + << ", evtInBx=" << sv.eventId().event() << ", GenVertex_nodeId=" << g.nodeSimVtxToGen(i) << ","; + } + } + + // --- SIM enrichment + if (r.kind == TruthGraph::NodeKind::SimTrack && haveSim) { + os << "p4=" << dotQuote(fmtP4(simt->momentum())) << ","; + const int32_t gn = g.nodeSimTrackToGen(i); + os << "GenParticle_nodeId=" << gn << ","; + + const int32_t vn = g.nodeSimTrackToVtx(i); + os << "SimVertex_nodeId=" << vn << ","; + + if (crossedBoundary) { + os << "idAtBoundary=" << simt->getIDAtBoundary() + << ", x4boundary=" << dotQuote(fmtX4(simt->getPositionAtBoundary())) + << ", p4boundary=" << dotQuote(fmtP4(simt->getMomentumAtBoundary())) << ","; + } + } + + // HTML label + os << "label=<\n"; + os << " \n"; + os << " \n"; + + if (pdg != 0) + os << " \n"; + if (st != 0) + os << " \n"; + if (flags != 0) { + os << " \n"; + os << " \n"; + } + if (eid != 0) + os << " \n"; + + // --- GEN enrichment + if (r.kind == TruthGraph::NodeKind::GenEvent) { + if (ev2) { + os << " \n"; + } else if (have3) { + os << " \n"; + } + } else if (r.kind == TruthGraph::NodeKind::GenParticle) { + const int bc = static_cast(r.key); + if (ev2) { + auto it = bc2p.find(bc); + if (it != bc2p.end()) { + auto const* p = it->second; + os << " \n"; + os << " \n"; + os << " \n"; + os << " \n"; + const int prod = p->production_vertex() ? p->production_vertex()->barcode() : 0; + const int endv = p->end_vertex() ? p->end_vertex()->barcode() : 0; + os << " \n"; + } + } else if (have3) { + auto it = id3p.find(bc); + if (it != id3p.end() && it->second) { + auto const& p = it->second; + os << " \n"; + os << " \n"; + os << " \n"; + const int prod = p->production_vertex() ? p->production_vertex()->id() : 0; + const int endv = p->end_vertex() ? p->end_vertex()->id() : 0; + os << " \n"; + } + } + } else if (r.kind == TruthGraph::NodeKind::GenVertex) { + const int bc = static_cast(r.key); + if (ev2) { + auto it = bc2v.find(bc); + if (it != bc2v.end()) { + auto const* v = it->second; + os << " \n"; + os << " \n"; + os << " \n"; + } + } else if (have3) { + auto it = id3v.find(bc); + if (it != id3v.end() && it->second) { + auto const& v = it->second; + os << " \n"; + os << " \n"; + os << " \n"; + } + } + } else if (r.kind == TruthGraph::NodeKind::SimVertex && hSimVertices.isValid()) { + const int64_t idx = r.key; + if (idx >= 0 && static_cast(idx) < hSimVertices->size()) { + auto const& sv = (*hSimVertices)[static_cast(idx)]; + os << " \n"; + os << " \n"; + os << " \n"; + os << " \n"; + const int32_t gv = g.nodeSimVtxToGen(i); + if (gv >= 0) + os << " \n"; + } + } + + // --- SIM enrichment + if (r.kind == TruthGraph::NodeKind::SimTrack && haveSim) { + os << " \n"; + + const int32_t gn = g.nodeSimTrackToGen(i); + if (gn >= 0) + os << " \n"; + + const int32_t vn = g.nodeSimTrackToVtx(i); + if (vn >= 0) + os << " \n"; + + if (crossedBoundary) { + os << " \n"; + os << " \n"; + os << " \n"; + } + } + + os << "
" << i << " " << kindName(r.kind) << " key=" << r.key << "
pid: " << pdgLabel(pdg) << "
status: " << st << "
statusFlags: " << flags << "
flags: " << flagsLabel << "
eid: " << eid << "
HepMC2: event=" << ev2->event_number() << " spid=" << ev2->signal_process_id() + << "
HepMC3: event=" << ev3.event_number() << "
pid: " << pdgLabel(p->pdg_id()) << "
status: " << p->status() << "
p4: " << fmtP4(p->momentum()) << "
m: " << std::fixed << std::setprecision(3) << p->generated_mass() << "
prodVtx: " << prod << " endVtx: " << endv << "
pid: " << pdgLabel(p->pid()) << "
status: " << p->status() << "
p4: " << fmtP4(p->momentum()) << "
prodVtx: " << prod << " endVtx: " << endv << "
barcode: " << v->barcode() << "
x4: " << fmtX4(v->position()) << "
nIn: " << v->particles_in_size() << " nOut: " << v->particles_out_size() + << "
status: " << v->status() << "
x4: " << fmtX4(v->position()) << "
nIn: " << v->particles_in().size() << " nOut: " << v->particles_out().size() + << "
x4 (cm,ns): " << fmtSimVertexX4(sv) << "
vertexId: " << sv.vertexId() << " processType: " << sv.processType() << "
parent trackId: " << sv.parentIndex() << " noParent: " << (sv.noParent() ? "yes" : "no") + << "
bx: " << sv.eventId().bunchCrossing() << " evtInBx: " << sv.eventId().event() + << "
GenVertex nodeId: " << gv << "
p4: " << fmtP4(simt->momentum()) << "
GenParticle nodeId: " << gn << "
SimVertex nodeId: " << vn << "
crossedBoundary: true" + << " idAtBoundary=" << simt->getIDAtBoundary() << "
x4@boundary: " << fmtX4(simt->getPositionAtBoundary()) + << "
p4@boundary: " << fmtP4(simt->getMomentumAtBoundary()) + << "
\n"; + os << " >];\n"; + } + + // edges + for (uint32_t src = 0; src < n; ++src) { + const uint32_t b = g.edgeBegin(src); + const uint32_t e = g.edgeEnd(src); + + unsigned kept = 0; + for (uint32_t pos = b; pos < e; ++pos) { + const uint32_t dst = g.edges()[pos]; + if (dst >= n) + continue; + os << " n" << src << " -> n" << dst << edgeAttrs(static_cast(g.edgeKind()[pos])) + << ";\n"; + if (++kept >= maxEdgesPerNode_) + break; + } + } + + os << "}\n"; + os.close(); + } + +private: + edm::EDGetTokenT token_; + std::string dotFile_; + unsigned maxNodes_; + unsigned maxEdgesPerNode_; + + edm::EDGetTokenT simTracksToken_; + edm::EDGetTokenT simVerticesToken_; + + edm::EDGetTokenT hepmc2Token_; + edm::EDGetTokenT hepmc3Token_; +}; + +DEFINE_FWK_MODULE(TruthGraphDumper); diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc new file mode 100644 index 0000000000000..bd7b4644e0c74 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphMixedProducer.cc @@ -0,0 +1,205 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Phase-A pileup prototype: build a raw TruthGraph from the MixingModule +// crossing frames (signal + pileup) instead of the signal-only g4SimHits. +// +// The MixCollection flattens all sub-events; SimTrack::trackId(), +// SimTrack::vertIndex(), SimVertex::vertexId() and SimVertex::parentIndex() are +// all local to each sub-event, so every linking map is keyed by +// (EncodedEventId, localId). Each node carries its EncodedEventId, so signal +// (bx=0,event=0) and pileup (bx!=0 or event!=0) sub-events stay distinguishable. +// +// This first prototype builds the SIM realm only (signal + pileup SimTracks / +// SimVertices). GEN merging is signal-only in the standard producer and is not +// reproduced here: pileup has no persisted GEN history anyway. Calo/tracker hit +// crossing frames are handled separately. + +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/Exception.h" +#include "FWCore/Utilities/interface/InputTag.h" + +#include "SimDataFormats/CrossingFrame/interface/CrossingFrame.h" +#include "SimDataFormats/CrossingFrame/interface/MixCollection.h" +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" +#include "SimDataFormats/Track/interface/SimTrack.h" +#include "SimDataFormats/Vertex/interface/SimVertex.h" + +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + // Mirror of TruthGraphProducer::packEventId so the logical producer / consumers + // decode the bunch crossing the same way. + uint64_t packEventId(EncodedEventId const& id) { + uint64_t out = 0; + std::memcpy(&out, &id, sizeof(EncodedEventId)); + return out; + } + + // Key for per-sub-event disambiguation: (EncodedEventId.rawId, local id). + struct SubEventKey { + uint32_t eid; + uint32_t localId; + bool operator==(SubEventKey const& o) const { return eid == o.eid && localId == o.localId; } + }; + struct SubEventKeyHash { + std::size_t operator()(SubEventKey const& k) const { return (static_cast(k.eid) << 32) ^ k.localId; } + }; +} // namespace + +class TruthGraphMixedProducer : public edm::stream::EDProducer<> { +public: + explicit TruthGraphMixedProducer(edm::ParameterSet const&); + void produce(edm::Event&, edm::EventSetup const&) override; + static void fillDescriptions(edm::ConfigurationDescriptions&); + +private: + const edm::EDGetTokenT> simTrackToken_; + const edm::EDGetTokenT> simVertexToken_; +}; + +TruthGraphMixedProducer::TruthGraphMixedProducer(edm::ParameterSet const& cfg) + : simTrackToken_(consumes>(cfg.getParameter("simTracks"))), + simVertexToken_(consumes>(cfg.getParameter("simVertices"))) { + produces(); +} + +void TruthGraphMixedProducer::fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + desc.add("simTracks", edm::InputTag("mix", "g4SimHits")) + ->setComment("CrossingFrame from the MixingModule (signal + pileup)."); + desc.add("simVertices", edm::InputTag("mix", "g4SimHits")) + ->setComment("CrossingFrame from the MixingModule (signal + pileup)."); + descriptions.addWithDefaultLabel(desc); +} + +void TruthGraphMixedProducer::produce(edm::Event& evt, edm::EventSetup const&) { + auto const& cfTracks = evt.get(simTrackToken_); + auto const& cfVertices = evt.get(simVertexToken_); + + MixCollection simTracks(&cfTracks); + MixCollection simVertices(&cfVertices); + + auto out = std::make_unique(); + + const uint32_t nVtx = static_cast(simVertices.size()); + const uint32_t nTrk = static_cast(simTracks.size()); + const uint32_t nNodes = nVtx + nTrk; + + out->nodes().resize(nNodes); + out->pdgId().assign(nNodes, 0); + out->status().assign(nNodes, 0); + out->statusFlags().assign(nNodes, 0); + out->eventId().assign(nNodes, 0ull); + out->genEventOfNode().assign(nNodes, -1); + out->simTrackToGen().assign(nNodes, -1); + out->simTrackToVtx().assign(nNodes, -1); + out->simVtxToGen().assign(nNodes, -1); + out->simVertexProcessType().assign(nNodes, 0); + out->simTrackBackscattered().assign(nNodes, 0); + + // Vertex nodes come first (ids [0, nVtx)), tracks after (ids [nVtx, nNodes)). + // perEventVtxNodes[eid] preserves sub-event order, so the position equals the + // local vector index used by SimTrack::vertIndex(). + std::unordered_map> perEventVtxNodes; + std::unordered_map trackKey; // (eid, trackId) -> track node + std::vector> vtxParent(nVtx, {0u, -1}); // (eid, parentTrackId) per vertex node + std::vector> trkProdVtxLocal(nTrk, {0u, -1}); // (eid, local vertIndex) per track + + uint32_t v = 0; + for (auto it = simVertices.begin(); it != simVertices.end(); ++it, ++v) { + const uint32_t node = v; // vertex nodes [0, nVtx) + const EncodedEventId eid = it->eventId(); + out->nodes()[node] = TruthGraph::NodeRef{TruthGraph::NodeKind::SimVertex, static_cast(it->vertexId())}; + out->eventId()[node] = packEventId(eid); + out->simVertexProcessType()[node] = static_cast(it->processType()); + perEventVtxNodes[eid.rawId()].push_back(node); + vtxParent[v] = {eid.rawId(), it->parentIndex()}; + } + + uint32_t t = 0; + for (auto it = simTracks.begin(); it != simTracks.end(); ++it, ++t) { + const uint32_t node = nVtx + t; // track nodes [nVtx, nNodes) + const EncodedEventId eid = it->eventId(); + out->nodes()[node] = TruthGraph::NodeRef{TruthGraph::NodeKind::SimTrack, static_cast(it->trackId())}; + out->pdgId()[node] = it->type(); + out->eventId()[node] = packEventId(eid); + out->simTrackBackscattered()[node] = it->isFromBackScattering() ? 1 : 0; + trackKey[SubEventKey{eid.rawId(), it->trackId()}] = node; + trkProdVtxLocal[t] = {eid.rawId(), it->vertIndex()}; + } + + // Build the SIM bipartite edges, keyed within each sub-event. + std::vector> edgePairs; + std::vector edgeKinds; + edgePairs.reserve(2 * nTrk); + edgeKinds.reserve(2 * nTrk); + auto pushEdge = [&](uint32_t src, uint32_t dst) { + edgePairs.emplace_back(src, dst); + edgeKinds.emplace_back(static_cast(TruthGraph::EdgeKind::Sim)); + }; + + // Production edge: a track's production SimVertex is its sub-event-local vertIndex. + for (uint32_t i = 0; i < nTrk; ++i) { + const uint32_t trkNode = nVtx + i; + const auto [eid, vi] = trkProdVtxLocal[i]; + if (vi < 0) + continue; + auto evIt = perEventVtxNodes.find(eid); + if (evIt == perEventVtxNodes.end() || static_cast(vi) >= evIt->second.size()) + continue; + const uint32_t prodVtxNode = evIt->second[static_cast(vi)]; + pushEdge(prodVtxNode, trkNode); // vertex produces track + out->simTrackToVtx()[trkNode] = static_cast(prodVtxNode); + } + + // Decay edge: a vertex's parent track (SimVertex::parentIndex() is a trackId). + for (uint32_t i = 0; i < nVtx; ++i) { + const auto [eid, parentTrackId] = vtxParent[i]; + if (parentTrackId < 0) + continue; + auto kIt = trackKey.find(SubEventKey{eid, static_cast(parentTrackId)}); + if (kIt == trackKey.end()) + continue; + pushEdge(kIt->second, i); // parent track decays into vertex + } + + // CSR out-edges via the counting-sort cursor scatter: each edge lands in its + // source's range, by construction (no sort, no permutation vector). + out->offsets().assign(nNodes + 1, 0); + for (auto const& e : edgePairs) + ++out->offsets()[e.first + 1]; + for (uint32_t i = 1; i <= nNodes; ++i) + out->offsets()[i] += out->offsets()[i - 1]; + + out->edges().resize(edgePairs.size()); + out->edgeKind().resize(edgePairs.size()); + std::vector cursor = out->offsets(); + for (std::size_t e = 0; e < edgePairs.size(); ++e) { + const uint32_t pos = cursor[edgePairs[e].first]++; + out->edges()[pos] = edgePairs[e].second; + out->edgeKind()[pos] = edgeKinds[e]; + } + + if (!out->isConsistent()) + throw cms::Exception("TruthGraphMixedProducer") << "Produced TruthGraph is not consistent"; + + evt.put(std::move(out)); +} + +DEFINE_FWK_MODULE(TruthGraphMixedProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc new file mode 100644 index 0000000000000..a215e3f4cd7ec --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphProducer.cc @@ -0,0 +1,759 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Author: Felice Pantaleo - CERN +// Date: 03/2026 + +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/InputTag.h" + +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" +#include "SimDataFormats/Track/interface/SimTrackContainer.h" +#include "SimDataFormats/Vertex/interface/SimVertexContainer.h" + +// Legacy HepMC, HepMC2. +#include "SimDataFormats/GeneratorProducts/interface/HepMCProduct.h" +#include "HepMC/GenEvent.h" +#include "HepMC/GenParticle.h" +#include "HepMC/GenVertex.h" + +// HepMC3. +#include "SimDataFormats/GeneratorProducts/interface/HepMC3Product.h" +#include "HepMC3/GenEvent.h" +#include "HepMC3/GenParticle.h" +#include "HepMC3/GenVertex.h" + +#include "PhysicsTools/HepMCCandAlgos/interface/MCTruthHelper.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + + // Pack EncodedEventId into 64 bit without relying on a particular public API. + uint64_t packEventId(EncodedEventId const& id) { + uint64_t out = 0; + static_assert(sizeof(EncodedEventId) <= sizeof(uint64_t), "EncodedEventId larger than 64 bits, adjust packing"); + std::memcpy(&out, &id, sizeof(EncodedEventId)); + return out; + } + + struct DSU { + std::vector p; + std::vector r; + + explicit DSU(int n) : p(n), r(n, 0) { + for (int i = 0; i < n; ++i) + p[i] = i; + } + + int find(int x) { + while (p[x] != x) { + p[x] = p[p[x]]; + x = p[x]; + } + return x; + } + + void unite(int a, int b) { + a = find(a); + b = find(b); + + if (a == b) + return; + + if (r[a] < r[b]) + std::swap(a, b); + + p[b] = a; + + if (r[a] == r[b]) + ++r[a]; + } + }; + + inline int64_t genKeyVertex(int barcode) { return (int64_t(barcode) << 1) | 1LL; } + + inline int64_t genKeyParticle(int barcode) { return (int64_t(barcode) << 1); } + + struct GenBuild { + std::vector vtxBarcodes; + std::vector partBarcodes; + + // index -> barcode in HepMC iteration order. Kept only for diagnostics. + // SimTrack::genpartIndex() is not an index into this vector: for primary + // SimTracks it is a HepMC barcode. + std::vector particleBarcodeByIndex; + + std::vector> vtxToPart; + std::vector> partToVtx; + + std::unordered_map particlePdgIdByBarcode; + std::unordered_map particleStatusByBarcode; + // Packed reco::GenStatusFlags computed from the HepMC record via MCTruthHelper + // (the same helper GenParticleProducer uses), so no barcode-to-reco::GenParticle + // association is needed. HepMC2 only; the HepMC3 path leaves them 0 until + // MCTruthHelper grows a HepMC3 specialization. + std::unordered_map particleStatusFlagsByBarcode; + }; + + GenBuild buildFromHepMC2(HepMC::GenEvent const& ev) { + GenBuild gb; + + std::unordered_set seenV; + std::unordered_set seenP; + + gb.particlePdgIdByBarcode.reserve(ev.particles_size() * 2); + gb.particleStatusByBarcode.reserve(ev.particles_size() * 2); + gb.particleBarcodeByIndex.reserve(ev.particles_size()); + + for (auto v = ev.vertices_begin(); v != ev.vertices_end(); ++v) { + if (*v == nullptr) + continue; + + const int vbc = (*v)->barcode(); + + if (seenV.insert(vbc).second) + gb.vtxBarcodes.push_back(vbc); + + for (auto po = (*v)->particles_out_const_begin(); po != (*v)->particles_out_const_end(); ++po) { + if (*po == nullptr) + continue; + + const int pbc = (*po)->barcode(); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + + gb.vtxToPart.emplace_back(vbc, pbc); + } + + for (auto pi = (*v)->particles_in_const_begin(); pi != (*v)->particles_in_const_end(); ++pi) { + if (*pi == nullptr) + continue; + + const int pbc = (*pi)->barcode(); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + + gb.partToVtx.emplace_back(pbc, vbc); + } + } + + MCTruthHelper mcTruthHelper; + for (auto p = ev.particles_begin(); p != ev.particles_end(); ++p) { + if (*p == nullptr) + continue; + + const int pbc = (*p)->barcode(); + + gb.particleBarcodeByIndex.push_back(pbc); + gb.particlePdgIdByBarcode.emplace(pbc, (*p)->pdg_id()); + gb.particleStatusByBarcode.emplace(pbc, static_cast((*p)->status())); + + reco::GenStatusFlags flags; + mcTruthHelper.fillGenStatusFlags(**p, flags); + gb.particleStatusFlagsByBarcode.emplace(pbc, static_cast(flags.flags_.to_ulong())); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + } + + return gb; + } + + GenBuild buildFromHepMC3(HepMC3::GenEvent const& ev) { + GenBuild gb; + + std::unordered_set seenV; + std::unordered_set seenP; + + gb.particlePdgIdByBarcode.reserve(ev.particles().size() * 2); + gb.particleStatusByBarcode.reserve(ev.particles().size() * 2); + gb.particleBarcodeByIndex.reserve(ev.particles().size()); + + for (auto const& vptr : ev.vertices()) { + if (!vptr) + continue; + + const int vbc = vptr->id(); + + if (seenV.insert(vbc).second) + gb.vtxBarcodes.push_back(vbc); + + for (auto const& po : vptr->particles_out()) { + if (!po) + continue; + + const int pbc = po->id(); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + + gb.vtxToPart.emplace_back(vbc, pbc); + } + + for (auto const& pi : vptr->particles_in()) { + if (!pi) + continue; + + const int pbc = pi->id(); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + + gb.partToVtx.emplace_back(pbc, vbc); + } + } + + for (auto const& pptr : ev.particles()) { + if (!pptr) + continue; + + const int pbc = pptr->id(); + + gb.particleBarcodeByIndex.push_back(pbc); + gb.particlePdgIdByBarcode.emplace(pbc, pptr->pid()); + gb.particleStatusByBarcode.emplace(pbc, static_cast(pptr->status())); + + if (seenP.insert(pbc).second) + gb.partBarcodes.push_back(pbc); + } + + return gb; + } + + template + bool validHandle(HandleT const& h) { + return h.isValid(); + } + +} // namespace + +class TruthGraphProducer : public edm::stream::EDProducer<> { +public: + explicit TruthGraphProducer(const edm::ParameterSet& cfg) + : hepmc3Token_(mayConsume(cfg.getParameter("genEventHepMC3"))), + hepmc2Token_(mayConsume(cfg.getParameter("genEventHepMC"))), + simTrackToken_(consumes(cfg.getParameter("simTracks"))), + simVertexToken_(consumes(cfg.getParameter("simVertices"))), + addGenToSimEdges_(cfg.getParameter("addGenToSimEdges")) { + produces(); + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + + desc.add("genEventHepMC3", edm::InputTag("generatorSmeared")) + ->setComment("edm::HepMC3Product label, preferred when available"); + desc.add("genEventHepMC", edm::InputTag("generatorSmeared")) + ->setComment("edm::HepMCProduct label, legacy fallback"); + + desc.add("simTracks", edm::InputTag("g4SimHits")) + ->setComment("SimTrackContainer label, typically g4SimHits"); + desc.add("simVertices", edm::InputTag("g4SimHits")) + ->setComment("SimVertexContainer label, typically g4SimHits"); + + desc.add("addGenToSimEdges", true) + ->setComment( + "If true, add GenParticle -> SimTrack cross edges. The association is built only for primary " + "SimTracks, interpreting SimTrack::genpartIndex() as a HepMC barcode."); + + descriptions.addWithDefaultLabel(desc); + } + + void produce(edm::Event& evt, const edm::EventSetup&) override { + auto out = std::make_unique(); + + const auto& simTracks = evt.get(simTrackToken_); + const auto& simVertices = evt.get(simVertexToken_); + + GenBuild gb; + bool haveGen = false; + + { + edm::Handle h3; + evt.getByToken(hepmc3Token_, h3); + + if (validHandle(h3) && h3->GetEvent() != nullptr) { + const HepMC3::GenEventData* data = h3->GetEvent(); + + HepMC3::GenEvent ev3; + ev3.read_data(*data); + + gb = buildFromHepMC3(ev3); + haveGen = true; + } + } + + if (!haveGen) { + edm::Handle h2; + evt.getByToken(hepmc2Token_, h2); + + if (validHandle(h2) && h2->GetEvent() != nullptr) { + gb = buildFromHepMC2(*h2->GetEvent()); + haveGen = true; + } + } + + const uint32_t nSimVtx = static_cast(simVertices.size()); + const uint32_t nSimTrk = static_cast(simTracks.size()); + + int nGenEvents = 0; + + std::unordered_map tempIndex; + std::vector tempKeys; + + auto getTemp = [&](int64_t k) -> int { + auto it = tempIndex.find(k); + if (it != tempIndex.end()) + return it->second; + + const int idx = static_cast(tempKeys.size()); + tempIndex.emplace(k, idx); + tempKeys.push_back(k); + return idx; + }; + + std::vector compOfTemp; + std::unordered_map repToComp; + + if (haveGen) { + for (int vbc : gb.vtxBarcodes) + (void)getTemp(genKeyVertex(vbc)); + + for (int pbc : gb.partBarcodes) + (void)getTemp(genKeyParticle(pbc)); + + DSU dsu(static_cast(tempKeys.size())); + + for (auto const& e : gb.vtxToPart) { + dsu.unite(getTemp(genKeyVertex(e.first)), getTemp(genKeyParticle(e.second))); + } + + for (auto const& e : gb.partToVtx) { + dsu.unite(getTemp(genKeyParticle(e.first)), getTemp(genKeyVertex(e.second))); + } + + compOfTemp.resize(tempKeys.size(), -1); + + for (int i = 0; i < static_cast(tempKeys.size()); ++i) { + const int rep = dsu.find(i); + + auto it = repToComp.find(rep); + if (it == repToComp.end()) { + const int cid = nGenEvents++; + repToComp.emplace(rep, cid); + compOfTemp[i] = cid; + } else { + compOfTemp[i] = it->second; + } + } + + if (nGenEvents == 0) + nGenEvents = 1; + } + + const uint32_t nGenVtx = haveGen ? static_cast(gb.vtxBarcodes.size()) : 0u; + const uint32_t nGenPar = haveGen ? static_cast(gb.partBarcodes.size()) : 0u; + + const uint32_t baseGenEvent = 0; + const uint32_t baseGenVtx = baseGenEvent + static_cast(nGenEvents); + const uint32_t baseGenPar = baseGenVtx + nGenVtx; + const uint32_t baseSimVtx = baseGenPar + nGenPar; + const uint32_t baseSimTrk = baseSimVtx + nSimVtx; + + const uint32_t nNodes = baseSimTrk + nSimTrk; + + out->nodes().resize(nNodes); + + out->pdgId().assign(nNodes, 0); + out->status().assign(nNodes, 0); + out->eventId().assign(nNodes, 0); + out->statusFlags().assign(nNodes, 0); + out->genEventOfNode().assign(nNodes, -1); + out->simVertexProcessType().assign(nNodes, 0); + out->simTrackBackscattered().assign(nNodes, 0); + + out->simTrackToGen().assign(nNodes, -1); + out->simTrackToVtx().assign(nNodes, -1); + out->simVtxToGen().assign(nNodes, -1); + + for (int cid = 0; cid < nGenEvents; ++cid) { + const uint32_t nodeId = baseGenEvent + static_cast(cid); + + out->nodes()[nodeId] = TruthGraph::NodeRef{TruthGraph::NodeKind::GenEvent, static_cast(cid)}; + out->eventId()[nodeId] = 0; + out->genEventOfNode()[nodeId] = cid; + } + + std::unordered_map genVtxBarcodeToNode; + std::unordered_map genParBarcodeToNode; + + genVtxBarcodeToNode.reserve(nGenVtx * 2); + genParBarcodeToNode.reserve(nGenPar * 2); + + if (haveGen) { + for (uint32_t i = 0; i < nGenVtx; ++i) { + const int vbc = gb.vtxBarcodes[i]; + const uint32_t nodeId = baseGenVtx + i; + + genVtxBarcodeToNode.emplace(vbc, nodeId); + + out->nodes()[nodeId] = TruthGraph::NodeRef{TruthGraph::NodeKind::GenVertex, static_cast(vbc)}; + out->eventId()[nodeId] = 0; + + const int tidx = tempIndex.at(genKeyVertex(vbc)); + out->genEventOfNode()[nodeId] = compOfTemp[tidx]; + } + + for (uint32_t i = 0; i < nGenPar; ++i) { + const int pbc = gb.partBarcodes[i]; + const uint32_t nodeId = baseGenPar + i; + + genParBarcodeToNode.emplace(pbc, nodeId); + + out->nodes()[nodeId] = TruthGraph::NodeRef{TruthGraph::NodeKind::GenParticle, static_cast(pbc)}; + out->eventId()[nodeId] = 0; + + const int tidx = tempIndex.at(genKeyParticle(pbc)); + out->genEventOfNode()[nodeId] = compOfTemp[tidx]; + + auto itPdg = gb.particlePdgIdByBarcode.find(pbc); + if (itPdg != gb.particlePdgIdByBarcode.end()) + out->pdgId()[nodeId] = itPdg->second; + + auto itStatus = gb.particleStatusByBarcode.find(pbc); + if (itStatus != gb.particleStatusByBarcode.end()) + out->status()[nodeId] = itStatus->second; + + // Computed from the HepMC record via MCTruthHelper at build time (HepMC2); + // 0 when unavailable (HepMC3 path, missing barcode). + auto itFlags = gb.particleStatusFlagsByBarcode.find(pbc); + out->statusFlags()[nodeId] = (itFlags != gb.particleStatusFlagsByBarcode.end()) ? itFlags->second : 0; + } + } + + // Map each GEN particle barcode to its production GenVertex barcode. + // gb.vtxToPart holds (vertex barcode -> outgoing particle barcode), i.e. the + // production vertex of each outgoing particle. + std::unordered_map genPartToProdVtxBarcode; + if (haveGen) { + genPartToProdVtxBarcode.reserve(gb.vtxToPart.size() * 2); + for (auto const& e : gb.vtxToPart) + genPartToProdVtxBarcode.emplace(e.second, e.first); + } + + std::vector simVtxIndexToNode(nSimVtx, 0); + + for (uint32_t i = 0; i < nSimVtx; ++i) { + const uint32_t nodeId = baseSimVtx + i; + + simVtxIndexToNode[i] = nodeId; + + out->nodes()[nodeId] = TruthGraph::NodeRef{TruthGraph::NodeKind::SimVertex, static_cast(i)}; + out->eventId()[nodeId] = packEventId(simVertices[i].eventId()); + out->simVertexProcessType()[nodeId] = static_cast(simVertices[i].processType()); + } + + std::unordered_map simTrackIdToNode; + simTrackIdToNode.reserve(nSimTrk * 2); + + for (uint32_t i = 0; i < nSimTrk; ++i) { + auto const& simTrack = simTracks[i]; + + const uint32_t nodeId = baseSimTrk + i; + const uint32_t tid = simTrack.trackId(); + + simTrackIdToNode.emplace(tid, nodeId); + + out->nodes()[nodeId] = TruthGraph::NodeRef{TruthGraph::NodeKind::SimTrack, static_cast(tid)}; + out->pdgId()[nodeId] = simTrack.type(); + out->eventId()[nodeId] = packEventId(simTrack.eventId()); + out->simTrackBackscattered()[nodeId] = simTrack.isFromBackScattering() ? 1 : 0; + + const int vtxIdx = simTrack.vertIndex(); + if (vtxIdx >= 0 && static_cast(vtxIdx) < nSimVtx) { + out->simTrackToVtx()[nodeId] = static_cast(simVtxIndexToNode[static_cast(vtxIdx)]); + } + + // SimTrack::genpartIndex() must be used only for primary G4 tracks. + // For non-primary tracks, getPrimaryOrLastStoredID() can still contain + // a generator barcode, but that is ancestry information for orphan or + // backscattered tracks, not a direct SimTrack -> GenParticle association. + if (addGenToSimEdges_ && haveGen && simTrack.isPrimary()) { + const int barcode = simTrack.genpartIndex(); + + if (barcode != -1) { + auto it = genParBarcodeToNode.find(barcode); + + if (it != genParBarcodeToNode.end()) { + const int simPdgId = simTrack.type(); + const int genPdgId = out->nodePdgId(it->second); + + if (genPdgId == 0 || genPdgId == simPdgId) { + out->simTrackToGen()[nodeId] = static_cast(it->second); + + // Provenance SimVertex -> GenVertex association: the SimTrack's production + // SimVertex corresponds to the production GenVertex of its GenParticle. + const int32_t simVtxNode = out->simTrackToVtx()[nodeId]; + if (simVtxNode >= 0) { + auto itProd = genPartToProdVtxBarcode.find(barcode); + if (itProd != genPartToProdVtxBarcode.end()) { + auto itGV = genVtxBarcodeToNode.find(itProd->second); + if (itGV != genVtxBarcodeToNode.end()) { + const int32_t gvNode = static_cast(itGV->second); + int32_t& slot = out->simVtxToGen()[simVtxNode]; + if (slot < 0) { + slot = gvNode; + } else if (slot != gvNode) { + edm::LogPrint("TruthGraphProducer") + << "SimVertex node " << simVtxNode << " associated to multiple GenVertex nodes (" << slot + << " and " << gvNode << "); keeping the first"; + } + } + } + } + } else { + edm::LogPrint("TruthGraphProducer") + << "Rejecting primary SimTrack->GenParticle association with mismatched PDG id: " + << "simTrack index=" << i << " trackId=" << simTrack.trackId() << " genBarcode=" << barcode + << " simPdgId=" << simPdgId << " genNode=" << it->second << " genPdgId=" << genPdgId; + } + } else { + edm::LogPrint("TruthGraphProducer") + << "Rejecting primary SimTrack->GenParticle association with missing GEN barcode: " + << "simTrack index=" << i << " trackId=" << simTrack.trackId() << " genBarcode=" << barcode; + } + } + } + } + + std::vector> edgePairs; + std::vector edgeKinds; + + edgePairs.reserve(8 * (nGenVtx + nGenPar + nSimTrk)); + edgeKinds.reserve(edgePairs.capacity()); + + auto push_edge = [&](uint32_t src, uint32_t dst, TruthGraph::EdgeKind k) { + edgePairs.emplace_back(src, dst); + edgeKinds.emplace_back(static_cast(k)); + }; + + if (haveGen) { + std::unordered_map vtxIncoming; + vtxIncoming.reserve(nGenVtx * 2); + + for (int vbc : gb.vtxBarcodes) + vtxIncoming.emplace(vbc, 0); + + for (auto const& e : gb.partToVtx) { + auto it = vtxIncoming.find(e.second); + if (it != vtxIncoming.end()) + ++it->second; + } + + std::vector> rootsByComp(nGenEvents); + std::vector> allVtxByComp(nGenEvents); + + for (int vbc : gb.vtxBarcodes) { + const int tidx = tempIndex.at(genKeyVertex(vbc)); + const int cid = compOfTemp[tidx]; + + if (cid < 0 || cid >= nGenEvents) + continue; + + allVtxByComp[cid].push_back(vbc); + + if (vtxIncoming[vbc] == 0) + rootsByComp[cid].push_back(vbc); + } + + for (int cid = 0; cid < nGenEvents; ++cid) { + const uint32_t genEventNode = baseGenEvent + static_cast(cid); + + auto roots = rootsByComp[cid]; + if (roots.empty()) + roots = allVtxByComp[cid]; + + for (int vbc : roots) { + auto itV = genVtxBarcodeToNode.find(vbc); + if (itV != genVtxBarcodeToNode.end()) { + push_edge(genEventNode, itV->second, TruthGraph::EdgeKind::Gen); + } + } + } + + for (auto const& e : gb.vtxToPart) { + auto itV = genVtxBarcodeToNode.find(e.first); + auto itP = genParBarcodeToNode.find(e.second); + + if (itV != genVtxBarcodeToNode.end() && itP != genParBarcodeToNode.end()) { + push_edge(itV->second, itP->second, TruthGraph::EdgeKind::Gen); + } + } + + for (auto const& e : gb.partToVtx) { + auto itP = genParBarcodeToNode.find(e.first); + auto itV = genVtxBarcodeToNode.find(e.second); + + if (itP != genParBarcodeToNode.end() && itV != genVtxBarcodeToNode.end()) { + push_edge(itP->second, itV->second, TruthGraph::EdgeKind::Gen); + } + } + } + + // Production edges: SimVertex -> outgoing SimTrack, one per track. + for (uint32_t i = 0; i < nSimTrk; ++i) { + auto const& simTrack = simTracks[i]; + + const uint32_t childNode = baseSimTrk + i; + + const int vtxIdx = simTrack.vertIndex(); + if (vtxIdx < 0 || static_cast(vtxIdx) >= nSimVtx) + continue; + + const uint32_t vtxNode = simVtxIndexToNode[static_cast(vtxIdx)]; + + push_edge(vtxNode, childNode, TruthGraph::EdgeKind::Sim); + } + + // Decay edges: parent SimTrack -> SimVertex, one per vertex. Built in a + // separate pass over SimVertices (not inside the track loop) so the edge is + // emitted once per vertex rather than once per outgoing daughter, which would + // duplicate parentTrack -> vertex by the vertex's out-degree. + for (uint32_t i = 0; i < nSimVtx; ++i) { + const int parentTid = simVertices[i].parentIndex(); + if (parentTid <= 0) + continue; + + auto itParent = simTrackIdToNode.find(static_cast(parentTid)); + if (itParent != simTrackIdToNode.end()) { + push_edge(itParent->second, simVtxIndexToNode[i], TruthGraph::EdgeKind::Sim); + } + } + + // Cross-domain particle associations only. These edges are created only for + // primary SimTracks that carry a validated HepMC barcode. + // + // GenVertex -> SimVertex edges are intentionally not created here because + // shared Geant4 source or injection vertices can create artificial many-to-one topology. + if (addGenToSimEdges_ && haveGen) { + for (uint32_t i = 0; i < nSimTrk; ++i) { + const uint32_t simNode = baseSimTrk + i; + const int32_t genNode = out->simTrackToGen()[simNode]; + + if (genNode >= 0) { + push_edge(static_cast(genNode), simNode, TruthGraph::EdgeKind::GenToSim); + } + } + + // SimVertex -> GenVertex provenance edges. Unlike the GenVertex -> SimVertex + // direction warned about above, these are derived from per-track primary + // associations and stored as a single edge per SimVertex (simVtxToGen). + for (uint32_t i = 0; i < nSimVtx; ++i) { + const uint32_t simVtxNode = baseSimVtx + i; + const int32_t genVtxNode = out->simVtxToGen()[simVtxNode]; + + if (genVtxNode >= 0) { + push_edge(simVtxNode, static_cast(genVtxNode), TruthGraph::EdgeKind::SimToGen); + } + } + } + + out->offsets().assign(nNodes + 1, 0); + + for (auto const& e : edgePairs) { + if (e.first < nNodes) + ++out->offsets()[e.first + 1]; + } + + for (uint32_t i = 1; i <= nNodes; ++i) + out->offsets()[i] += out->offsets()[i - 1]; + + const uint32_t nEdges = out->offsets().back(); + + out->edges().assign(nEdges, 0); + out->edgeKind().assign(nEdges, static_cast(TruthGraph::EdgeKind::Gen)); + + std::vector cursor = out->offsets(); + + for (std::size_t i = 0; i < edgePairs.size(); ++i) { + const uint32_t src = edgePairs[i].first; + const uint32_t dst = edgePairs[i].second; + + if (src < nNodes && dst < nNodes) { + const uint32_t pos = cursor[src]++; + out->edges()[pos] = dst; + out->edgeKind()[pos] = edgeKinds[i]; + } + } + + unsigned nGenEventOut = 0; + unsigned nGenVertexOut = 0; + unsigned nGenParticleOut = 0; + unsigned nSimVertexOut = 0; + unsigned nSimTrackOut = 0; + unsigned nGenToSimParticleLinks = 0; + unsigned nSimVtxToGenLinks = 0; + + for (uint32_t i = 0; i < out->nNodes(); ++i) { + switch (out->nodeRef(i).kind) { + case TruthGraph::NodeKind::GenEvent: + ++nGenEventOut; + break; + case TruthGraph::NodeKind::GenVertex: + ++nGenVertexOut; + break; + case TruthGraph::NodeKind::GenParticle: + ++nGenParticleOut; + break; + case TruthGraph::NodeKind::SimVertex: + ++nSimVertexOut; + if (out->simVtxToGen()[i] >= 0) + ++nSimVtxToGenLinks; + break; + case TruthGraph::NodeKind::SimTrack: + ++nSimTrackOut; + if (out->simTrackToGen()[i] >= 0) + ++nGenToSimParticleLinks; + break; + } + } + + edm::LogPrint("TruthGraphProducer") << "TruthGraph nodes: " + << "GenEvent=" << nGenEventOut << " GenVertex=" << nGenVertexOut + << " GenParticle=" << nGenParticleOut << " SimVertex=" << nSimVertexOut + << " SimTrack=" << nSimTrackOut << " total=" << out->nNodes() + << " edges=" << out->nEdges() + << " primaryGenToSimParticleLinks=" << nGenToSimParticleLinks + << " simVtxToGenVertexLinks=" << nSimVtxToGenLinks; + + evt.put(std::move(out)); + } + +private: + edm::EDGetTokenT hepmc3Token_; + edm::EDGetTokenT hepmc2Token_; + edm::EDGetTokenT simTrackToken_; + edm::EDGetTokenT simVertexToken_; + + bool addGenToSimEdges_; +}; + +DEFINE_FWK_MODULE(TruthGraphProducer); diff --git a/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc b/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc new file mode 100644 index 0000000000000..eb6db28940830 --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthGraphTopologyChecker.cc @@ -0,0 +1,455 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Diagnostic analyzer that audits the raw TruthGraph and the logical truth::Graph +// for "strange" topologies and reports their structural provenance: +// * vertices with many outgoing particles (hadronization, showers, system vtx); +// * particles with more than one parent particle (hard scatter, multi-mother); +// * particles produced at more than one vertex (structural anomaly); +// * cycles (the graphs must be DAGs); +// * disconnected components (orphans). +// Everything is aggregated per job and printed in endJob, with a few worst-case +// examples carrying pdgId/status so the cause can be identified. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/Frameworkfwd.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/one/EDAnalyzer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" + +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" + +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + + // Running distribution of a non-negative integer quantity. + struct DegreeStats { + std::map dist; + uint32_t maxValue = 0; + uint64_t sum = 0; + uint64_t count = 0; + void add(uint32_t v) { + ++dist[v]; + maxValue = std::max(maxValue, v); + sum += v; + ++count; + } + [[nodiscard]] double mean() const { return count ? static_cast(sum) / count : 0.0; } + [[nodiscard]] uint64_t atLeast(uint32_t threshold) const { + uint64_t n = 0; + for (auto const& [v, c] : dist) + if (v >= threshold) + n += c; + return n; + } + [[nodiscard]] std::string summary() const { + std::ostringstream os; + os << "count=" << count << " mean=" << mean() << " max=" << maxValue + << " | n(=0)=" << (dist.count(0) ? dist.at(0) : 0) << " n(=1)=" << (dist.count(1) ? dist.at(1) : 0) + << " n(2-9)=" << (atLeast(2) - atLeast(10)) << " n(10-49)=" << (atLeast(10) - atLeast(50)) + << " n(>=50)=" << atLeast(50); + return os.str(); + } + }; + + // Union-find for weakly-connected components. + struct UnionFind { + std::vector parent; + explicit UnionFind(uint32_t n) : parent(n) { std::iota(parent.begin(), parent.end(), 0u); } + uint32_t find(uint32_t x) { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + void unite(uint32_t a, uint32_t b) { parent[find(a)] = find(b); } + }; + + // Kahn topological sort over a directed graph given as out-adjacency + indegree. + // Returns true if it is a DAG (all nodes processed). + bool isDag(std::vector> const& outAdj, std::vector indeg) { + std::vector stack; + for (uint32_t i = 0; i < indeg.size(); ++i) + if (indeg[i] == 0) + stack.push_back(i); + uint64_t processed = 0; + while (!stack.empty()) { + const uint32_t u = stack.back(); + stack.pop_back(); + ++processed; + for (const uint32_t v : outAdj[u]) + if (--indeg[v] == 0) + stack.push_back(v); + } + return processed == indeg.size(); + } + + void capPush(std::vector& v, std::string s, std::size_t cap = 6) { + if (v.size() < cap) + v.push_back(std::move(s)); + } + + // Decode the packed EncodedEventId (memcpy reverse of TruthGraphProducer::packEventId). + EncodedEventId decodeEid(uint64_t packed) { + uint32_t raw = 0; + std::memcpy(&raw, &packed, sizeof(raw)); + return EncodedEventId(raw); + } + +} // namespace + +class TruthGraphTopologyChecker : public edm::one::EDAnalyzer<> { +public: + explicit TruthGraphTopologyChecker(edm::ParameterSet const&); + void analyze(edm::Event const&, edm::EventSetup const&) override; + void endJob() override; + +private: + void analyzeRaw(TruthGraph const&); + void analyzeLogical(truth::Graph const&); + + const edm::EDGetTokenT rawToken_; + const edm::EDGetTokenT logicalToken_; + // When true, throw at endJob if any history-fragmentation violation (orphan + // components or cycles, raw or logical) was seen - used by the history-guard + // unit test to fail if the simulation stops producing connected parentage. + const bool failOnViolations_; + + uint64_t nEvents_ = 0; + + // ---- raw graph accumulators ---- + DegreeStats rawGenVtxOut_, rawSimVtxOut_, rawGenVtxInMothers_, rawSimVtxInMothers_; + DegreeStats rawGenProdVtx_, rawSimProdVtx_, rawGenParents_, rawSimParents_; + uint64_t rawMultiProdParticles_ = 0, rawMultiMotherVertices_ = 0, rawMultiParentParticles_ = 0; + uint64_t rawCycles_ = 0, rawComponentsTotal_ = 0, rawOrphanComponents_ = 0; + std::vector rawBigVtxEx_, rawMultiParentEx_, rawMultiProdEx_, rawOrphanEx_; + + // ---- logical graph accumulators ---- + DegreeStats logVtxOut_, logVtxInMothers_, logProdVtx_, logParents_, logDecayVtx_; + uint64_t logMultiProdParticles_ = 0, logMultiMotherVertices_ = 0, logMultiParentParticles_ = 0; + uint64_t logMultiDecayParticles_ = 0, logArtificialVertices_ = 0; + uint64_t logCycles_ = 0, logComponentsTotal_ = 0, logOrphanComponents_ = 0; + uint64_t logSignalParticles_ = 0, logPileupParticles_ = 0; + std::map logBxHist_; // bunchCrossing -> particle count + std::vector logBigVtxEx_, logMultiParentEx_, logMultiProdEx_, logOrphanEx_; +}; + +TruthGraphTopologyChecker::TruthGraphTopologyChecker(edm::ParameterSet const& cfg) + : rawToken_(consumes(cfg.getParameter("rawSrc"))), + logicalToken_(consumes(cfg.getParameter("src"))), + failOnViolations_(cfg.getUntrackedParameter("failOnViolations", false)) {} + +void TruthGraphTopologyChecker::analyze(edm::Event const& event, edm::EventSetup const&) { + ++nEvents_; + analyzeRaw(event.get(rawToken_)); + analyzeLogical(event.get(logicalToken_)); +} + +void TruthGraphTopologyChecker::analyzeRaw(TruthGraph const& g) { + using NK = TruthGraph::NodeKind; + using EK = TruthGraph::EdgeKind; + const uint32_t n = g.nNodes(); + + // Reverse pass: classify incoming edges of each node by the source's kind and + // by edge kind (structural Gen/Sim vs cross-realm GenToSim). + std::vector inFromVertex(n, 0), inFromParticle(n, 0); + std::vector firstProdVertex(n, -1); + std::vector> structOut(n); // structural out-adjacency for DAG/components + UnionFind uf(n); + + for (uint32_t s = 0; s < n; ++s) { + const auto kids = g.children(s); + const auto kinds = g.childrenEdgeKinds(s); + const NK sk = g.nodeRef(s).kind; + for (std::size_t e = 0; e < kids.size(); ++e) { + const uint32_t d = kids[e]; + const EK ek = static_cast(kinds[e]); + uf.unite(s, d); // weak connectivity uses every edge, incl. GenToSim + if (ek == EK::GenToSim || ek == EK::SimToGen) + continue; + structOut[s].push_back(d); + if (sk == NK::GenVertex || sk == NK::SimVertex) { + ++inFromVertex[d]; + if (firstProdVertex[d] < 0) + firstProdVertex[d] = static_cast(s); + } else if (sk == NK::GenParticle || sk == NK::SimTrack) { + ++inFromParticle[d]; + } + } + } + + auto nodeStr = [&](uint32_t id) { + std::ostringstream os; + os << "pdg=" << g.nodePdgId(id) << " st=" << g.nodeStatus(id); + return os.str(); + }; + + for (uint32_t v = 0; v < n; ++v) { + const NK k = g.nodeRef(v).kind; + if (k == NK::GenVertex || k == NK::SimVertex) { + const uint32_t outdeg = static_cast(structOut[v].size()); + const uint32_t mothers = inFromParticle[v]; + const bool isGen = (k == NK::GenVertex); + (isGen ? rawGenVtxOut_ : rawSimVtxOut_).add(outdeg); + (isGen ? rawGenVtxInMothers_ : rawSimVtxInMothers_).add(mothers); + if (mothers > 1) + ++rawMultiMotherVertices_; + if (outdeg >= 20) { + std::ostringstream os; + os << (isGen ? "GenVtx" : "SimVtx") << " out=" << outdeg << " mothers=" << mothers << " ["; + // mothers' pdgs: scan for particle nodes whose structural out-edge hits v + for (uint32_t s = 0, shown = 0; s < n && shown < 4; ++s) { + for (const uint32_t d : structOut[s]) + if (d == v && (g.nodeRef(s).kind == NK::GenParticle || g.nodeRef(s).kind == NK::SimTrack)) { + os << nodeStr(s) << "; "; + ++shown; + break; + } + } + os << "] sampleOut: "; + for (uint32_t j = 0; j < outdeg && j < 5; ++j) + os << nodeStr(structOut[v][j]) << "; "; + capPush(rawBigVtxEx_, os.str()); + } + } else if (k == NK::GenParticle || k == NK::SimTrack) { + const bool isGen = (k == NK::GenParticle); + const uint32_t prod = inFromVertex[v]; + (isGen ? rawGenProdVtx_ : rawSimProdVtx_).add(prod); + if (prod > 1) { + ++rawMultiProdParticles_; + std::ostringstream os; + os << (isGen ? "GenPart " : "SimTrk ") << nodeStr(v) << " nProdVtx=" << prod; + capPush(rawMultiProdEx_, os.str()); + } + if (prod >= 1 && firstProdVertex[v] >= 0) { + const uint32_t pv = static_cast(firstProdVertex[v]); + const uint32_t parents = inFromParticle[pv]; + (isGen ? rawGenParents_ : rawSimParents_).add(parents); + if (parents > 1) { + ++rawMultiParentParticles_; + std::ostringstream os; + os << (isGen ? "GenPart " : "SimTrk ") << nodeStr(v) << " parents=" << parents << " ["; + for (uint32_t s = 0, shown = 0; s < n && shown < 5; ++s) + for (const uint32_t d : structOut[s]) + if (d == pv && (g.nodeRef(s).kind == NK::GenParticle || g.nodeRef(s).kind == NK::SimTrack)) { + os << nodeStr(s) << "; "; + ++shown; + break; + } + os << "]"; + capPush(rawMultiParentEx_, os.str()); + } + } + } + } + + // DAG check over structural edges. + std::vector indeg(n, 0); + for (uint32_t s = 0; s < n; ++s) + for (const uint32_t d : structOut[s]) + ++indeg[d]; + if (!isDag(structOut, indeg)) + ++rawCycles_; + + // Weakly-connected components. + std::map compSize; + for (uint32_t i = 0; i < n; ++i) + ++compSize[uf.find(i)]; + rawComponentsTotal_ += compSize.size(); + for (auto const& [root, sz] : compSize) + if (sz < n / 2 && sz <= 50) { // small fragment relative to the event = orphan + ++rawOrphanComponents_; + if (rawOrphanEx_.size() < 6) { + std::ostringstream os; + os << "size=" << sz << " e.g. " << nodeStr(root); + capPush(rawOrphanEx_, os.str()); + } + } +} + +void TruthGraphTopologyChecker::analyzeLogical(truth::Graph const& g) { + const uint32_t nP = g.nParticles(); + const uint32_t nV = g.nVertices(); + const uint32_t n = nP + nV; // combined indexing: particle p -> p, vertex v -> nP+v + + UnionFind uf(n); + std::vector> outAdj(n); + std::vector indeg(n, 0); + + auto partStr = [&](uint32_t p) { + std::ostringstream os; + os << "pdg=" << g.particles()[p].pdgId << " st=" << g.particles()[p].status; + return os.str(); + }; + + for (uint32_t v = 0; v < nV; ++v) { + if (g.vertices()[v].isArtificial()) + ++logArtificialVertices_; + const auto out = g.outgoingParticles(v); + const auto in = g.incomingParticles(v); + logVtxOut_.add(static_cast(out.size())); + logVtxInMothers_.add(static_cast(in.size())); + if (in.size() > 1) + ++logMultiMotherVertices_; + for (const uint32_t p : out) { + outAdj[nP + v].push_back(p); + ++indeg[p]; + uf.unite(nP + v, p); + } + for (const uint32_t p : in) { + outAdj[p].push_back(nP + v); + ++indeg[nP + v]; + uf.unite(p, nP + v); + } + if (out.size() >= 20) { + std::ostringstream os; + os << "vtx out=" << out.size() << " in=" << in.size() << " mothers:["; + for (std::size_t j = 0; j < in.size() && j < 4; ++j) + os << partStr(in[j]) << "; "; + os << "] sampleOut:["; + for (std::size_t j = 0; j < out.size() && j < 5; ++j) + os << partStr(out[j]) << "; "; + os << "]"; + capPush(logBigVtxEx_, os.str()); + } + } + + for (uint32_t p = 0; p < nP; ++p) { + const auto prod = g.productionVertices(p); + const auto decay = g.decayVertices(p); + logProdVtx_.add(static_cast(prod.size())); + logDecayVtx_.add(static_cast(decay.size())); + if (prod.size() > 1) { + ++logMultiProdParticles_; + std::ostringstream os; + os << partStr(p) << " nProdVtx=" << prod.size(); + capPush(logMultiProdEx_, os.str()); + } + if (decay.size() > 1) + ++logMultiDecayParticles_; + uint32_t parents = 0; + for (const uint32_t v : prod) + parents += static_cast(g.incomingParticles(v).size()); + logParents_.add(parents); + if (parents > 1) { + ++logMultiParentParticles_; + std::ostringstream os; + os << partStr(p) << " parents=" << parents << " ["; + for (const uint32_t v : prod) + for (const uint32_t m : g.incomingParticles(v)) + os << partStr(m) << "; "; + os << "]"; + capPush(logMultiParentEx_, os.str()); + } + + // Pileup provenance: signal is (bx==0, event==0); everything else is pileup. + const EncodedEventId eid = decodeEid(g.particles()[p].eventId); + ++logBxHist_[eid.bunchCrossing()]; + if (eid.bunchCrossing() == 0 && eid.event() == 0) + ++logSignalParticles_; + else + ++logPileupParticles_; + } + + if (!isDag(outAdj, indeg)) + ++logCycles_; + + std::map compSize; + for (uint32_t i = 0; i < n; ++i) + ++compSize[uf.find(i)]; + logComponentsTotal_ += compSize.size(); + for (auto const& [root, sz] : compSize) + if (sz < n / 2 && sz <= 50) { + ++logOrphanComponents_; + if (logOrphanEx_.size() < 6) { + std::ostringstream os; + os << "size=" << sz << (root < nP ? " (particle root)" : " (vertex root)"); + capPush(logOrphanEx_, os.str()); + } + } +} + +void TruthGraphTopologyChecker::endJob() { + auto dump = [](const char* tag, std::vector const& ex) { + if (ex.empty()) + return; + std::ostringstream os; + os << tag; + for (auto const& s : ex) + os << "\n - " << s; + edm::LogPrint("TruthGraphTopologyChecker") << os.str(); + }; + + edm::LogPrint("TruthGraphTopologyChecker") + << "================ TruthGraph topology audit (" << nEvents_ << " events) ================\n" + << "[RAW] GenVtx out-degree: " << rawGenVtxOut_.summary() << "\n" + << "[RAW] SimVtx out-degree: " << rawSimVtxOut_.summary() << "\n" + << "[RAW] GenVtx mothers(in): " << rawGenVtxInMothers_.summary() << "\n" + << "[RAW] SimVtx mothers(in): " << rawSimVtxInMothers_.summary() << "\n" + << "[RAW] GenPart prod-vtx: " << rawGenProdVtx_.summary() << "\n" + << "[RAW] SimTrk prod-vtx: " << rawSimProdVtx_.summary() << "\n" + << "[RAW] GenPart parent-cnt: " << rawGenParents_.summary() << "\n" + << "[RAW] SimTrk parent-cnt: " << rawSimParents_.summary() << "\n" + << "[RAW] anomalies: multiProdParticles=" << rawMultiProdParticles_ + << " multiMotherVertices=" << rawMultiMotherVertices_ << " multiParentParticles=" << rawMultiParentParticles_ + << " cyclesEvents=" << rawCycles_ << " components(sum)=" << rawComponentsTotal_ + << " orphanFragments=" << rawOrphanComponents_; + dump("[RAW] big-out-degree vertices:", rawBigVtxEx_); + dump("[RAW] multi-parent particles:", rawMultiParentEx_); + dump("[RAW] multi-production particles:", rawMultiProdEx_); + dump("[RAW] orphan fragments:", rawOrphanEx_); + + edm::LogPrint("TruthGraphTopologyChecker") + << "---------------- logical truth::Graph ----------------\n" + << "[LOG] Vtx out-degree: " << logVtxOut_.summary() << "\n" + << "[LOG] Vtx mothers(in): " << logVtxInMothers_.summary() << "\n" + << "[LOG] Part prod-vtx: " << logProdVtx_.summary() << "\n" + << "[LOG] Part decay-vtx: " << logDecayVtx_.summary() << "\n" + << "[LOG] Part parent-cnt: " << logParents_.summary() << "\n" + << "[LOG] anomalies: multiProdParticles=" << logMultiProdParticles_ + << " multiDecayParticles=" << logMultiDecayParticles_ << " multiMotherVertices=" << logMultiMotherVertices_ + << " multiParentParticles=" << logMultiParentParticles_ << " artificialVertices=" << logArtificialVertices_ + << " cyclesEvents=" << logCycles_ << " components(sum)=" << logComponentsTotal_ + << " orphanFragments=" << logOrphanComponents_; + dump("[LOG] big-out-degree vertices:", logBigVtxEx_); + dump("[LOG] multi-parent particles:", logMultiParentEx_); + dump("[LOG] multi-production particles:", logMultiProdEx_); + dump("[LOG] orphan fragments:", logOrphanEx_); + + std::ostringstream bx; + for (auto const& [b, c] : logBxHist_) + bx << " bx" << b << "=" << c; + edm::LogPrint("TruthGraphTopologyChecker") + << "[LOG] pileup provenance: signalParticles(bx=0,ev=0)=" << logSignalParticles_ + << " pileupParticles=" << logPileupParticles_ << " | per-bunchCrossing:" << bx.str(); + + // History guard: a disconnected (orphan) fragment or a parentage cycle means the + // SimTrack/SimVertex history no longer forms one tree reaching the generator - + // exactly the regression a simulation change that drops the per-track parentage + // (e.g. a GPU port) would cause. Fail hard when asked to (the history-guard test). + if (failOnViolations_) { + const uint64_t violations = rawOrphanComponents_ + logOrphanComponents_ + rawCycles_ + logCycles_; + if (violations != 0) + throw cms::Exception("TruthGraphHistoryBroken") + << "Truth-graph history is fragmented over " << nEvents_ + << " events: raw orphanFragments=" << rawOrphanComponents_ << " cyclesEvents=" << rawCycles_ + << ", logical orphanFragments=" << logOrphanComponents_ << " cyclesEvents=" << logCycles_ + << ". The SimTrack/SimVertex parentage is no longer fully connected to the generator."; + } +} + +DEFINE_FWK_MODULE(TruthGraphTopologyChecker); diff --git a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc new file mode 100644 index 0000000000000..592bc7f0c21db --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphDumper.cc @@ -0,0 +1,999 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/one/EDAnalyzer.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/InputTag.h" + +#include "DataFormats/HGCRecHit/interface/HGCRecHitCollections.h" +#include "DataFormats/ParticleFlowReco/interface/PFRecHit.h" + +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" + +namespace { + + std::string pdgNameUtf8(int pdgId) { + const int ap = std::abs(pdgId); + + if (pdgId == 11) + return "e-"; + if (pdgId == -11) + return "e+"; + if (pdgId == 13) + return "mu-"; + if (pdgId == -13) + return "mu+"; + if (pdgId == 15) + return "tau-"; + if (pdgId == -15) + return "tau+"; + + if (pdgId == 12) + return "nu_e"; + if (pdgId == -12) + return "anti-nu_e"; + if (pdgId == 14) + return "nu_mu"; + if (pdgId == -14) + return "anti-nu_mu"; + if (pdgId == 16) + return "nu_tau"; + if (pdgId == -16) + return "anti-nu_tau"; + + if (pdgId == 22) + return "gamma"; + if (pdgId == 21) + return "g"; + if (pdgId == 23) + return "Z0"; + if (pdgId == 24) + return "W+"; + if (pdgId == -24) + return "W-"; + if (pdgId == 25) + return "H"; + + if (pdgId == 2212) + return "p"; + if (pdgId == -2212) + return "anti-p"; + if (pdgId == 2112) + return "n"; + if (pdgId == -2112) + return "anti-n"; + + if (pdgId == 111) + return "pi0"; + if (pdgId == 211) + return "pi+"; + if (pdgId == -211) + return "pi-"; + if (pdgId == 321) + return "K+"; + if (pdgId == -321) + return "K-"; + if (pdgId == 130) + return "K0_L"; + if (pdgId == 310) + return "K0_S"; + + if (ap >= 1 && ap <= 6) { + static const char* qname[7] = {"", "d", "u", "s", "c", "b", "t"}; + std::string s = qname[ap]; + if (pdgId < 0) + s = "anti-" + s; + return s; + } + + return "pdg"; + } + + std::string pdgLabel(int pdgId) { + std::ostringstream ss; + const std::string name = pdgNameUtf8(pdgId); + if (name == "pdg") + ss << "pdg(" << pdgId << ")"; + else + ss << name << " (" << pdgId << ")"; + return ss.str(); + } + + const char* rawKindName(TruthGraph::NodeKind k) { + switch (k) { + case TruthGraph::NodeKind::GenEvent: + return "GenEvent"; + case TruthGraph::NodeKind::GenVertex: + return "GenVertex"; + case TruthGraph::NodeKind::GenParticle: + return "GenParticle"; + case TruthGraph::NodeKind::SimVertex: + return "SimVertex"; + case TruthGraph::NodeKind::SimTrack: + return "SimTrack"; + } + return "Unknown"; + } + + std::string rawNodeSummary(TruthGraph const* raw, int32_t nodeId) { + if (raw == nullptr || nodeId < 0 || static_cast(nodeId) >= raw->nNodes()) + return "n/a"; + + auto const& r = raw->nodeRef(static_cast(nodeId)); + + std::ostringstream ss; + ss << rawKindName(r.kind) << " #" << nodeId << " key=" << r.key; + return ss.str(); + } + + template + std::string fmtX4(X4 const& x4) { + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss.precision(3); + ss << "(" << x4.x() << ", " << x4.y() << ", " << x4.z() << ", " << x4.t() << ")"; + return ss.str(); + } + + template + std::string fmtP4(P4 const& p4) { + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss.precision(3); + ss << "(" << p4.px() << ", " << p4.py() << ", " << p4.pz() << ", " << p4.e() << ")"; + return ss.str(); + } + + const char* logicalVertexDomain(truth::VertexData const& d) { + // Artificial source vertices (Interaction / Upstream-ISR / UnderlyingEvent) + // have no GEN or SIM back-reference by construction; they are graph-internal + // bookkeeping nodes, so they get their own "Internal" domain rather than + // looking like an unclassified real vertex. + if (d.isArtificial()) + return "Internal"; + if (d.hasGen() && !d.hasSim()) + return "GEN"; + if (!d.hasGen() && d.hasSim()) + return "SIM"; + if (d.hasGen() && d.hasSim()) + return "GEN+SIM"; + return "UNKNOWN"; + } + + std::string statusFlagsLabel(uint16_t flags) { + struct FlagInfo { + uint16_t bit; + const char* name; + }; + + static constexpr FlagInfo flagInfos[] = { + {1u << 0, "isPrompt"}, + {1u << 1, "isDecayedLeptonHadron"}, + {1u << 2, "isTauDecayProduct"}, + {1u << 3, "isPromptTauDecayProduct"}, + {1u << 4, "isDirectTauDecayProduct"}, + {1u << 5, "isDirectPromptTauDecayProduct"}, + {1u << 6, "isDirectHadronDecayProduct"}, + {1u << 7, "isHardProcess"}, + {1u << 8, "fromHardProcess"}, + {1u << 9, "isHardProcessTauDecayProduct"}, + {1u << 10, "isDirectHardProcessTauDecayProduct"}, + {1u << 11, "fromHardProcessBeforeFSR"}, + {1u << 12, "isFirstCopy"}, + {1u << 13, "isLastCopy"}, + {1u << 14, "isLastCopyBeforeFSR"}, + }; + + std::ostringstream ss; + bool first = true; + + for (auto const& flag : flagInfos) { + if ((flags & flag.bit) == 0) + continue; + + if (!first) + ss << ", "; + ss << flag.name; + first = false; + } + + if (first) + return "none"; + + return ss.str(); + } + + std::string fmtEnergy(float energy) { + std::ostringstream ss; + ss.setf(std::ios::fixed); + ss << std::setprecision(6) << energy; + return ss.str(); + } + + struct HitSummary { + uint32_t nSimHits = 0; + uint32_t nMatchedRecHits = 0; + uint32_t nMissingRecHits = 0; + float simHitEnergy = 0.f; + float recHitEnergy = 0.f; + }; + + HitSummary summarizeHits(std::span hits, + std::vector const& recHitEnergies) { + HitSummary summary; + summary.nSimHits = static_cast(hits.size()); + + for (auto const& hit : hits) { + summary.simHitEnergy += hit.energy; + + if (hit.recHitIndex == truth::LogicalGraphHitIndex::Hit::kInvalidRecHitIndex || + hit.recHitIndex >= recHitEnergies.size()) { + ++summary.nMissingRecHits; + continue; + } + + ++summary.nMatchedRecHits; + summary.recHitEnergy += recHitEnergies[hit.recHitIndex]; + } + + return summary; + } + + template + void forEachDescendantParticle(truth::Graph const& g, uint32_t particleId, F&& f) { + if (particleId >= g.nParticles()) + return; + + // Iterative DFS with a visited set: cycle-safe (a stray cycle would otherwise + // overflow the stack) and visits each descendant exactly once (no exponential + // blow-up on re-convergent / diamond topologies). f is invoked once per + // distinct descendant, never on the start particle itself. + std::vector visited(g.nParticles(), 0); + std::vector stack; + visited[particleId] = 1; + stack.push_back(particleId); + + while (!stack.empty()) { + const uint32_t current = stack.back(); + stack.pop_back(); + + for (const uint32_t vertexId : g.decayVertices(current)) { + if (vertexId >= g.nVertices()) + continue; + + for (const uint32_t childId : g.outgoingParticles(vertexId)) { + if (childId >= g.nParticles() || visited[childId]) + continue; + visited[childId] = 1; + f(childId); + stack.push_back(childId); + } + } + } + } + + bool hasVisibleIncomingParticle(truth::Graph const& g, uint32_t vertexId, std::vector const& hideParticle) { + for (const uint32_t p : g.incomingParticles(vertexId)) { + if (p < hideParticle.size() && !hideParticle[p]) + return true; + } + + return false; + } + + bool hasVisibleOutgoingParticle(truth::Graph const& g, uint32_t vertexId, std::vector const& hideParticle) { + for (const uint32_t p : g.outgoingParticles(vertexId)) { + if (p < hideParticle.size() && !hideParticle[p]) + return true; + } + + return false; + } + + bool shouldHideVertexAfterParticleFiltering(truth::Graph const& g, + uint32_t vertexId, + std::vector const& hideParticle) { + const bool hasIncoming = !g.incomingParticles(vertexId).empty(); + const bool hasOutgoing = !g.outgoingParticles(vertexId).empty(); + + const bool hasVisibleIncoming = hasVisibleIncomingParticle(g, vertexId, hideParticle); + const bool hasVisibleOutgoing = hasVisibleOutgoingParticle(g, vertexId, hideParticle); + + // Hide vertices fully disconnected by the particle filter. + if (!hasVisibleIncoming && !hasVisibleOutgoing) + return true; + + // Hide decay vertices that no longer have visible daughters. + if (hasOutgoing && !hasVisibleOutgoing) + return true; + + // Hide production/source vertices that no longer have visible outgoing particles. + if (!hasIncoming && hasOutgoing && !hasVisibleOutgoing) + return true; + + return false; + } + + std::string appendEventIdToFilename(std::string const& filename, edm::EventID const& id) { + const auto dotPos = filename.rfind('.'); + + std::ostringstream ss; + + if (dotPos == std::string::npos) { + ss << filename; + ss << "_run" << id.run(); + ss << "_lumi" << id.luminosityBlock(); + ss << "_event" << id.event(); + return ss.str(); + } + + ss << filename.substr(0, dotPos); + ss << "_run" << id.run(); + ss << "_lumi" << id.luminosityBlock(); + ss << "_event" << id.event(); + ss << filename.substr(dotPos); + + return ss.str(); + } + +} // namespace + +class TruthLogicalGraphDumper : public edm::one::EDAnalyzer<> { +public: + explicit TruthLogicalGraphDumper(const edm::ParameterSet& cfg) + : token_(consumes(cfg.getParameter("src"))), + rawToken_(mayConsume(cfg.getParameter("rawSrc"))), + hitIndexTag_(cfg.getParameter("hitIndex")), + hitIndexToken_(mayConsume(hitIndexTag_)), + useHitIndex_(!hitIndexTag_.label().empty()), + dotFile_(cfg.getParameter("dotFile")), + layout_(cfg.getParameter("layout")), + maxParticles_(cfg.getParameter("maxParticles")), + maxVertices_(cfg.getParameter("maxVertices")), + maxEdgesPerNode_(cfg.getParameter("maxEdgesPerNode")), + hideLargeSimSourceVertices_(cfg.getParameter("hideLargeSimSourceVertices")), + dumpSimHits_(cfg.getParameter("dumpSimHits")), + largeSimSourceVertexMinOutgoing_(cfg.getParameter("largeSimSourceVertexMinOutgoing")), + hideZeroSimHitSubgraphs_(cfg.getParameter("hideZeroSimHitSubgraphs")) { + const auto hgcalRecHitTags = cfg.getParameter>("hgcalRecHits"); + hgcalRecHitTags_.reserve(hgcalRecHitTags.size()); + hgcalRecHitTokens_.reserve(hgcalRecHitTags.size()); + + for (auto const& tag : hgcalRecHitTags) { + hgcalRecHitTags_.push_back(tag); + hgcalRecHitTokens_.push_back(mayConsume(tag)); + } + + const auto pfRecHitTags = cfg.getParameter>("pfRecHits"); + pfRecHitTags_.reserve(pfRecHitTags.size()); + pfRecHitTokens_.reserve(pfRecHitTags.size()); + + for (auto const& tag : pfRecHitTags) { + pfRecHitTags_.push_back(tag); + pfRecHitTokens_.push_back(mayConsume(tag)); + } + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + + desc.add("src", edm::InputTag("truthLogicalGraphProducer")); + desc.add("rawSrc", edm::InputTag("truthGraphProducer")) + ->setComment("Optional raw TruthGraph used only to enrich labels"); + + desc.add("hitIndex", edm::InputTag("")) + ->setComment("Optional LogicalGraphHitIndex used to annotate particles with SimHit and RecHit summaries"); + + // These two lists MUST match DetIdToRecHitMapProducer's hgcalRecHits/pfRecHits + // (same collections, same order): hit.recHitIndex is the global concatenation + // index into HGC-then-PF, so any divergence makes recHitEnergies[recHitIndex] + // read the wrong hit. Defaults mirror that producer's defaults. + desc.add>("hgcalRecHits", + {edm::InputTag("HGCalRecHit", "HGCEERecHits"), + edm::InputTag("HGCalRecHit", "HGCHEFRecHits"), + edm::InputTag("HGCalRecHit", "HGCHEBRecHits")}) + ->setComment("HGCRecHit collections, in the same order used by DetIdToRecHitMapProducer"); + + desc.add>("pfRecHits", + {edm::InputTag("particleFlowRecHitECAL", "Cleaned"), + edm::InputTag("particleFlowRecHitHBHE", "Cleaned"), + edm::InputTag("particleFlowRecHitHF", "Cleaned"), + edm::InputTag("particleFlowRecHitHO", "Cleaned")}) + ->setComment("PFRecHit collections, in the same order used by DetIdToRecHitMapProducer"); + + desc.add("dotFile", "truthlogicalgraph.dot"); + desc.add("layout", "dot") + ->setComment( + "DOT layout: 'dot' (default, hierarchical left-to-right ranks) or a force-directed engine " + "name ('sfdp'/'fdp'/'neato') for node repulsion + spring edges toward parents (no forced ranks)"); + + desc.add("maxParticles", 5000)->setComment("Truncate logical particle nodes"); + desc.add("maxVertices", 5000)->setComment("Truncate logical vertex nodes"); + desc.add("maxEdgesPerNode", 200)->setComment("Truncate fanout per node"); + + desc.add("hideLargeSimSourceVertices", true) + ->setComment("If true, do not print large SIM-only source vertices in the DOT output"); + desc.add("dumpSimHits", false)->setComment("If true, dump all simhits"); + + desc.add("largeSimSourceVertexMinOutgoing", 50) + ->setComment("Minimum outgoing multiplicity for hiding a SIM-only source vertex"); + + desc.add("hideZeroSimHitSubgraphs", false) + ->setComment( + "If true, hide every SIM-backed particle whose subgraph has zero SimHits, together with its descendant " + "subgraph. Requires hitIndex to be configured."); + + descriptions.addWithDefaultLabel(desc); + } + + void analyze(const edm::Event& evt, const edm::EventSetup&) override { + auto const& g = evt.get(token_); + + edm::Handle hRaw; + evt.getByToken(rawToken_, hRaw); + TruthGraph const* raw = hRaw.isValid() ? &(*hRaw) : nullptr; + + edm::Handle hHitIndex; + if (useHitIndex_) { + evt.getByToken(hitIndexToken_, hHitIndex); + } + truth::LogicalGraphHitIndex const* hitIndex = hHitIndex.isValid() ? &(*hHitIndex) : nullptr; + + const std::vector recHitEnergies = collectRecHitEnergies(evt); + + const std::string eventDotFile = appendEventIdToFilename(dotFile_, evt.id()); + + std::ofstream os(eventDotFile); + + os << "digraph TruthLogicalGraph {\n"; + if (layout_.empty() || layout_ == "dot") { + os << " rankdir=LR;\n"; // hierarchical: left-to-right ranks (the default) + } else { + // Force-directed engine (sfdp / fdp / neato / ...): nodes repel each other and + // edges act as springs pulling children toward their parents, with overlap + // removal and no forced ranks. The `layout` graph attribute makes the standard + // `dot -Tsvg` invocation use that engine. + os << " layout=\"" << layout_ << "\";\n"; + os << " overlap=\"prism\";\n"; // remove node overlaps + os << " splines=line;\n"; // straight edges (spline routing is very slow on big graphs) + os << " K=1.0;\n"; // preferred edge length / spring constant + } + os << " node [fontsize=10];\n"; + + const uint32_t nParticles = std::min(g.nParticles(), maxParticles_); + const uint32_t nVertices = std::min(g.nVertices(), maxVertices_); + + std::vector hideVertex(nVertices, 0); + + if (hideLargeSimSourceVertices_) { + for (uint32_t i = 0; i < nVertices; ++i) { + auto v = g.vertex(i); + auto const& d = v.data(); + + const auto incoming = v.incomingParticles(); + const auto outgoing = v.outgoingParticles(); + + if (!d.hasGen() && d.hasSim() && incoming.empty() && outgoing.size() >= largeSimSourceVertexMinOutgoing_) { + hideVertex[i] = 1; + } + } + } + + std::vector hideParticle(nParticles, 0); + + if (hideZeroSimHitSubgraphs_) { + if (hitIndex == nullptr) { + edm::LogWarning("TruthLogicalGraphDumper") + << "hideZeroSimHitSubgraphs is enabled, but no valid LogicalGraphHitIndex was provided. " + << "No zero-hit subgraphs will be hidden."; + } else { + for (uint32_t i = 0; i < nParticles; ++i) { + if (!g.particle(i).data().hasSim()) + continue; + + if (i >= hitIndex->nParticles()) + continue; + + // "Hitless" means no detectable hit in ANY channel: a particle that + // deposits only in the tracker / MTD / muon system (but never reaches + // HGCal) must not be treated as hitless and hidden. + bool hasAnySubgraphHit = false; + for (std::size_t ch = 0; ch < truth::kNumHitChannels; ++ch) { + if (!hitIndex->subgraphHits(static_cast(ch), i).empty()) { + hasAnySubgraphHit = true; + break; + } + } + if (hasAnySubgraphHit) + continue; + + hideParticle[i] = 1; + + forEachDescendantParticle(g, i, [&](uint32_t childId) { + if (childId < hideParticle.size()) + hideParticle[childId] = 1; + }); + } + } + } + + for (uint32_t i = 0; i < nVertices; ++i) { + if (hideVertex[i]) + continue; + + if (shouldHideVertexAfterParticleFiltering(g, i, hideParticle)) { + hideVertex[i] = 1; + } + } + + // Suppress particles that would render with no edge at all (floating "Particle + // N" boxes): the leaf tail of a vertex whose outgoing edges are truncated past + // maxEdgesPerNode_, or particles whose only incident vertices are hidden. They + // are not graph orphans (their production vertex exists) - the crowded DOT view + // just cannot draw the edge. Mirror the emission loops' per-node cap so the same + // edges are considered drawn. (No-op in --showAll: maxEdgesPerNode_ is huge.) + { + std::vector hasVisibleEdge(nParticles, 0); + for (uint32_t v = 0; v < nVertices; ++v) { + if (hideVertex[v]) + continue; + unsigned kept = 0; + for (uint32_t p : g.outgoingParticles(v)) { + if (p >= nParticles || hideParticle[p]) + continue; + hasVisibleEdge[p] = 1; // a production edge v -> p will be drawn + if (++kept >= maxEdgesPerNode_) + break; + } + } + for (uint32_t i = 0; i < nParticles; ++i) { + if (hideParticle[i] || hasVisibleEdge[i]) + continue; + for (uint32_t v : g.decayVertices(i)) { // any visible decay vertex gives a drawn p -> v edge + if (v < nVertices && !hideVertex[v]) { + hasVisibleEdge[i] = 1; + break; + } + } + } + for (uint32_t i = 0; i < nParticles; ++i) { + if (!hideParticle[i] && !hasVisibleEdge[i]) + hideParticle[i] = 1; + } + } + + // ------------------------------------------------------------------ + // Particle nodes + // ------------------------------------------------------------------ + for (uint32_t i = 0; i < nParticles; ++i) { + if (hideParticle[i]) + continue; + + auto p = g.particle(i); + auto const& d = p.data(); + + const bool hasHitInfo = hitIndex != nullptr && i < hitIndex->nParticles(); + + const auto directHits = hasHitInfo ? hitIndex->directHits(truth::HitChannel::HGCalCalo, i) + : std::span(); + const auto subgraphHits = hasHitInfo ? hitIndex->subgraphHits(truth::HitChannel::HGCalCalo, i) + : std::span(); + + const HitSummary directSummary = hasHitInfo ? summarizeHits(directHits, recHitEnergies) : HitSummary(); + const HitSummary subgraphSummary = hasHitInfo ? summarizeHits(subgraphHits, recHitEnergies) : HitSummary(); + + // Tracker simhits (separate channel, no recHit association). Reusing + // summarizeHits is fine: tracker hits have an invalid recHitIndex, so only + // nSimHits and simHitEnergy (energy loss) carry meaning. + const bool hasTrackerInfo = hasHitInfo && hitIndex->hasChannel(truth::HitChannel::Tracker); + const auto trackerDirectHits = hasTrackerInfo ? hitIndex->directHits(truth::HitChannel::Tracker, i) + : std::span(); + const auto trackerSubgraphHits = hasTrackerInfo ? hitIndex->subgraphHits(truth::HitChannel::Tracker, i) + : std::span(); + const HitSummary trackerDirectSummary = + hasTrackerInfo ? summarizeHits(trackerDirectHits, recHitEnergies) : HitSummary(); + const HitSummary trackerSubgraphSummary = + hasTrackerInfo ? summarizeHits(trackerSubgraphHits, recHitEnergies) : HitSummary(); + + // MTD (BTL/ETL) simhits from the MtdSimLayerCluster channel. + const bool hasMtdInfo = hasHitInfo && hitIndex->hasChannel(truth::HitChannel::MTD); + const HitSummary mtdDirectSummary = + hasMtdInfo ? summarizeHits(hitIndex->directHits(truth::HitChannel::MTD, i), recHitEnergies) : HitSummary(); + const HitSummary mtdSubgraphSummary = + hasMtdInfo ? summarizeHits(hitIndex->subgraphHits(truth::HitChannel::MTD, i), recHitEnergies) : HitSummary(); + + // MTD recHitIndex points into the FTLCluster ordering (channel-relative, not + // the HGCal recHit ordering), so count the FTLCluster-linked hits directly. + uint32_t mtdSubgraphRecHitLinked = 0; + if (hasMtdInfo) + for (auto const& h : hitIndex->subgraphHits(truth::HitChannel::MTD, i)) + mtdSubgraphRecHitLinked += static_cast(h.hasRecHit()); + + // Muon-chamber simhits (DT/CSC/RPC/GEM), no recHit link. + const bool hasMuonInfo = hasHitInfo && hitIndex->hasChannel(truth::HitChannel::Muon); + const HitSummary muonDirectSummary = + hasMuonInfo ? summarizeHits(hitIndex->directHits(truth::HitChannel::Muon, i), recHitEnergies) : HitSummary(); + const HitSummary muonSubgraphSummary = + hasMuonInfo ? summarizeHits(hitIndex->subgraphHits(truth::HitChannel::Muon, i), recHitEnergies) + : HitSummary(); + + os << " p" << i << " [shape=ellipse, hasCheckpoints=" << p.hasCheckpoints() << ", hasGen=" << p.hasGen() + << ", hasSim=" << d.hasSim(); + + if (p.hasCheckpoints()) { + os << ", color=\"red\", penwidth=2"; + } else if (d.hasGen() && d.hasSim()) { + os << ", penwidth=2"; + } else if (d.hasGen()) { + os << ", color=\"blue\""; + } else if (d.hasSim()) { + os << ", color=\"darkgreen\""; + } + + os << ", pid=" << d.pdgId << ", status=" << d.status << ", statusFlags=" << d.statusFlags << ", flags=<" + << statusFlagsLabel(d.statusFlags) << ">" + << ", eid=" << d.eventId << ", genEvent=" << d.genEvent << ", isRoot=" << p.isRoot() + << ", isLeaf=" << p.isLeaf() << ", p4=\"" << fmtP4(d.momentum) + << "\", nProdVtx=" << p.productionVertices().size() << ", nDecayVtx=" << p.decayVertices().size() + << ", nParents=" << p.parents().size() << ", nChildren=" << p.children().size() + << ", nCheckpoints=" << d.checkpoints.size() << ", backscattered=" << d.backscattered; + + if (hasHitInfo) { + os << ", nDirectSimHits=" << directSummary.nSimHits << ", nDirectRecHits=" << directSummary.nMatchedRecHits + << ", directSimHitEnergy=" << fmtEnergy(directSummary.simHitEnergy) + << ", directRecHitEnergy=" << fmtEnergy(directSummary.recHitEnergy) + << ", nSubgraphSimHits=" << subgraphSummary.nSimHits + << ", nSubgraphRecHits=" << subgraphSummary.nMatchedRecHits + << ", subgraphSimHitEnergy=" << fmtEnergy(subgraphSummary.simHitEnergy) + << ", subgraphRecHitEnergy=" << fmtEnergy(subgraphSummary.recHitEnergy); + if (hasTrackerInfo) { + os << ", nDirectTrackerSimHits=" << trackerDirectSummary.nSimHits + << ", directTrackerSimHitEnergy=" << fmtEnergy(trackerDirectSummary.simHitEnergy) + << ", nSubgraphTrackerSimHits=" << trackerSubgraphSummary.nSimHits + << ", subgraphTrackerSimHitEnergy=" << fmtEnergy(trackerSubgraphSummary.simHitEnergy); + } + if (hasMtdInfo) { + os << ", nDirectMtdSimHits=" << mtdDirectSummary.nSimHits + << ", directMtdSimHitEnergy=" << fmtEnergy(mtdDirectSummary.simHitEnergy) + << ", nSubgraphMtdSimHits=" << mtdSubgraphSummary.nSimHits + << ", subgraphMtdSimHitEnergy=" << fmtEnergy(mtdSubgraphSummary.simHitEnergy) + << ", nSubgraphMtdRecHits=" << mtdSubgraphRecHitLinked; + } + if (hasMuonInfo) { + os << ", nDirectMuonSimHits=" << muonDirectSummary.nSimHits + << ", directMuonSimHitEnergy=" << fmtEnergy(muonDirectSummary.simHitEnergy) + << ", nSubgraphMuonSimHits=" << muonSubgraphSummary.nSimHits + << ", subgraphMuonSimHitEnergy=" << fmtEnergy(muonSubgraphSummary.simHitEnergy); + } + if (dumpSimHits_) { + os << ", directHitsDetIds=\""; + for (auto h : directHits) { + os << h.detId << ","; + } + os << "\""; + os << ", directHitsEnergies=\""; + for (auto h : directHits) { + os << h.energy << ","; + } + os << "\""; + } + } + + if (raw != nullptr) { + os << ", raw_GEN=<" << rawNodeSummary(raw, d.genNode) << ">, raw_SIM=<" << rawNodeSummary(raw, d.simNode) + << ">"; + } + + // Big, immediately-legible particle name + PDG id as the table's title row + // (HTML-like labels cannot mix free text and a TABLE), with the details below. + const std::string bigName = (!p.hasGen() && !d.hasSim()) ? std::string("connector") : pdgLabel(d.pdgId); + os << ", label=<\n"; + os << " \n"; + os << " \n"; + os << " \n"; + + if (d.pdgId != 0) + os << " \n"; + + if (d.status != 0) + os << " \n"; + + if (d.statusFlags != 0) { + os << " \n"; + os << " \n"; + } + + if (d.backscattered) + os << " \n"; + + if (d.eventId != 0) + os << " \n"; + + if (d.genEvent >= 0) + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + if (hasHitInfo) { + os << " \n"; + os << " \n"; + + os << " \n"; + os << " \n"; + } + + if (hasTrackerInfo) { + os << " \n"; + os << " \n"; + } + + for (auto const& cp : d.checkpoints) { + os << " \n"; + os << " \n"; + os << " \n"; + } + + if (raw != nullptr) { + os << " \n"; + os << " \n"; + } + + os << "
" << bigName << "
Particle " << i << "
pid: " << pdgLabel(d.pdgId) << "
status: " << d.status << "
statusFlags: " << d.statusFlags << "
flags: " << statusFlagsLabel(d.statusFlags) << "
back-scattered
eid: " << d.eventId << "
genEvent: " << d.genEvent << "
hasGen: " << (d.hasGen() ? "yes" : "no") << " hasSim: " << (d.hasSim() ? "yes" : "no") + << "
isRoot: " << (p.isRoot() ? "yes" : "no") << " isLeaf: " << (p.isLeaf() ? "yes" : "no") + << "
p4: " << fmtP4(d.momentum) << "
nProdVtx: " << p.productionVertices().size() << " nDecayVtx: " << p.decayVertices().size() + << "
nParents: " << p.parents().size() << " nChildren: " << p.children().size() + << "
nCheckpoints: " << d.checkpoints.size() << "
direct simHits: " << directSummary.nSimHits + << " simE=" << fmtEnergy(directSummary.simHitEnergy) << "
direct recHits: " << directSummary.nMatchedRecHits + << " missing=" << directSummary.nMissingRecHits << " recoE=" << fmtEnergy(directSummary.recHitEnergy) + << "
subgraph simHits: " << subgraphSummary.nSimHits + << " simE=" << fmtEnergy(subgraphSummary.simHitEnergy) << "
subgraph recHits: " << subgraphSummary.nMatchedRecHits + << " missing=" << subgraphSummary.nMissingRecHits << " recoE=" << fmtEnergy(subgraphSummary.recHitEnergy) + << "
direct tracker simHits: " << trackerDirectSummary.nSimHits + << " dE=" << fmtEnergy(trackerDirectSummary.simHitEnergy) << "
subgraph tracker simHits: " << trackerSubgraphSummary.nSimHits + << " dE=" << fmtEnergy(trackerSubgraphSummary.simHitEnergy) << "
checkpointId: " << cp.checkpointId << "
x4@checkpoint: " << fmtP4(cp.position) << "
p4@checkpoint: " << fmtP4(cp.momentum) << "
raw GEN: " << rawNodeSummary(raw, d.genNode) << "
raw SIM: " << rawNodeSummary(raw, d.simNode) << "
\n"; + os << " >];\n"; + } + + // ------------------------------------------------------------------ + // Vertex nodes + // ------------------------------------------------------------------ + for (uint32_t i = 0; i < nVertices; ++i) { + if (hideVertex[i]) + continue; + + auto v = g.vertex(i); + auto const& d = v.data(); + + const auto& incoming = v.incomingParticles(); + const auto& outgoing = v.outgoingParticles(); + + const char* roleName = nullptr; + const char* roleColor = "lightgrey"; + switch (d.vertexRole()) { + case truth::VertexRole::Interaction: + roleName = "interaction"; + roleColor = "indianred1"; + break; + case truth::VertexRole::Upstream: + roleName = "ISR/upstream"; + roleColor = "navajowhite"; + break; + case truth::VertexRole::UnderlyingEvent: + roleName = "underlying event"; + roleColor = "lightgrey"; + break; + default: + break; + } + + os << " v" << i << " [shape=diamond, domain=<" << logicalVertexDomain(d) << ">, hasGen=" << d.hasGen() + << ", hasSim=" << d.hasSim() << ", eid=" << d.eventId << ", genEvent=" << d.genEvent << ", reason=\"" + << truth::vertexReasonName(d.vertexReason()) << "\"" << ", isSource=" << v.isSource() + << ", isSink=" << v.isSink(); + if (roleName != nullptr) { + os << ", role=\"" << roleName << "\", style=filled, fillcolor=\"" << roleColor << "\""; + } else if (d.hasGen() && d.hasSim()) { + os << ", color=\"purple\", penwidth=2"; + } else if (d.hasGen()) { + os << ", color=\"blue\""; + } else if (d.hasSim()) { + os << ", color=\"darkgreen\""; + } + os << ", x=" << std::fixed << std::setprecision(6) << d.position.x() << ", y=" << d.position.y() + << ", z=" << d.position.z() << ", t=" << d.position.t() << ", x4=\"" << fmtX4(d.position) << "\"" + << ", nIn=" << incoming.size() << ", nOut=" << outgoing.size(); + + if (raw != nullptr) { + os << ", raw_GEN=<" << rawNodeSummary(raw, d.genNode) << ">, raw_SIM=<" << rawNodeSummary(raw, d.simNode) + << ">"; + } + + // Big title row: the role for artificial vertices; the physical reason when + // known (SIM vertices carry the Geant4 process); else - GEN-only vertices have + // no process - infer from the topology (a single incoming particle decays). + std::string vBig; + if (roleName != nullptr) + vBig = roleName; + else if (d.vertexReason() != truth::VertexReason::Unknown) + vBig = truth::vertexReasonName(d.vertexReason()); + else + vBig = (incoming.size() == 1) ? "decay" : "production"; + os << ", label=<\n"; + os << " \n"; + os << " \n"; + os << " \n"; + + if (roleName != nullptr) + os << " \n"; + + os << " \n"; + + if (d.vertexReason() != truth::VertexReason::Unknown) + os << " \n"; + + if (d.eventId != 0) + os << " \n"; + + if (d.genEvent >= 0) + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + os << " \n"; + + if (raw != nullptr) { + os << " \n"; + os << " \n"; + } + + os << "
" << vBig << "
Vertex " << i << "
" << roleName << " (genEvent=" << d.genEvent << ", eid=" << d.eventId + << ")
domain: " << logicalVertexDomain(d) << "
reason: " << truth::vertexReasonName(d.vertexReason()) << "
eid: " << d.eventId << "
genEvent: " << d.genEvent << "
hasGen: " << (d.hasGen() ? "yes" : "no") << " hasSim: " << (d.hasSim() ? "yes" : "no") + << "
isSource: " << (v.isSource() ? "yes" : "no") << " isSink: " << (v.isSink() ? "yes" : "no") + << "
x4: " << fmtX4(d.position) << "
nIn: " << v.incomingParticles().size() << " nOut: " << v.outgoingParticles().size() + << "
raw GEN: " << rawNodeSummary(raw, d.genNode) << "
raw SIM: " << rawNodeSummary(raw, d.simNode) << "
\n"; + os << " >];\n"; + } + + // ------------------------------------------------------------------ + // Edges: physics-forward only + // ------------------------------------------------------------------ + for (uint32_t i = 0; i < nParticles; ++i) { + if (hideParticle[i]) + continue; + + unsigned kept = 0; + + for (uint32_t v : g.decayVertices(i)) { + if (v >= nVertices) + continue; + + if (hideVertex[v]) + continue; + + os << " p" << i << " -> v" << v << ";\n"; + + if (++kept >= maxEdgesPerNode_) + break; + } + } + + for (uint32_t i = 0; i < nVertices; ++i) { + if (hideVertex[i]) + continue; + + unsigned kept = 0; + + for (uint32_t p : g.outgoingParticles(i)) { + if (p >= nParticles) + continue; + + if (hideParticle[p]) + continue; + + os << " v" << i << " -> p" << p << ";\n"; + + if (++kept >= maxEdgesPerNode_) + break; + } + } + + os << "}\n"; + } + +private: + std::vector collectRecHitEnergies(const edm::Event& evt) const { + std::vector energies; + + // This must match the global recHit indexing order used by DetIdToRecHitMapProducer: + // first all HGCRecHit collections, then all PFRecHit collections. + for (uint32_t i = 0; i < hgcalRecHitTokens_.size(); ++i) { + edm::Handle handle; + evt.getByToken(hgcalRecHitTokens_[i], handle); + + if (!handle.isValid()) { + edm::LogWarning("TruthLogicalGraphDumper") << "Missing HGCRecHit collection " << hgcalRecHitTags_[i].encode() + << ". Skipping it while rebuilding recHit energies."; + continue; + } + + energies.reserve(energies.size() + handle->size()); + for (auto const& hit : *handle) { + energies.push_back(hit.energy()); + } + } + for (uint32_t i = 0; i < pfRecHitTokens_.size(); ++i) { + edm::Handle handle; + evt.getByToken(pfRecHitTokens_[i], handle); + + if (!handle.isValid()) { + edm::LogWarning("TruthLogicalGraphDumper") << "Missing reco::PFRecHitCollection " << pfRecHitTags_[i].encode() + << ". Skipping it while rebuilding recHit energies."; + continue; + } + + energies.reserve(energies.size() + handle->size()); + for (auto const& hit : *handle) { + energies.push_back(hit.energy()); + } + } + + return energies; + } + + edm::EDGetTokenT token_; + edm::EDGetTokenT rawToken_; + + edm::InputTag hitIndexTag_; + edm::EDGetTokenT hitIndexToken_; + bool useHitIndex_; + + std::vector hgcalRecHitTags_; + std::vector> hgcalRecHitTokens_; + + std::vector pfRecHitTags_; + std::vector> pfRecHitTokens_; + + std::string dotFile_; + std::string layout_; + unsigned maxParticles_; + unsigned maxVertices_; + unsigned maxEdgesPerNode_; + bool hideLargeSimSourceVertices_; + bool dumpSimHits_; + unsigned largeSimSourceVertexMinOutgoing_; + bool hideZeroSimHitSubgraphs_; +}; + +DEFINE_FWK_MODULE(TruthLogicalGraphDumper); diff --git a/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc new file mode 100644 index 0000000000000..72b5d6b47b0ec --- /dev/null +++ b/PhysicsTools/TruthInfo/plugins/TruthLogicalGraphProducer.cc @@ -0,0 +1,966 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +// Author: Felice Pantaleo - CERN +// Date: 03/2026 +// +// Build a logical truth::Graph from the raw heterogeneous TruthGraph. +// The topology comes from the raw TruthGraph. +// Standalone payload (momentum/position/checkpoints) is materialized from optional +// HepMC2 / HepMC3 / SimTrack / SimVertex inputs. +// +// GenParticle and SimTrack nodes are merged when a robust association exists. +// A merged GEN+SIM particle takes its production vertex from the GEN side (the +// immediate GenParticle's production GenVertex, via genpartIndex); the redundant +// SimTrack production vertex (the shared Geant4 beam vertex) is dropped. This +// attaches each track to its faithful immediate GEN vertex, without the artificial +// high-degree merged vertex that a position-based GEN/SIM vertex merge created. + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/Framework/interface/Event.h" +#include "FWCore/Framework/interface/EventSetup.h" +#include "FWCore/Framework/interface/MakerMacros.h" +#include "FWCore/Framework/interface/stream/EDProducer.h" +#include "FWCore/ParameterSet/interface/ConfigurationDescriptions.h" +#include "FWCore/ParameterSet/interface/ParameterSet.h" +#include "FWCore/ParameterSet/interface/ParameterSetDescription.h" +#include "FWCore/Utilities/interface/Exception.h" +#include "FWCore/Utilities/interface/InputTag.h" +#include "FWCore/MessageLogger/interface/MessageLogger.h" + +#include "DataFormats/Math/interface/LorentzVector.h" + +#include "SimDataFormats/Track/interface/SimTrackContainer.h" +#include "SimDataFormats/Vertex/interface/SimVertexContainer.h" +#include "SimDataFormats/CaloHit/interface/PCaloHit.h" +#include "SimDataFormats/TrackingHit/interface/PSimHitContainer.h" + +// Legacy HepMC (HepMC2) +#include "SimDataFormats/GeneratorProducts/interface/HepMCProduct.h" +#include "HepMC/GenEvent.h" +#include "HepMC/GenParticle.h" +#include "HepMC/GenVertex.h" + +// HepMC3 +#include "SimDataFormats/GeneratorProducts/interface/HepMC3Product.h" +#include "HepMC3/GenEvent.h" +#include "HepMC3/GenParticle.h" +#include "HepMC3/GenVertex.h" + +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/TruthInfo/interface/TruthGraph.h" +#include "PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h" + +namespace { + + struct DSU { + std::vector parent; + std::vector rank; + + explicit DSU(int n) : parent(n), rank(n, 0) { + for (int i = 0; i < n; ++i) + parent[i] = i; + } + + int find(int x) { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + void unite(int a, int b) { + a = find(a); + b = find(b); + + if (a == b) + return; + + if (rank[a] < rank[b]) + std::swap(a, b); + + parent[b] = a; + + if (rank[a] == rank[b]) + ++rank[a]; + } + }; + + struct GenParticlePayload { + int32_t pdgId = 0; + int16_t status = 0; + math::XYZTLorentzVectorD momentum; + }; + + struct GenVertexPayload { + math::XYZTLorentzVectorD position; + }; + + bool isParticleKind(TruthGraph::NodeKind kind) { + return kind == TruthGraph::NodeKind::GenParticle || kind == TruthGraph::NodeKind::SimTrack; + } + + bool isVertexKind(TruthGraph::NodeKind kind) { + return kind == TruthGraph::NodeKind::GenVertex || kind == TruthGraph::NodeKind::SimVertex; + } + + bool isGenParticleToSimTrack(TruthGraph const& g, uint32_t src, uint32_t dst) { + auto const& s = g.nodeRef(src); + auto const& d = g.nodeRef(dst); + + return s.kind == TruthGraph::NodeKind::GenParticle && d.kind == TruthGraph::NodeKind::SimTrack; + } + + void buildCSR(uint32_t nSources, + std::vector>& pairs, + std::vector& offsets, + std::vector& flat) { + std::sort(pairs.begin(), pairs.end()); + pairs.erase(std::unique(pairs.begin(), pairs.end()), pairs.end()); + + pairs.erase( + std::remove_if(pairs.begin(), pairs.end(), [nSources](auto const& edge) { return edge.first >= nSources; }), + pairs.end()); + + offsets.assign(nSources + 1, 0); + + for (auto const& edge : pairs) { + ++offsets[edge.first + 1]; + } + + for (uint32_t i = 1; i <= nSources; ++i) { + offsets[i] += offsets[i - 1]; + } + + flat.assign(pairs.size(), 0); + + auto cursor = offsets; + for (auto const& edge : pairs) { + flat[cursor[edge.first]++] = edge.second; + } + } + + template + bool validHandle(HandleT const& handle) { + return handle.isValid(); + } + + void fillGenPayloadFromHepMC2(HepMC::GenEvent const& ev, + std::unordered_map& particlePayload, + std::unordered_map& vertexPayload) { + particlePayload.reserve(ev.particles_size() * 2); + vertexPayload.reserve(ev.vertices_size() * 2); + constexpr double mmTocm = 0.1; + constexpr double mmOverCToNs = 1.0 / 299.792458; // HepMC vertex time is c*t in mm -> ns + + for (auto p = ev.particles_begin(); p != ev.particles_end(); ++p) { + if (*p == nullptr) + continue; + + const int barcode = (*p)->barcode(); + + GenParticlePayload payload; + payload.pdgId = (*p)->pdg_id(); + payload.status = static_cast((*p)->status()); + payload.momentum = math::XYZTLorentzVectorD( + (*p)->momentum().px(), (*p)->momentum().py(), (*p)->momentum().pz(), (*p)->momentum().e()); + + particlePayload.emplace(barcode, payload); + } + + for (auto v = ev.vertices_begin(); v != ev.vertices_end(); ++v) { + if (*v == nullptr) + continue; + + const int barcode = (*v)->barcode(); + GenVertexPayload payload; + payload.position = math::XYZTLorentzVectorD((*v)->position().x() * mmTocm, + (*v)->position().y() * mmTocm, + (*v)->position().z() * mmTocm, + (*v)->position().t() * mmOverCToNs); + + vertexPayload.emplace(barcode, payload); + } + } + + void fillGenPayloadFromHepMC3(HepMC3::GenEvent const& ev, + std::unordered_map& particlePayload, + std::unordered_map& vertexPayload) { + particlePayload.reserve(ev.particles().size() * 2); + vertexPayload.reserve(ev.vertices().size() * 2); + constexpr double mmTocm = 0.1; + constexpr double mmOverCToNs = 1.0 / 299.792458; // HepMC vertex time is c*t in mm -> ns + for (auto const& pptr : ev.particles()) { + if (!pptr) + continue; + + const int id = pptr->id(); + + GenParticlePayload payload; + payload.pdgId = pptr->pid(); + payload.status = static_cast(pptr->status()); + payload.momentum = math::XYZTLorentzVectorD( + pptr->momentum().px(), pptr->momentum().py(), pptr->momentum().pz(), pptr->momentum().e()); + + particlePayload.emplace(id, payload); + } + + for (auto const& vptr : ev.vertices()) { + if (!vptr) + continue; + + const int id = vptr->id(); + + GenVertexPayload payload; + payload.position = math::XYZTLorentzVectorD(vptr->position().x() * mmTocm, + vptr->position().y() * mmTocm, + vptr->position().z() * mmTocm, + vptr->position().t() * mmOverCToNs); + + vertexPayload.emplace(id, payload); + } + } + + std::vector buildKeepMaskForAllRawNodes(TruthGraph const& raw) { + return std::vector(raw.nNodes(), 1); + } + + std::vector buildGenParticleToProductionGenVertexMap(TruthGraph const& raw, + std::vector const& keepRawNode) { + const uint32_t nRawNodes = raw.nNodes(); + + std::vector genParticleToProductionGenVertex(nRawNodes, -1); + + for (uint32_t src = 0; src < nRawNodes; ++src) { + if (!keepRawNode[src]) + continue; + + if (raw.nodeRef(src).kind != TruthGraph::NodeKind::GenVertex) + continue; + + const auto dsts = raw.children(src); + const auto edgeKinds = raw.childrenEdgeKinds(src); + + for (std::size_t i = 0; i < dsts.size(); ++i) { + const uint32_t dst = dsts[i]; + + if (dst >= nRawNodes || !keepRawNode[dst]) + continue; + + if (raw.nodeRef(dst).kind != TruthGraph::NodeKind::GenParticle) + continue; + + if (static_cast(edgeKinds[i]) != TruthGraph::EdgeKind::Gen) + continue; + + if (genParticleToProductionGenVertex[dst] < 0) + genParticleToProductionGenVertex[dst] = static_cast(src); + } + } + + return genParticleToProductionGenVertex; + } + + void buildRawSimVertexDegrees(TruthGraph const& raw, + std::vector const& keepRawNode, + std::vector& simVertexIncomingSimTracks, + std::vector& simVertexOutgoingSimTracks) { + const uint32_t nRawNodes = raw.nNodes(); + + simVertexIncomingSimTracks.assign(nRawNodes, 0); + simVertexOutgoingSimTracks.assign(nRawNodes, 0); + + for (uint32_t src = 0; src < nRawNodes; ++src) { + if (!keepRawNode[src]) + continue; + + const auto dsts = raw.children(src); + const auto edgeKinds = raw.childrenEdgeKinds(src); + + for (std::size_t i = 0; i < dsts.size(); ++i) { + const uint32_t dst = dsts[i]; + + if (dst >= nRawNodes || !keepRawNode[dst]) + continue; + + if (static_cast(edgeKinds[i]) != TruthGraph::EdgeKind::Sim) + continue; + + const auto srcKind = raw.nodeRef(src).kind; + const auto dstKind = raw.nodeRef(dst).kind; + + if (srcKind == TruthGraph::NodeKind::SimTrack && dstKind == TruthGraph::NodeKind::SimVertex) { + ++simVertexIncomingSimTracks[dst]; + } else if (srcKind == TruthGraph::NodeKind::SimVertex && dstKind == TruthGraph::NodeKind::SimTrack) { + ++simVertexOutgoingSimTracks[src]; + } + } + } + } + +} // namespace + +class TruthLogicalGraphProducer : public edm::stream::EDProducer<> { +public: + explicit TruthLogicalGraphProducer(edm::ParameterSet const& cfg) + : srcToken_(consumes(cfg.getParameter("src"))), + simTrackToken_(mayConsume(cfg.getParameter("simTracks"))), + simVertexToken_(mayConsume(cfg.getParameter("simVertices"))), + hepmc3Token_(mayConsume(cfg.getParameter("genEventHepMC3"))), + hepmc2Token_(mayConsume(cfg.getParameter("genEventHepMC"))), + mergeGenSimVertices_(cfg.getParameter("mergeGenSimVertices")), + dropHitlessSimSubgraphs_( + cfg.getParameter("postProcessing").getParameter("dropHitlessSimSubgraphs")), + postProcessor_(truth::TruthLogicalGraphPostProcessor::configFromPSet( + cfg.getParameter("postProcessing"))) { + // The hitless-subgraph pruning needs to know which SimTracks left a calo or + // tracker sim-hit; consume the same collections the hit-index producer uses. + if (dropHitlessSimSubgraphs_) { + for (auto const& tag : cfg.getParameter>("simHitCollections")) + caloSimHitTokens_.push_back(consumes>(tag)); + for (auto const& tag : cfg.getParameter>("trackerSimHitCollections")) + trackerSimHitTokens_.push_back(consumes(tag)); + } + + produces(); + } + + static void fillDescriptions(edm::ConfigurationDescriptions& descriptions) { + edm::ParameterSetDescription desc; + + desc.add("src", edm::InputTag("truthGraphProducer")); + desc.add("simTracks", edm::InputTag("g4SimHits")); + desc.add("simVertices", edm::InputTag("g4SimHits")); + desc.add("genEventHepMC3", edm::InputTag("generatorSmeared")); + desc.add("genEventHepMC", edm::InputTag("generatorSmeared")); + + desc.add("mergeGenSimVertices", true) + ->setComment( + "If true, merge production GenVertex and SimVertex only for locally one-to-one matches induced by " + "GenParticle <-> SimTrack associations."); + + desc.add>("simHitCollections", + {edm::InputTag("g4SimHits", "HGCHitsEE"), + edm::InputTag("g4SimHits", "HGCHitsHEfront"), + edm::InputTag("g4SimHits", "HGCHitsHEback"), + edm::InputTag("g4SimHits", "EcalHitsEB"), + edm::InputTag("g4SimHits", "HcalHits")}) + ->setComment( + "Calorimeter PCaloHit collections used only to decide which SimTracks left a hit, for the " + "postProcessing.dropHitlessSimSubgraphs pruning. Covers endcap (HGCAL) and barrel (ECAL/HCAL) " + "so a particle is kept if it leaves a hit in any calorimeter. Matched via PCaloHit::geantTrackId(); " + "only the track id and energy are read, so no DetId relabelling is needed."); + + desc.add>("trackerSimHitCollections", + {edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelBarrelHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsPixelEndcapHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTIDHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTOBHighTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECLowTof"), + edm::InputTag("g4SimHits", "TrackerHitsTECHighTof")}) + ->setComment( + "Tracker PSimHit collections used only to decide which SimTracks left a hit, for the " + "postProcessing.dropHitlessSimSubgraphs pruning. Matched to particles via PSimHit::trackId()."); + + desc.add("postProcessing", truth::TruthLogicalGraphPostProcessor::psetDescription()) + ->setComment("Logical graph post-processing configuration."); + + descriptions.addWithDefaultLabel(desc); + } + + void produce(edm::Event& evt, edm::EventSetup const&) override { + auto const& raw = evt.get(srcToken_); + + if (!raw.isConsistent()) { + throw cms::Exception("TruthLogicalGraphProducer") << "Input TruthGraph is not consistent"; + } + + auto out = std::make_unique(); + + const uint32_t nRawNodes = raw.nNodes(); + + edm::Handle hSimTracks; + evt.getByToken(simTrackToken_, hSimTracks); + + edm::Handle hSimVertices; + evt.getByToken(simVertexToken_, hSimVertices); + + std::unordered_map simTrackIdToIndex; + + if (validHandle(hSimTracks)) { + simTrackIdToIndex.reserve(hSimTracks->size() * 2); + + for (uint32_t i = 0; i < hSimTracks->size(); ++i) { + simTrackIdToIndex.emplace((*hSimTracks)[i].trackId(), i); + } + } + + std::unordered_map genParticlePayload; + std::unordered_map genVertexPayload; + + bool haveGenPayload = false; + + { + edm::Handle h3; + evt.getByToken(hepmc3Token_, h3); + + if (validHandle(h3) && h3->GetEvent() != nullptr) { + const HepMC3::GenEventData* data = h3->GetEvent(); + + HepMC3::GenEvent ev3; + ev3.read_data(*data); + + fillGenPayloadFromHepMC3(ev3, genParticlePayload, genVertexPayload); + haveGenPayload = true; + } + } + + if (!haveGenPayload) { + edm::Handle h2; + evt.getByToken(hepmc2Token_, h2); + + if (validHandle(h2) && h2->GetEvent() != nullptr) { + fillGenPayloadFromHepMC2(*h2->GetEvent(), genParticlePayload, genVertexPayload); + haveGenPayload = true; + } + } + + const auto keepRawNode = buildKeepMaskForAllRawNodes(raw); + const auto genParticleToProductionGenVertex = buildGenParticleToProductionGenVertexMap(raw, keepRawNode); + + std::vector rawSimVertexIncomingSimTracks; + std::vector rawSimVertexOutgoingSimTracks; + buildRawSimVertexDegrees(raw, keepRawNode, rawSimVertexIncomingSimTracks, rawSimVertexOutgoingSimTracks); + + // ------------------------------------------------------------------ + // 1. Temporary ids + // ------------------------------------------------------------------ + std::vector rawToParticleTmp(nRawNodes, -1); + std::vector rawToVertexTmp(nRawNodes, -1); + + int nParticleTmp = 0; + int nVertexTmp = 0; + + for (uint32_t nodeId = 0; nodeId < nRawNodes; ++nodeId) { + if (!keepRawNode[nodeId]) + continue; + + const auto kind = raw.nodeRef(nodeId).kind; + + if (isParticleKind(kind)) { + rawToParticleTmp[nodeId] = nParticleTmp++; + } else if (isVertexKind(kind)) { + rawToVertexTmp[nodeId] = nVertexTmp++; + } + } + + DSU particleDSU(nParticleTmp); + DSU vertexDSU(nVertexTmp); + + std::vector> productionVertexMergeCandidates; + + auto addProductionVertexMergeCandidate = [&](uint32_t simTrackNode, uint32_t genParticleNode) { + if (!mergeGenSimVertices_) + return; + + const int32_t simVertexNode = raw.nodeSimTrackToVtx(simTrackNode); + if (simVertexNode < 0) + return; + + if (genParticleNode >= genParticleToProductionGenVertex.size()) + return; + + const int32_t genVertexNode = genParticleToProductionGenVertex[genParticleNode]; + if (genVertexNode < 0) + return; + + const uint32_t gv = static_cast(genVertexNode); + const uint32_t sv = static_cast(simVertexNode); + + if (gv >= nRawNodes || sv >= nRawNodes) + return; + + if (!keepRawNode[gv] || !keepRawNode[sv]) + return; + + if (rawToVertexTmp[gv] < 0 || rawToVertexTmp[sv] < 0) + return; + + if (raw.nodeRef(gv).kind != TruthGraph::NodeKind::GenVertex) + return; + + if (raw.nodeRef(sv).kind != TruthGraph::NodeKind::SimVertex) + return; + + productionVertexMergeCandidates.emplace_back(gv, sv); + }; + + // ------------------------------------------------------------------ + // 2. Merge particles across GEN <-> SIM and collect vertex merge candidates + // ------------------------------------------------------------------ + for (uint32_t nodeId = 0; nodeId < nRawNodes; ++nodeId) { + if (!keepRawNode[nodeId]) + continue; + + auto const& ref = raw.nodeRef(nodeId); + + if (ref.kind != TruthGraph::NodeKind::SimTrack) + continue; + + const int32_t genNode = raw.nodeSimTrackToGen(nodeId); + if (genNode < 0) + continue; + + const uint32_t genNodeU32 = static_cast(genNode); + if (genNodeU32 >= nRawNodes) + continue; + + if (!keepRawNode[genNodeU32]) + continue; + + if (raw.nodeRef(genNodeU32).kind != TruthGraph::NodeKind::GenParticle) + continue; + + if (rawToParticleTmp[nodeId] < 0 || rawToParticleTmp[genNodeU32] < 0) + continue; + + particleDSU.unite(rawToParticleTmp[nodeId], rawToParticleTmp[genNodeU32]); + addProductionVertexMergeCandidate(nodeId, genNodeU32); + } + + for (uint32_t src = 0; src < nRawNodes; ++src) { + if (!keepRawNode[src]) + continue; + + const auto dsts = raw.children(src); + const auto ekinds = raw.childrenEdgeKinds(src); + + for (std::size_t i = 0; i < dsts.size(); ++i) { + const uint32_t dst = dsts[i]; + + if (dst >= nRawNodes || !keepRawNode[dst]) + continue; + + const auto ek = static_cast(ekinds[i]); + + if (ek != TruthGraph::EdgeKind::GenToSim && ek != TruthGraph::EdgeKind::SimToGen) + continue; + + if (!isGenParticleToSimTrack(raw, src, dst)) + continue; + + if (rawToParticleTmp[src] < 0 || rawToParticleTmp[dst] < 0) + continue; + + particleDSU.unite(rawToParticleTmp[src], rawToParticleTmp[dst]); + addProductionVertexMergeCandidate(dst, src); + } + } + + if (mergeGenSimVertices_) { + std::sort(productionVertexMergeCandidates.begin(), productionVertexMergeCandidates.end()); + productionVertexMergeCandidates.erase( + std::unique(productionVertexMergeCandidates.begin(), productionVertexMergeCandidates.end()), + productionVertexMergeCandidates.end()); + + std::vector genVertexCandidateMultiplicity(nRawNodes, 0); + std::vector simVertexCandidateMultiplicity(nRawNodes, 0); + + for (auto const& candidate : productionVertexMergeCandidates) { + if (genVertexCandidateMultiplicity[candidate.first] < UINT16_MAX) + ++genVertexCandidateMultiplicity[candidate.first]; + + if (simVertexCandidateMultiplicity[candidate.second] < UINT16_MAX) + ++simVertexCandidateMultiplicity[candidate.second]; + } + + for (auto const& candidate : productionVertexMergeCandidates) { + const uint32_t gv = candidate.first; + const uint32_t sv = candidate.second; + + // Only accept locally one-to-one GenVertex <-> SimVertex matches. + // This prevents one busy SimVertex from absorbing many unrelated GenVertices. + if (genVertexCandidateMultiplicity[gv] != 1 || simVertexCandidateMultiplicity[sv] != 1) + continue; + + // Do not merge secondary SIM vertices produced by an existing SimTrack. + // Primary/injection SIM vertices can legitimately have multiple outgoing primary tracks. + if (rawSimVertexIncomingSimTracks[sv] != 0) + continue; + + if (rawSimVertexOutgoingSimTracks[sv] == 0) + continue; + + vertexDSU.unite(rawToVertexTmp[gv], rawToVertexTmp[sv]); + } + } + + // ------------------------------------------------------------------ + // 3. Compress particle and vertex representatives + // ------------------------------------------------------------------ + std::unordered_map particleRepToLogical; + std::vector rawToParticle(nRawNodes, -1); + + for (uint32_t nodeId = 0; nodeId < nRawNodes; ++nodeId) { + if (!keepRawNode[nodeId]) + continue; + + if (rawToParticleTmp[nodeId] >= 0) { + const int rep = particleDSU.find(rawToParticleTmp[nodeId]); + auto result = particleRepToLogical.emplace(rep, static_cast(particleRepToLogical.size())); + + rawToParticle[nodeId] = static_cast(result.first->second); + } + } + + std::unordered_map vertexRepToLogical; + std::vector rawToVertex(nRawNodes, -1); + + for (uint32_t nodeId = 0; nodeId < nRawNodes; ++nodeId) { + if (!keepRawNode[nodeId]) + continue; + + if (rawToVertexTmp[nodeId] >= 0) { + const int rep = vertexDSU.find(rawToVertexTmp[nodeId]); + auto result = vertexRepToLogical.emplace(rep, static_cast(vertexRepToLogical.size())); + + rawToVertex[nodeId] = static_cast(result.first->second); + } + } + + out->particles().resize(particleRepToLogical.size()); + out->vertices().resize(vertexRepToLogical.size()); + + // Whether the GEN payload actually supplied a momentum/position for each + // logical object. For a merged GEN+SIM object whose GEN barcode is absent from + // the payload (e.g. pile-up GEN particles, which are not in the signal HepMC, + // or jobs with no HepMC product), the SimTrack/SimVertex value is used as the + // fallback instead of leaving the field default-constructed (zero momentum). + std::vector genMomentumApplied(out->particles().size(), 0); + std::vector genPositionApplied(out->vertices().size(), 0); + + // ------------------------------------------------------------------ + // 4. Fill payload + // ------------------------------------------------------------------ + for (uint32_t nodeId = 0; nodeId < nRawNodes; ++nodeId) { + if (!keepRawNode[nodeId]) + continue; + + auto const& ref = raw.nodeRef(nodeId); + + if (rawToParticle[nodeId] >= 0) { + auto& p = out->particles()[static_cast(rawToParticle[nodeId])]; + + if (ref.kind == TruthGraph::NodeKind::GenParticle) { + p.genNode = static_cast(nodeId); + + if (nodeId < raw.genEventOfNode().size()) + p.genEvent = raw.genEventOfNode()[nodeId]; + + if (p.pdgId == 0) + p.pdgId = raw.nodePdgId(nodeId); + + if (p.status == 0) + p.status = raw.nodeStatus(nodeId); + + if (p.statusFlags == 0) + p.statusFlags = raw.nodeStatusFlags(nodeId); + + if (haveGenPayload) { + const int barcode = static_cast(ref.key); + auto it = genParticlePayload.find(barcode); + + if (it != genParticlePayload.end()) { + if (p.pdgId == 0) + p.pdgId = it->second.pdgId; + + if (p.status == 0) + p.status = it->second.status; + + // Keep the GEN four-momentum as nominal for GEN and GEN+SIM logical particles. + p.momentum = it->second.momentum; + genMomentumApplied[static_cast(rawToParticle[nodeId])] = 1; + } + } + + } else if (ref.kind == TruthGraph::NodeKind::SimTrack) { + p.simNode = static_cast(nodeId); + + // Back-scattering (albedo) is a SimTrack property; OR it in so a merged + // GEN+SIM particle inherits it from its SIM side. + p.backscattered = p.backscattered || raw.nodeBackscattered(nodeId); + + if (p.pdgId == 0) + p.pdgId = raw.nodePdgId(nodeId); + + if (p.status == 0) + p.status = raw.nodeStatus(nodeId); + + if (p.eventId == 0) + p.eventId = raw.nodeEventId(nodeId); + + if (validHandle(hSimTracks)) { + const auto trackId = static_cast(ref.key); + auto it = simTrackIdToIndex.find(trackId); + + if (it != simTrackIdToIndex.end()) { + auto const& t = (*hSimTracks)[it->second]; + + const math::XYZTLorentzVectorD simMomentum( + t.momentum().px(), t.momentum().py(), t.momentum().pz(), t.momentum().e()); + + // Use the SimTrack momentum whenever the GEN side did not supply one: + // SIM-only particles, and merged GEN+SIM particles whose GEN barcode + // missed the payload (pile-up / no-HepMC). When a GEN momentum was + // applied it remains the nominal one. + if (!genMomentumApplied[static_cast(rawToParticle[nodeId])]) { + p.momentum = simMomentum; + } + + if (t.crossedBoundary()) { + truth::Checkpoint cp; + cp.checkpointId = 0; + + const auto& xb = t.getPositionAtBoundary(); + cp.position = math::XYZTLorentzVectorF(xb.x(), xb.y(), xb.z(), xb.t()); + + const auto& pb = t.getMomentumAtBoundary(); + cp.momentum = math::XYZTLorentzVectorF(pb.px(), pb.py(), pb.pz(), pb.e()); + + p.checkpoints.push_back(cp); + } + } + } + } + } + + if (rawToVertex[nodeId] >= 0) { + auto& v = out->vertices()[static_cast(rawToVertex[nodeId])]; + + if (ref.kind == TruthGraph::NodeKind::GenVertex) { + v.genNode = static_cast(nodeId); + + if (nodeId < raw.genEventOfNode().size()) + v.genEvent = raw.genEventOfNode()[nodeId]; + + if (haveGenPayload) { + const int barcode = static_cast(ref.key); + auto it = genVertexPayload.find(barcode); + + if (it != genVertexPayload.end()) { + // Keep the GEN position as nominal for GEN and GEN+SIM logical vertices. + v.position = it->second.position; + genPositionApplied[static_cast(rawToVertex[nodeId])] = 1; + } + } + + } else if (ref.kind == TruthGraph::NodeKind::SimVertex) { + v.simNode = static_cast(nodeId); + + // Physical reason this vertex exists, from the SimVertex G4 process subtype. + // For GEN+SIM merged vertices the SIM side is the one that carries it. + v.reason = static_cast(truth::reasonFromG4ProcessSubType(raw.nodeProcessType(nodeId))); + + if (v.eventId == 0) + v.eventId = raw.nodeEventId(nodeId); + + if (validHandle(hSimVertices)) { + const auto simIndex = static_cast(ref.key); + + if (simIndex < hSimVertices->size()) { + auto const& sv = (*hSimVertices)[simIndex]; + const auto& pos = sv.position(); + constexpr double sToNs = 1e9; // SimVertex time is stored in seconds -> ns + + // Use the SimVertex position whenever the GEN side did not supply one: + // SIM-only vertices, and merged GEN+SIM vertices whose GEN barcode + // missed the payload (pile-up / no-HepMC). Position is in cm; SimVertex + // time is converted from seconds to ns so it shares the (cm, ns) + // convention used for GEN vertices. When a GEN position was applied it + // remains the nominal one. + if (!genPositionApplied[static_cast(rawToVertex[nodeId])]) { + v.position = math::XYZTLorentzVectorD(pos.x(), pos.y(), pos.z(), pos.t() * sToNs); + } + } + } + } + } + } + + // ------------------------------------------------------------------ + // 5. Rebuild logical graph + // ------------------------------------------------------------------ + std::vector> particleToDecayVertexPairs; + std::vector> particleToProductionVertexPairs; + std::vector> vertexToOutgoingParticlePairs; + std::vector> vertexToIncomingParticlePairs; + + for (uint32_t src = 0; src < nRawNodes; ++src) { + if (!keepRawNode[src]) + continue; + + auto const& srcRef = raw.nodeRef(src); + const auto dsts = raw.children(src); + + for (uint32_t dst : dsts) { + if (dst >= nRawNodes || !keepRawNode[dst]) + continue; + + auto const& dstRef = raw.nodeRef(dst); + + if (isVertexKind(srcRef.kind) && isParticleKind(dstRef.kind)) { + const int32_t logicalVertex = rawToVertex[src]; + const int32_t logicalParticle = rawToParticle[dst]; + + if (logicalVertex >= 0 && logicalParticle >= 0) { + // A merged GEN+SIM particle takes its production vertex from the GEN side: the + // immediate GenParticle's production GenVertex (faithful, via genpartIndex). The + // SimTrack's production SimVertex is the shared Geant4 beam vertex, redundant and + // many-GEN-to-one-SIM, so drop that edge here. The GEN production edge is added + // when the GEN side of this particle is visited. This replaces the former + // position-based GEN/SIM vertex merge. + const bool redundantSimProduction = srcRef.kind == TruthGraph::NodeKind::SimVertex && + out->particles()[static_cast(logicalParticle)].hasGen(); + + if (!redundantSimProduction) { + vertexToOutgoingParticlePairs.emplace_back(static_cast(logicalVertex), + static_cast(logicalParticle)); + particleToProductionVertexPairs.emplace_back(static_cast(logicalParticle), + static_cast(logicalVertex)); + } + } + + } else if (isParticleKind(srcRef.kind) && isVertexKind(dstRef.kind)) { + const int32_t logicalParticle = rawToParticle[src]; + const int32_t logicalVertex = rawToVertex[dst]; + + if (logicalParticle >= 0 && logicalVertex >= 0) { + particleToDecayVertexPairs.emplace_back(static_cast(logicalParticle), + static_cast(logicalVertex)); + vertexToIncomingParticlePairs.emplace_back(static_cast(logicalVertex), + static_cast(logicalParticle)); + } + } + } + } + + buildCSR(out->nParticles(), + particleToDecayVertexPairs, + out->particleToDecayVertexOffsets(), + out->particleToDecayVertices()); + + buildCSR(out->nParticles(), + particleToProductionVertexPairs, + out->particleToProductionVertexOffsets(), + out->particleToProductionVertices()); + + buildCSR(out->nVertices(), + vertexToOutgoingParticlePairs, + out->vertexToOutgoingParticleOffsets(), + out->vertexToOutgoingParticles()); + + buildCSR(out->nVertices(), + vertexToIncomingParticlePairs, + out->vertexToIncomingParticleOffsets(), + out->vertexToIncomingParticles()); + + // Per-particle sim-hit presence for the hitless-subgraph pruning. A logical + // particle is flagged when a calo or tracker sim-hit carries its SimTrack + // trackId with positive energy -- exactly how the LogicalGraphHitIndex + // attributes direct hits, so the pruned graph stays consistent with the + // index. Left empty (pruning disabled) when no sim-hit collection is present. + std::vector particleDirectHit; + + if (dropHitlessSimSubgraphs_) { + std::unordered_set hitTrackIds; + bool anyCollectionValid = false; + + for (auto const& token : caloSimHitTokens_) { + edm::Handle> hHits; + evt.getByToken(token, hHits); + if (!hHits.isValid()) + continue; + anyCollectionValid = true; + for (auto const& hit : *hHits) { + const int trackId = hit.geantTrackId(); + if (trackId > 0 && hit.energy() > 0.f) + hitTrackIds.insert(static_cast(trackId)); + } + } + + for (auto const& token : trackerSimHitTokens_) { + edm::Handle hHits; + evt.getByToken(token, hHits); + if (!hHits.isValid()) + continue; + anyCollectionValid = true; + for (auto const& hit : *hHits) { + if (hit.energyLoss() > 0.f) + hitTrackIds.insert(hit.trackId()); + } + } + + if (anyCollectionValid) { + particleDirectHit.assign(out->nParticles(), 0); + for (uint32_t particleId = 0; particleId < out->nParticles(); ++particleId) { + const int32_t simNode = out->particles()[particleId].simNode; + if (simNode < 0) + continue; + const uint32_t simNodeU32 = static_cast(simNode); + if (simNodeU32 >= nRawNodes) + continue; + auto const& ref = raw.nodeRef(simNodeU32); + if (ref.kind != TruthGraph::NodeKind::SimTrack) + continue; + if (ref.key <= 0 || ref.key > static_cast(std::numeric_limits::max())) + continue; + if (hitTrackIds.count(static_cast(ref.key)) != 0) + particleDirectHit[particleId] = 1; + } + } else { + edm::LogWarning("TruthLogicalGraphProducer") + << "dropHitlessSimSubgraphs is enabled but no calo/tracker sim-hit collection was found; " + "keeping the full logical graph for this event."; + } + } + + *out = postProcessor_.process(std::move(*out), particleDirectHit); + + if (!out->isConsistent()) { + throw cms::Exception("TruthLogicalGraphProducer") << "Produced truth::Graph is not consistent"; + } + + evt.put(std::move(out)); + } + +private: + edm::EDGetTokenT srcToken_; + edm::EDGetTokenT simTrackToken_; + edm::EDGetTokenT simVertexToken_; + edm::EDGetTokenT hepmc3Token_; + edm::EDGetTokenT hepmc2Token_; + std::vector>> caloSimHitTokens_; + std::vector> trackerSimHitTokens_; + + bool mergeGenSimVertices_; + bool dropHitlessSimSubgraphs_; + truth::TruthLogicalGraphPostProcessor postProcessor_; +}; + +DEFINE_FWK_MODULE(TruthLogicalGraphProducer); diff --git a/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py new file mode 100644 index 0000000000000..6f5867096f0d9 --- /dev/null +++ b/PhysicsTools/TruthInfo/python/ST_tch_top_14TeV_TuneCP5_cfi.py @@ -0,0 +1,33 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). +# +# t-channel single-top GEN fragment (q q' -> t q'' via t-channel W). There is no +# single-top sample in the standard relval matrix, so this minimal fragment is used +# to produce a gallery/library example that exercises the 'top' selection preset +# keeping the production co-products (the recoiling spectator quark alongside the t). + +import FWCore.ParameterSet.Config as cms +from Configuration.Generator.Pythia8CommonSettings_cfi import * +from Configuration.Generator.MCTunes2017.PythiaCP5Settings_cfi import * + +generator = cms.EDFilter("Pythia8ConcurrentGeneratorFilter", + pythiaHepMCVerbosity = cms.untracked.bool(False), + maxEventsToPrint = cms.untracked.int32(0), + pythiaPylistVerbosity = cms.untracked.int32(0), + filterEfficiency = cms.untracked.double(1.0), + comEnergy = cms.double(14000.0), + PythiaParameters = cms.PSet( + pythia8CommonSettingsBlock, + pythia8CP5SettingsBlock, + processParameters = cms.vstring( + 'Top:qq2tq(t:W) = on', # t-channel single top (and single antitop) + '6:m0 = 175 ', + ), + parameterSets = cms.vstring('pythia8CommonSettings', + 'pythia8CP5Settings', + 'processParameters', + ) + ) + ) +ProductionFilterSequence = cms.Sequence(generator) diff --git a/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py b/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py new file mode 100644 index 0000000000000..821d315debf9b --- /dev/null +++ b/PhysicsTools/TruthInfo/python/TTto2L2Nu_Powheg_Pythia8_cfi.py @@ -0,0 +1,54 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). +# +# ttbar -> dilepton (TTto2L2Nu) at NLO with POWHEG (hvq) showered by Pythia8, used +# to produce a more realistic ttbar gallery/library example than the LO Pythia8 +# Top:gg2ttbar sample. +# +# WARNING: this is the Run3 13.6 TeV gridpack (and a Run3 13.6 TeV tune / +# PS-weights), but the truth-graph library/gallery is Phase-2 (Run4 D120) at +# 14 TeV. The GEN centre-of-mass energy (13.6 TeV) therefore does NOT match the +# Phase-2 detector/conditions it is simulated with - it is included only as a +# topology demonstration. For a physically consistent Phase-2 ttbar-POWHEG sample, +# swap the gridpack for a 14 TeV one and use a matching tune (e.g. CP5 14 TeV). + +import FWCore.ParameterSet.Config as cms + +externalLHEProducer = cms.EDProducer('ExternalLHEProducer', + args = cms.vstring('/cvmfs/cms.cern.ch/phys_generator/gridpacks/PdmV/Run3Summer22/Powheg/TT/hvq_slc7_amd64_gcc10_CMSSW_12_4_8_TTto2L2Nu_powheg-pythia8.tgz'), + nEvents = cms.untracked.uint32(5000), + numberOfParameters = cms.uint32(1), + outputFile = cms.string('cmsgrid_final.lhe'), + scriptName = cms.FileInPath('GeneratorInterface/LHEInterface/data/run_generic_tarball_cvmfs.sh'), + generateConcurrently = cms.untracked.bool(True), +) + +from Configuration.Generator.Pythia8CommonSettings_cfi import * +from Configuration.Generator.MCTunesRun3ECM13p6TeV.PythiaCP5Settings_cfi import * +from Configuration.Generator.Pythia8PowhegEmissionVetoSettings_cfi import * +from Configuration.Generator.PSweightsPythia.PythiaPSweightsSettings_cfi import * + +generator = cms.EDFilter("Pythia8ConcurrentHadronizerFilter", + PythiaParameters = cms.PSet( + pythia8CommonSettingsBlock, + pythia8CP5SettingsBlock, + pythia8PowhegEmissionVetoSettingsBlock, + pythia8PSweightsSettingsBlock, + processParameters = cms.vstring( + 'POWHEG:nFinal = 2', + 'TimeShower:mMaxGamma = 1.0' + ), + parameterSets = cms.vstring( + 'pythia8CommonSettings', + 'pythia8CP5Settings', + 'pythia8PowhegEmissionVetoSettings', + 'processParameters', + 'pythia8PSweightsSettings', + ) + ), + comEnergy = cms.double(13600), + maxEventsToPrint = cms.untracked.int32(1), + pythiaHepMCVerbosity = cms.untracked.bool(False), + pythiaPylistVerbosity = cms.untracked.int32(1), +) diff --git a/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py new file mode 100644 index 0000000000000..1e5b912f24b1b --- /dev/null +++ b/PhysicsTools/TruthInfo/python/WWTo2L2Nu_14TeV_TuneCP5_cfi.py @@ -0,0 +1,34 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). +# +# Diboson WW GEN fragment (q qbar -> W+ W-, both W -> leptons). There is no WW +# sample in the standard relval matrix, so this minimal fragment produces a +# gallery/library example that exercises the 'diboson' selection preset (seed the +# vector bosons {23, 24, -24}, keep the production system). + +import FWCore.ParameterSet.Config as cms +from Configuration.Generator.Pythia8CommonSettings_cfi import * +from Configuration.Generator.MCTunes2017.PythiaCP5Settings_cfi import * + +generator = cms.EDFilter("Pythia8ConcurrentGeneratorFilter", + pythiaHepMCVerbosity = cms.untracked.bool(False), + maxEventsToPrint = cms.untracked.int32(0), + pythiaPylistVerbosity = cms.untracked.int32(0), + filterEfficiency = cms.untracked.double(1.0), + comEnergy = cms.double(14000.0), + PythiaParameters = cms.PSet( + pythia8CommonSettingsBlock, + pythia8CP5SettingsBlock, + processParameters = cms.vstring( + 'WeakDoubleBoson:ffbar2WW = on', # q qbar -> W+ W- + '24:onMode = off', + '24:onIfAny = 11 13 15', # W -> e / mu / tau (clean leptonic diboson) + ), + parameterSets = cms.vstring('pythia8CommonSettings', + 'pythia8CP5Settings', + 'processParameters', + ) + ) + ) +ProductionFilterSequence = cms.Sequence(generator) diff --git a/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py b/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py new file mode 100644 index 0000000000000..a3066496a3b56 --- /dev/null +++ b/PhysicsTools/TruthInfo/python/ZHToBB_ZToLL_14TeV_TuneCP5_cfi.py @@ -0,0 +1,38 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). +# +# Associated-Higgs (VH) GEN fragment: Higgs-strahlung q qbar -> Z H, with H -> b b +# and Z -> leptons. There is no VH sample in the standard relval matrix, so this +# minimal fragment produces a gallery/library example that exercises the 'vh' +# selection preset (seed the Higgs {25}, keep the recoiling Z as a production +# sibling). + +import FWCore.ParameterSet.Config as cms +from Configuration.Generator.Pythia8CommonSettings_cfi import * +from Configuration.Generator.MCTunes2017.PythiaCP5Settings_cfi import * + +generator = cms.EDFilter("Pythia8ConcurrentGeneratorFilter", + pythiaHepMCVerbosity = cms.untracked.bool(False), + maxEventsToPrint = cms.untracked.int32(0), + pythiaPylistVerbosity = cms.untracked.int32(0), + filterEfficiency = cms.untracked.double(1.0), + comEnergy = cms.double(14000.0), + PythiaParameters = cms.PSet( + pythia8CommonSettingsBlock, + pythia8CP5SettingsBlock, + processParameters = cms.vstring( + 'HiggsSM:ffbar2HZ = on', # q qbar -> H Z (Higgs-strahlung) + '25:m0 = 125.0', + '25:onMode = off', + '25:onIfMatch = 5 -5', # H -> b bbar + '23:onMode = off', + '23:onIfAny = 11 13 15', # Z -> e / mu / tau + ), + parameterSets = cms.vstring('pythia8CommonSettings', + 'pythia8CP5Settings', + 'processParameters', + ) + ) + ) +ProductionFilterSequence = cms.Sequence(generator) diff --git a/PhysicsTools/TruthInfo/python/addTruthHistoryGuard.py b/PhysicsTools/TruthInfo/python/addTruthHistoryGuard.py new file mode 100644 index 0000000000000..f09c87e060443 --- /dev/null +++ b/PhysicsTools/TruthInfo/python/addTruthHistoryGuard.py @@ -0,0 +1,59 @@ +# Original author: Felice Pantaleo (CERN) +# +# cmsDriver --customise hook for the truth-graph history-guard unit test. Appended +# to a short GEN,SIM job run with --procModifiers enableTruth, it builds the truth +# graph straight from the freshly simulated SimTracks/SimVertices and runs the +# TruthGraphTopologyChecker in failOnViolations mode, so the job throws if the +# SimTrack/SimVertex history is not one tree fully connected to the generator - +# exactly the regression a simulation change that drops the per-track parentage +# (e.g. a port that no longer records parentID for every track) would cause. + +import FWCore.ParameterSet.Config as cms + + +def addTruthHistoryGuard(process): + process.truthGraphProducer = cms.EDProducer( + "TruthGraphProducer", + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + addGenToSimEdges=cms.bool(True), + ) + + process.truthLogicalGraphProducer = cms.EDProducer( + "TruthLogicalGraphProducer", + src=cms.InputTag("truthGraphProducer"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + mergeGenSimVertices=cms.bool(True), + postProcessing=cms.PSet( + collapseIntermediateGenParticles=cms.bool(True), + seedPdgIds=cms.vint32(), # full graph, no selection + seedHadronFlavors=cms.vint32(), + seedParentDepth=cms.uint32(0), + keepStableSpectators=cms.bool(True), + decayPdgIdGroups=cms.VPSet(), + ignoredPdgIds=cms.vint32(), + ignoredParticleIds=cms.vuint32(), + ), + ) + + process.truthHistoryGuard = cms.EDAnalyzer( + "TruthGraphTopologyChecker", + rawSrc=cms.InputTag("truthGraphProducer"), + src=cms.InputTag("truthLogicalGraphProducer"), + # Throw at endJob if the history is fragmented (orphan components / cycles). + failOnViolations=cms.untracked.bool(True), + ) + + process.MessageLogger.cerr.TruthGraphTopologyChecker = cms.untracked.PSet(limit=cms.untracked.int32(-1)) + + process.truthHistoryGuardPath = cms.EndPath( + process.truthGraphProducer + process.truthLogicalGraphProducer + process.truthHistoryGuard + ) + process.schedule.append(process.truthHistoryGuardPath) + + return process diff --git a/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py b/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py new file mode 100644 index 0000000000000..39a141eb4ee0c --- /dev/null +++ b/PhysicsTools/TruthInfo/python/mixedTruthGraphCustomize.py @@ -0,0 +1,58 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# Phase-A pileup customise: enable the SimTrack/SimVertex crossing frames and run +# TruthGraphMixedProducer in the DIGI step (the only place the transient +# CrossingFrame products live), then keep the compact mixed +# raw TruthGraph in the output so downstream steps can read signal+pileup truth. + +import FWCore.ParameterSet.Config as cms +from SimGeneral.MixingModule.fullMixCustomize_cff import setCrossingFrameOn + + +def addMixedTruthGraph(process): + # makeCrossingFrame=True for SimTrack/SimVertex (transient, in-process only). + process = setCrossingFrameOn(process) + + process.truthGraphMixedProducer = cms.EDProducer( + "TruthGraphMixedProducer", + simTracks=cms.InputTag("mix", "g4SimHits"), + simVertices=cms.InputTag("mix", "g4SimHits"), + ) + + process.truthGraphMixedPath = cms.Path(process.truthGraphMixedProducer) + if process.schedule is not None: + process.schedule.append(process.truthGraphMixedPath) + + for out in process.outputModules_().values(): + out.outputCommands.append("keep *_truthGraphMixedProducer_*_*") + + return process + + +def addTruthGraphAccumulator(process, + pileupBunchCrossings=(0,), + collapsePileupGen=True): + """Phase-B (B1): register TruthGraphAccumulator inside the MixingModule. + + The accumulator builds the mixed (signal + pileup) raw TruthGraph from the + native per-sub-event SimTrack/SimVertex collections. By default only in-time + pileup (bx 0) is included; pass pileupBunchCrossings to widen. The mixed graph + is kept in the output as TruthGraph_mix__. + """ + process.mix.digitizers.truthGraph = cms.PSet( + accumulatorType=cms.string("TruthGraphAccumulator"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + pileupBunchCrossings=cms.vint32(*pileupBunchCrossings), + collapsePileupGen=cms.bool(collapsePileupGen), + collapseSignalGen=cms.bool(False), + ) + + for out in process.outputModules_().values(): + out.outputCommands.append("keep TruthGraph_mix_*_*") + + return process diff --git a/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py b/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py new file mode 100644 index 0000000000000..506b112c36bba --- /dev/null +++ b/PhysicsTools/TruthInfo/python/truthGraphDQMHarvester_cff.py @@ -0,0 +1,92 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# DQM harvesting for the Branch performance-plot validators: turns the booked +# numerator/denominator histograms into reproduction-efficiency plots vs +# eta/pt/energy, in the same fashion as the standard HGCAL/tracking post-processors +# (DQMGenericClient). Profiles (purity/completeness/response) are produced directly +# by the analyzer and need no harvesting. + +import FWCore.ParameterSet.Config as cms +from DQMServices.Core.DQMEDHarvester import DQMEDHarvester + +_branchEfficiency = cms.vstring( + "efficiency_eta 'Branch reproduction efficiency vs #eta;#eta;efficiency' effnum_eta denom_eta", + "efficiency_pt 'Branch reproduction efficiency vs p_{T};p_{T} [GeV];efficiency' effnum_pt denom_pt", + "efficiency_energy 'Branch reproduction efficiency vs E;E [GeV];efficiency' effnum_energy denom_energy", + # "Other way around": fraction of objects whose best hit-matched Branch is the + # natural (trackId-seeded) one, from the validator's selfmatch_* numerators. + "selfmatchrate_eta 'Best Branch is the natural one vs #eta;#eta;self-match rate' selfmatch_eta denom_eta", + "selfmatchrate_pt 'Best Branch is the natural one vs p_{T};p_{T} [GeV];self-match rate' selfmatch_pt denom_pt", +) + +branchHGCalPostProcessor = DQMEDHarvester( + "DQMGenericClient", + subDirs=cms.untracked.vstring( + "HGCAL/BranchValidator/CaloParticle", + "HGCAL/BranchValidator/SimCluster", + ), + efficiency=_branchEfficiency, + resolution=cms.vstring(), + verbose=cms.untracked.uint32(0), + outputFileName=cms.untracked.string(""), +) + +# Tracking: "Branch reproduces the TrackingParticle track->truth assignment" +# efficiency vs eta/pt (energy is meaningless for the tracker). +_branchTrackingEfficiency = cms.vstring( + "efficiency_eta 'Branch reproduction efficiency vs #eta;#eta;efficiency' effnum_eta denom_eta", + "efficiency_pt 'Branch reproduction efficiency vs p_{T};p_{T} [GeV];efficiency' effnum_pt denom_pt", +) + +branchTrackingPostProcessor = DQMEDHarvester( + "DQMGenericClient", + subDirs=cms.untracked.vstring( + "Tracking/BranchValidator/TrackingParticle", + ), + efficiency=_branchTrackingEfficiency, + resolution=cms.vstring(), + verbose=cms.untracked.uint32(0), + outputFileName=cms.untracked.string(""), +) + +# Generic reco-side validators (BranchRecoValidator): efficiency/duplicate over the +# branches, fake-rate/merge-rate over the reco objects, vs eta and the second axis +# (pt for tracks, energy for tracksters). All are num/den ratios via DQMGenericClient. +def _recoSideEfficiency(xName): + return cms.vstring( + "efficiency_eta 'Branch reco efficiency vs #eta;#eta;efficiency' effnum_eta denom_eta", + "efficiency_%s 'Branch reco efficiency;;efficiency' effnum_%s denom_%s" % (xName, xName, xName), + "duplicate_eta 'Branch duplicate rate vs #eta;#eta;duplicate' dupnum_eta denom_eta", + "duplicate_%s 'Branch duplicate rate;;duplicate' dupnum_%s denom_%s" % (xName, xName, xName), + "fakerate_eta 'Reco fake rate vs #eta;#eta;fake rate' fakenum_eta recodenom_eta", + "fakerate_%s 'Reco fake rate;;fake rate' fakenum_%s recodenom_%s" % (xName, xName, xName), + "mergerate_eta 'Reco merge rate vs #eta;#eta;merge rate' mergenum_eta recodenom_eta", + "mergerate_%s 'Reco merge rate;;merge rate' mergenum_%s recodenom_%s" % (xName, xName, xName), + ) + +branchTrackRecoPostProcessor = DQMEDHarvester( + "DQMGenericClient", + subDirs=cms.untracked.vstring("Tracking/BranchValidator/recoTrack"), + efficiency=_recoSideEfficiency("pt"), + resolution=cms.vstring(), + verbose=cms.untracked.uint32(0), + outputFileName=cms.untracked.string(""), +) + +branchTracksterRecoPostProcessor = DQMEDHarvester( + "DQMGenericClient", + subDirs=cms.untracked.vstring("HGCAL/BranchValidator/Trackster"), + efficiency=_recoSideEfficiency("energy"), + resolution=cms.vstring(), + verbose=cms.untracked.uint32(0), + outputFileName=cms.untracked.string(""), +) + +truthGraphDQMHarvesting = cms.Sequence(branchHGCalPostProcessor + branchTrackingPostProcessor) + +# Opt-in harvesting for the experimental reco-side validators (see +# truthGraphRecoSideValidationSequence in truthGraphValidation_cff): pair with that +# sequence only once a disjoint antichain reference is configured. +truthGraphRecoSideHarvesting = cms.Sequence(branchTrackRecoPostProcessor + branchTracksterRecoPostProcessor) diff --git a/PhysicsTools/TruthInfo/python/truthGraphSelections.py b/PhysicsTools/TruthInfo/python/truthGraphSelections.py new file mode 100644 index 0000000000000..23808534df2be --- /dev/null +++ b/PhysicsTools/TruthInfo/python/truthGraphSelections.py @@ -0,0 +1,248 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +"""Per-process logical-graph selection presets for the enableTruth relval samples. + +The enableTruth process modifier attaches to *every* Run4 workflow, and the same +presets are used to pick a focused view for the much larger production zoo. They +collapse to a handful of archetypes that fix the right ``postProcessing`` +selection (which particle is the seed, whether to pull in the seed's hard-scatter +co-products, which decay channel to keep, ...): + + gun single/multi-particle guns seed = the gun species + resonance s-channel Z / DY (+n-jet) / Z' / W(+jets) seed = the resonance, ISR context + vbf VBF / t-channel Higgs (incl. VBF HH) seed = Higgs + keepProductionSiblings + ggf ggF / s-channel single Higgs, di-Higgs (gg->HH) seed = Higgs + vh associated Higgs (WH / ZH / VH / WWH / ZZH) seed = Higgs + recoiling boson + top ttbar / t' pair / ttX (ttH, ttW, ttZ, ttbb, tttt, ...) seed = tops + siblings + singletop single top (t-channel / tW / s-chan) seed = top + production partner (VBF-like) + diboson WW / WZ / ZZ / VBS / same-sign WW seed = the vector bosons + production system + heavyflavor B / charmonium / bottomonium seed by heavy-flavor content + full QCD / MinBias / NuGun / SUSY / LLP / DM / EFT / BSM / unknown keep the whole graph + +``selectionForFragment(name)`` maps a generator-fragment (or short gallery label) +to one of these and returns a plain dict of ``postProcessing`` parameters; the +preset is only a starting point - pass keyword overrides to customise any field, +or build a config by hand from ``TEMPLATES``. ``postProcessingPSet`` wraps the +result as a ``cms.PSet`` for use in a producer/dumper config, and the module's +CLI prints the matching ``dumpTruthGraphsFromGENSIMRECO_cfg.py`` flags (used by +makeTruthGallery.sh). +""" + +import re + +# The archetype names; resolution priority is handled by _RULES below. +TEMPLATE_NAMES = ("gun", "resonance", "vbf", "ggf", "vh", "top", "singletop", "diboson", "heavyflavor", "full") + + +def _selection(seedPdgIds=(0,), + seedHadronFlavors=(), + seedParentDepth=0, + decayPdgIdGroups=(), + keepStableSpectators=True, + attachSelectionSources=True, + keepProductionSiblings=False, + signalOnly=False, + keepBunchCrossings=()): + """A full postProcessing selection dict with sensible build-side defaults.""" + return dict( + seedPdgIds=[int(p) for p in seedPdgIds], + seedHadronFlavors=[int(f) for f in seedHadronFlavors], + seedParentDepth=int(seedParentDepth), + decayPdgIdGroups=[[int(p) for p in g] for g in decayPdgIdGroups], + keepStableSpectators=bool(keepStableSpectators), + attachSelectionSources=bool(attachSelectionSources), + keepProductionSiblings=bool(keepProductionSiblings), + # Pile-up filter (orthogonal to the preset; off by default = keep all bx). + signalOnly=bool(signalOnly), + keepBunchCrossings=[int(b) for b in keepBunchCrossings], + ) + + +# --- the seven pre-made templates ------------------------------------------ +# Each is a zero-argument factory returning a fresh dict (so callers can mutate). +TEMPLATES = { + # Guns: each primary is its own signal; no upstream, no underlying event. + "gun": lambda: _selection(seedPdgIds=(0,), seedParentDepth=0, + keepStableSpectators=False, attachSelectionSources=True), + # s-channel resonance: seed the boson, show one generation of incoming partons. + "resonance": lambda: _selection(seedPdgIds=(23, 32), seedParentDepth=1), + # VBF / t-channel: the tagging quarks recoil at the Higgs production vertex. + "vbf": lambda: _selection(seedPdgIds=(25,), seedParentDepth=1, keepProductionSiblings=True), + # ggF / s-channel single Higgs: 2->1, no production-vertex co-products. Also + # used for gg->HH di-Higgs: seedPdgIds=25 seeds every Higgs. + "ggf": lambda: _selection(seedPdgIds=(25,), seedParentDepth=1), + # Associated single Higgs (VH: WH / ZH / VH / WWH / ZZH): seed the Higgs; + # keepProductionSiblings retains the recoiling vector boson(s). + "vh": lambda: _selection(seedPdgIds=(25,), seedParentDepth=1, keepProductionSiblings=True), + # Top pair (ttbar / t'): seed both tops; their decay chains are the signal, + # with keepProductionSiblings retaining the gg/qq -> tt production system. + "top": lambda: _selection(seedPdgIds=(6, -6), seedParentDepth=1, keepProductionSiblings=True), + # Single top: one top plus its production partner is the point of interest - + # the t-channel spectator quark, the tW associated W, the s-channel b. VBF-like, + # keepProductionSiblings pulls in t+q / t+W rather than (just) the top decay. + "singletop": lambda: _selection(seedPdgIds=(6, -6), seedParentDepth=1, keepProductionSiblings=True), + # Diboson (WW / WZ / ZZ, including VBS and same-sign WW): seed the vector + # bosons and keep the production system (VBS tagging jets, associated partons). + "diboson": lambda: _selection(seedPdgIds=(23, 24, -24), seedParentDepth=1, keepProductionSiblings=True), + # Heavy flavor: seed by hadron flavor content (5=b, 4=c); the hadron is the root. + "heavyflavor": lambda: _selection(seedPdgIds=(), seedHadronFlavors=(5,), seedParentDepth=0, + keepStableSpectators=False), + # Everything else (QCD, MinBias, NuGun, BSM, unknown): keep the whole graph. + "full": lambda: _selection(seedPdgIds=(0,)), +} + + +# --- gun species -> seed PDG ids ------------------------------------------- +# The species word is glued to the multiplicity prefix (SingleElectron, TenTau, +# FourMu), so this is a plain ordered substring search: specific/long species +# first, ambiguous short ones (mu, pi) last. Detector-region CloseBy / CE_ guns +# have a configurable species and fall through to the full-graph seed (0). +_GUN_SPECIES = ( + ("electron", [11, -11]), + ("positron", [-11]), + ("gamma", [22]), + ("photon", [22]), + ("muon", [13, -13]), + ("pion", [211, -211]), + ("proton", [2212]), + ("tau", [15, -15]), + ("kaon", [321, -321]), + ("nu", [0]), # neutrino guns leave no hits -> keep the full graph + ("mu", [13, -13]), + ("pi", [211, -211]), +) + + +def gunSeed(name): + """Seed PDG ids for a particle-gun fragment, or [0] (full graph) if unknown.""" + low = name.lower() + for token, pdgs in _GUN_SPECIES: + if token in low: + return list(pdgs) + return [0] + + +# --- fragment-name -> (template, overrides) rules -------------------------- +# First match wins; order specific -> generic. Patterns match both the full +# generator-fragment cfi name and the short gallery label. +_RULES = ( + (r"(?i)(^|[_-])vbf|qqtohto", "vbf", {}), + (r"(?i)h125gggluonfusion|glugluh(to)?|(^|[_-])ggh", "ggf", {}), + (r"(?i)(^|[_-])singletop|(^|[_-])st_[ts]", "singletop", {}), + # ttbar / t' pair AND ttX (ttH, ttW, ttZ, ttbb, tttt, ttDM, ...): anything + # whose name starts with "tt" followed by a top partner/decay token has tops. + # Checked before the Higgs(VH/HH) and diboson rules so e.g. ttHH / ttZ seed tops. + (r"(?i)ttbar|(^|[_-])tt[0-9]*(to|bar|h|w|z|b|c|d|g|j|t|s)|tprimeto", "top", {}), + # Associated single Higgs: WH / ZH / VH / WWH / ZZH (and HW/HZ orderings). + (r"(?i)(^|[_-])(w|z|v|ww|wz|zz)h([0-9]|to|j|_|-|$)|(^|[_-])h[wz]j?([0-9]|to|_|-)|ggzh", "vh", {}), + # Di-Higgs (gg->HH and HH->...): seed every Higgs via the ggf preset. + (r"(?i)(^|[_-])hh|hhto|gluglutohh|to2hh|dihiggs", "ggf", {}), + # Diboson incl. VBS / same-sign WW. After VH/HH so WWH/ZZH/HHto...WWZZ are not stolen. + (r"(?i)(^|[_-])(ww|wz|zz|vv)([0-9]|to|jj|_|-|$)|(^|[_-])vbs|ssww|osww|wpwp|diboson", "diboson", {}), + # W single-boson and W+jets (WToLNu/WtoTauNu, WJetsToLNu, W4JToLNu, Wj_enuj, ...). + (r"(?i)wprime|wto[lme]nu|wtotaunu|wtolnu|(^|[_-])wto|(^|[_-])w[0-9]*j(et|ets|_|to)", "resonance", + dict(seedPdgIds=[24, -24])), + (r"(?i)zmm|zptomm", "resonance", dict(seedPdgIds=[23, 32], decayPdgIdGroups=[[13, -13]])), + (r"(?i)zee|zptoee", "resonance", dict(seedPdgIds=[23, 32], decayPdgIdGroups=[[11, -11]])), + (r"(?i)ztt|zptt|dytotautau", "resonance", dict(seedPdgIds=[23, 32], decayPdgIdGroups=[[15, -15]])), + # Drell-Yan (incl. n-jet DY1jToLL / dyellell) and s-channel Z / Z' (incl. prefixed Z'). + (r"(?i)(^|[_-])dy|drell|zprimeto|(^|[_-])z(prime)?to", "resonance", {}), + (r"(?i)jpsi|psi2s|chic|chib|upsilon|etab", "heavyflavor", dict(seedHadronFlavors=[4])), + (r"(?i)(^|[_-])b[sdu0c]to|bumixing|lambdab", "heavyflavor", dict(seedHadronFlavors=[5])), + (r"(?i)sms-|displacedsusy|(^|[_-])susy|glugluto2jets", "full", {}), + (r"(?i)singlenu|nugun|(^|[_-])nu(e|mu|tau|gun)", "full", {}), + (r"(?i)^(single|double|triple|four|five|six|ten|eleven|twelve|flat|closeby|ce_)", "gun", {}), + (r"(?i)qcd|minbias|photonjet", "full", {}), +) + + +def templateForFragment(name): + """Return (template_name, overrides) for a generator fragment / gallery label.""" + for pattern, template, overrides in _RULES: + if re.search(pattern, name): + ov = dict(overrides) + if template == "gun": + ov.setdefault("seedPdgIds", gunSeed(name)) + return template, ov + return "full", {} + + +def selectionForFragment(name=None, template=None, **overrides): + """Resolve a fragment (or an explicit ``template`` name) to a postProcessing + selection dict, then apply keyword ``overrides`` (full customisation).""" + if template is None: + if name is None: + raise ValueError("selectionForFragment needs a fragment name or a template") + template, auto = templateForFragment(name) + else: + auto = {} + if template not in TEMPLATES: + raise KeyError("unknown template %r (known: %s)" % (template, ", ".join(TEMPLATE_NAMES))) + selection = TEMPLATES[template]() + selection.update(auto) + selection.update(overrides) + return selection + + +def postProcessingPSet(name=None, template=None, **overrides): + """``selectionForFragment`` wrapped as a complete ``cms.PSet`` (build-side + defaults included), ready to drop into a producer's ``postProcessing``.""" + import FWCore.ParameterSet.Config as cms + + s = selectionForFragment(name=name, template=template, **overrides) + return cms.PSet( + collapseIntermediateGenParticles=cms.bool(overrides.get("collapseIntermediateGenParticles", True)), + seedPdgIds=cms.vint32(*s["seedPdgIds"]), + seedHadronFlavors=cms.vint32(*s["seedHadronFlavors"]), + seedParentDepth=cms.uint32(s["seedParentDepth"]), + keepStableSpectators=cms.bool(s["keepStableSpectators"]), + attachSelectionSources=cms.bool(s["attachSelectionSources"]), + keepProductionSiblings=cms.bool(s["keepProductionSiblings"]), + signalOnly=cms.bool(s["signalOnly"]), + keepBunchCrossings=cms.vint32(*s["keepBunchCrossings"]), + decayPdgIdGroups=cms.VPSet(*[cms.PSet(pdgIds=cms.vint32(*g)) for g in s["decayPdgIdGroups"]]), + ignoredPdgIds=cms.vint32(*overrides.get("ignoredPdgIds", [])), + ignoredParticleIds=cms.vuint32(*overrides.get("ignoredParticleIds", [])), + ) + + +def dumperArgs(name=None, template=None, **overrides): + """The dumpTruthGraphsFromGENSIMRECO_cfg.py flags for a fragment's selection.""" + s = selectionForFragment(name=name, template=template, **overrides) + # Empty seedPdgIds means "seed by flavor/decay group, not PDG": omit -s so the + # dumper keeps it empty (passing -s 0 would force the full-graph escape hatch). + args = [] + if s["seedPdgIds"]: + args += ["-s", ",".join(str(p) for p in s["seedPdgIds"])] + args += ["-d", str(s["seedParentDepth"])] + if s["seedHadronFlavors"]: + args += ["-f", ",".join(str(f) for f in s["seedHadronFlavors"])] + for group in s["decayPdgIdGroups"]: + args += ["-g", ",".join(str(p) for p in group)] + args.append("--keepSpectators" if s["keepStableSpectators"] else "--no-keepSpectators") + args.append("--attachSources" if s["attachSelectionSources"] else "--no-attachSources") + if s["keepProductionSiblings"]: + args.append("--keepProductionSiblings") + if s["signalOnly"]: + args.append("--signal-only") + if s["keepBunchCrossings"]: + args += ["--bunch-crossings", ",".join(str(b) for b in s["keepBunchCrossings"])] + return args + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Resolve a generator fragment to its truth-graph selection preset.") + parser.add_argument("fragment", help="generator fragment cfi name or short gallery label") + parser.add_argument("--template", default=None, help="force a template instead of auto-resolving") + parser.add_argument("--dump-args", action="store_true", help="print dumpTruthGraphs... flags (default)") + parser.add_argument("--name", action="store_true", help="print only the resolved template name") + args = parser.parse_args() + + if args.name: + print(args.template or templateForFragment(args.fragment)[0]) + else: + print(" ".join(dumperArgs(name=args.fragment, template=args.template))) diff --git a/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py b/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py new file mode 100644 index 0000000000000..a822b9beb2907 --- /dev/null +++ b/PhysicsTools/TruthInfo/python/truthGraphValidation_cff.py @@ -0,0 +1,177 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# Branch performance-plot validation: the truth-graph producers, the Branch<->reco +# association maps, and the DQM analyzers that turn them into plots comparing the +# truth::Branch graph to the legacy truth objects. Harvesting (efficiency) lives in +# truthGraphDQMHarvester_cff. Hooked into globalValidation behind enableTruth. + +import FWCore.ParameterSet.Config as cms +from DQMServices.Core.DQMEDAnalyzer import DQMEDAnalyzer + +# Reuse the producer chain already defined for the prevalidation. +from Validation.Configuration.truthPrevalidation_cff import ( + truthGraphProducer, + truthLogicalGraphProducer, + detIdToRecHitMapProducer, + truthLogicalGraphHitIndexProducer, +) + +# TICL-style Branch <-> calo-truth association maps (best-matched branch first), +# restricted to the interesting particles via interestingPdgIds (empty = all). +truthBranchCaloAssociationProducer = cms.EDProducer( + "TruthBranchCaloAssociationProducer", + src=cms.InputTag("truthLogicalGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + caloParticles=cms.InputTag("mix", "MergedCaloTruth"), + simClusters=cms.InputTag("mix", "MergedCaloTruth"), + interestingPdgIds=cms.vint32(), +) + +branchHGCalValidator = DQMEDAnalyzer( + "BranchHGCalValidator", + src=cms.InputTag("truthLogicalGraphProducer"), + rawSrc=cms.InputTag("truthGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + caloParticles=cms.InputTag("mix", "MergedCaloTruth"), + simClusters=cms.InputTag("mix", "MergedCaloTruth"), + folder=cms.string("HGCAL/BranchValidator"), + minPt=cms.double(1.0), + maxEta=cms.double(3.0), +) + +# Tracker counterpart. A TrackingParticle has no hits of its own, so the +# Branch<->TrackingParticle comparison is mediated by the reco track: the +# association producer matches reco tracks to branches by shared tracker simhits, +# and the validator closes the loop to the TrackingParticle via ClusterTPAssociation. +# Phase-2 tracker: pixel + outer-tracker (Phase2TrackerCluster1D), no strips. +from SimTracker.TrackerHitAssociation.tpClusterProducer_cfi import tpClusterProducer as _tpClusterProducer +truthTpClusterProducer = _tpClusterProducer.clone( + pixelClusterSrc=cms.InputTag("siPixelClusters"), + phase2OTClusterSrc=cms.InputTag("siPhase2Clusters"), + pixelSimLinkSrc=cms.InputTag("simSiPixelDigis", "Pixel"), + phase2OTSimLinkSrc=cms.InputTag("simSiPixelDigis", "Tracker"), + trackingParticleSrc=cms.InputTag("mix", "MergedTrackTruth"), + throwOnMissingCollections=cms.bool(False), +) + +truthBranchTrackingAssociationProducer = cms.EDProducer( + "TruthBranchTrackingAssociationProducer", + src=cms.InputTag("truthLogicalGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + tracks=cms.InputTag("generalTracks"), + interestingPdgIds=cms.vint32(), +) + +branchTrackingValidator = DQMEDAnalyzer( + "BranchTrackingValidator", + src=cms.InputTag("truthLogicalGraphProducer"), + rawSrc=cms.InputTag("truthGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + tracks=cms.InputTag("generalTracks"), + clusterTPMap=cms.InputTag("truthTpClusterProducer"), + folder=cms.string("Tracking/BranchValidator"), + minPt=cms.double(0.9), + maxEta=cms.double(3.0), +) + +# Generic reco-side validators: match a reco collection to the Branch graph by +# shared hits and book MTV/HGCalValidator-style efficiency/fake/merge/duplicate. +# One template, two instantiations driven by the truth::recoHits adapters. +# +# IMPORTANT (why these are EXPERIMENTAL/opt-in, see the sequence below): the sim +# reference (interestingPdgIds) must be a DISJOINT (antichain) set of particles. A +# Branch subgraph aggregates a particle's descendants, so against the full graph +# every ancestor contains its descendants' hits and every reco object "merges" >=2 +# nested branches (merge-rate ~1, efficiency ~0). A flat PDG-id list is a sufficient +# antichain ONLY for non-showering species (e.g. muons); for showering species it is +# still degenerate. The physically correct reference is the BranchSelector +# "interesting particles" antichain (CaloParticle-like for calo, TrackingParticle- +# like for tracking) - detector-dependent and not yet wired. The values below are +# placeholders for that opt-in configuration; muons are the one species that already +# gives meaningful numbers (see test/validateBranchRecoDQM_cfg.py). +branchTrackRecoValidator = DQMEDAnalyzer( + "BranchTrackRecoValidator", + src=cms.InputTag("truthLogicalGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + recoCollection=cms.InputTag("generalTracks"), + interestingPdgIds=cms.vint32(13, -13), # muons: a near-antichain (do not shower) + folder=cms.string("Tracking/BranchValidator/recoTrack"), + xName=cms.string("pt"), + xTitle=cms.string("p_{T} [GeV]"), + xMax=cms.double(200.0), + minX=cms.double(0.9), + minAbsEta=cms.double(0.0), + maxAbsEta=cms.double(3.0), + matchThreshold=cms.double(0.5), + mergeThreshold=cms.double(0.3), +) + +branchTracksterRecoValidator = DQMEDAnalyzer( + "BranchTracksterRecoValidator", + src=cms.InputTag("truthLogicalGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + recoCollection=cms.InputTag("ticlTrackstersCLUE3DHigh"), + layerClusters=cms.InputTag("hgcalMergeLayerClusters"), + # Placeholder; needs the BranchSelector calo antichain to be non-degenerate. + interestingPdgIds=cms.vint32(22, 11, -11, 211, -211, 321, -321, 2212, -2212, 2112), + folder=cms.string("HGCAL/BranchValidator/Trackster"), + xName=cms.string("energy"), + xTitle=cms.string("E [GeV]"), + xMax=cms.double(500.0), + minX=cms.double(1.0), + minAbsEta=cms.double(1.5), + maxAbsEta=cms.double(3.0), + matchThreshold=cms.double(0.5), + mergeThreshold=cms.double(0.3), +) + +# Producers (truth graph + hit index + association maps) followed by the DQM +# analyzers that reproduce the legacy truth objects (CaloParticle/SimCluster via +# branchHGCalValidator, TrackingParticle via branchTrackingValidator - both verified +# meaningful). Append to a validation sequence with the calo truth, the reco tracks +# and the tracker digi sim-links available. +truthGraphValidationSequence = cms.Sequence( + truthGraphProducer + + truthLogicalGraphProducer + + detIdToRecHitMapProducer + + truthLogicalGraphHitIndexProducer + + truthBranchCaloAssociationProducer + + truthTpClusterProducer + + truthBranchTrackingAssociationProducer + + branchHGCalValidator + + branchTrackingValidator +) + +# Split views for wiring into the release validation: the EDProducers (truth graph, +# hit index, association maps) run in the prevalidation Path, the DQM analyzers in +# the validation EndPath. truthGraphValidationSequence (above) keeps both together +# for the standalone single-file drivers in test/. +truthGraphValidationProducers = cms.Sequence( + truthGraphProducer + + truthLogicalGraphProducer + + detIdToRecHitMapProducer + + truthLogicalGraphHitIndexProducer + + truthBranchCaloAssociationProducer + + truthTpClusterProducer + + truthBranchTrackingAssociationProducer +) +truthGraphValidationAnalyzers = cms.Sequence( + branchHGCalValidator + + branchTrackingValidator +) + +# EXPERIMENTAL, opt-in (NOT in the default sequence): the generic reco-side +# eff/fake/merge/duplicate validators are only meaningful against a DISJOINT +# (antichain) interesting-particle reference. Because a Branch subgraph aggregates +# descendants, against the full graph every ancestor contains its descendants' hits +# and every reco object merges nested branches (merge-rate ~1, efficiency ~0). A +# flat PDG-id list is NOT a sufficient antichain for showering species (it only +# works for non-showering ones such as muons). The correct reference is the +# BranchSelector "interesting particles" antichain (CaloParticle-like for calo, +# TrackingParticle-like for tracking), which is detector-dependent and not yet +# wired - so these run only on demand (see test/validateBranchRecoDQM_cfg.py). +truthGraphRecoSideValidationSequence = cms.Sequence( + branchTrackRecoValidator + branchTracksterRecoValidator +) diff --git a/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py b/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py new file mode 100755 index 0000000000000..9c7fe72239957 --- /dev/null +++ b/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +"""Render the truth-Branch DQM validation plots, inspired by +Validation/HGCalValidation/scripts/makeHGCalValidationPlots.py but self-contained +(PyROOT only). It reads one or more DQM files - either the analyzer DQMIO output +(per-type TH1Fs/TProfiles trees) or a harvested legacy DQM_V0001 file - locates the +Branch-validator folders, derives the efficiency / fake-rate / merge-rate / +duplicate-rate ratios from their numerator/denominator histograms (with binomial +errors) and overlays the booked quality distributions. Passing several files +overlays the samples in one set of plots (e.g. Tau vs ZMM vs TTbar), which doubles +as the per-event guided comparison. Output: one PNG per plot plus an index.html. + +Examples: + makeTruthGraphValidationPlots.py branch_reco_dqm.root -o plots + makeTruthGraphValidationPlots.py tau.root:Tau zmm.root:ZMM ttbar.root:TTbar -o plots +""" + +import os +import argparse + +import ROOT + +ROOT.gROOT.SetBatch(True) +ROOT.gStyle.SetOptStat(0) +ROOT.gErrorIgnoreLevel = ROOT.kWarning + +# Each folder: ratio plots (name -> (num, den, y-title)) and direct distributions. +FOLDERS = [ + ("Tracking/BranchValidator/TrackingParticle", "Branch vs TrackingParticle", { + "ratios": { + # For tracking the self-match rate IS the reproduction efficiency + # (best Branch == the TP's natural Branch). + "efficiency_eta": ("effnum_eta", "denom_eta", "Branch reproduces TP / self-match"), + "efficiency_pt": ("effnum_pt", "denom_pt", "Branch reproduces TP / self-match"), + }, + "dists": ["completeness_hits", "shared_hits", "n_sharing_branches"], + }), + ("Tracking/BranchValidator/recoTrack", "Reco track vs Branch", { + "ratios": { + "efficiency_eta": ("effnum_eta", "denom_eta", "efficiency"), + "efficiency_pt": ("effnum_pt", "denom_pt", "efficiency"), + "fakerate_eta": ("fakenum_eta", "recodenom_eta", "fake rate"), + "mergerate_eta": ("mergenum_eta", "recodenom_eta", "merge rate"), + "duplicate_eta": ("dupnum_eta", "denom_eta", "duplicate rate"), + }, + "dists": ["purity"], + }), + ("HGCAL/BranchValidator/Trackster", "Trackster vs Branch", { + "ratios": { + "efficiency_eta": ("effnum_eta", "denom_eta", "efficiency"), + "efficiency_energy": ("effnum_energy", "denom_energy", "efficiency"), + "fakerate_eta": ("fakenum_eta", "recodenom_eta", "fake rate"), + "mergerate_eta": ("mergenum_eta", "recodenom_eta", "merge rate"), + }, + "dists": ["purity"], + }), + ("HGCAL/BranchValidator/CaloParticle", "Branch vs CaloParticle", { + "ratios": { + "efficiency_eta": ("effnum_eta", "denom_eta", "Branch reproduces CP"), + "efficiency_pt": ("effnum_pt", "denom_pt", "Branch reproduces CP"), + "efficiency_energy": ("effnum_energy", "denom_energy", "Branch reproduces CP"), + "selfmatchrate_eta": ("selfmatch_eta", "denom_eta", "best Branch is the natural one"), + "selfmatchrate_pt": ("selfmatch_pt", "denom_pt", "best Branch is the natural one"), + }, + "dists": ["purity", "completeness_hits", "completeness_energy", "energy_response", + "raw_energy_response_sim", "raw_energy_response_reco", + "bestmatch_purity", "bestmatch_completeness_hits", "bestmatch_completeness_energy", + "bestmatch_response", "n_sharing_branches"], + }), + ("HGCAL/BranchValidator/SimCluster", "Branch vs SimCluster", { + "ratios": { + "efficiency_eta": ("effnum_eta", "denom_eta", "Branch reproduces SC"), + "efficiency_pt": ("effnum_pt", "denom_pt", "Branch reproduces SC"), + "efficiency_energy": ("effnum_energy", "denom_energy", "Branch reproduces SC"), + "selfmatchrate_eta": ("selfmatch_eta", "denom_eta", "best Branch is the natural one"), + "selfmatchrate_pt": ("selfmatch_pt", "denom_pt", "best Branch is the natural one"), + }, + "dists": ["purity", "completeness_hits", "completeness_energy", "energy_response", + "raw_energy_response_sim", "raw_energy_response_reco", + "bestmatch_purity", "bestmatch_completeness_hits", "bestmatch_completeness_energy", + "bestmatch_response", "n_sharing_branches"], + }), +] + +COLORS = [ROOT.kBlack, ROOT.kRed + 1, ROOT.kAzure + 1, ROOT.kGreen + 2, ROOT.kMagenta + 1, ROOT.kOrange + 7] + + +class DQMReader: + """Reads MonitorElements by full path from either DQMIO trees or a legacy file.""" + + def __init__(self, path): + self.file = ROOT.TFile.Open(path) + if not self.file or self.file.IsZombie(): + raise IOError("cannot open %s" % path) + self.byPath = {} + self._index_dqmio() + self.legacyBase = None + if not self.byPath: + self.legacyBase = self._find_legacy_base() + + def _index_dqmio(self): + for tname in ("TH1Fs", "TH1Ds", "TProfiles"): + t = self.file.Get(tname) + if not t: + continue + for i in range(t.GetEntries()): + t.GetEntry(i) + self.byPath[str(t.FullName)] = (tname, i) + + def _find_legacy_base(self): + # DQMData/Run //Run summary/ + dqm = self.file.Get("DQMData") + if not dqm: + return None + for k in dqm.GetListOfKeys(): + if k.GetName().startswith("Run "): + return "DQMData/%s" % k.GetName() + return None + + def get(self, folder, name): + full = "%s/%s" % (folder, name) + if full in self.byPath: + tname, i = self.byPath[full] + t = self.file.Get(tname) + t.GetEntry(i) + return t.Value.Clone() + if self.legacyBase: + top, _, sub = folder.partition("/") + path = "%s/%s/Run summary/%s/%s" % (self.legacyBase, top, sub, name) + obj = self.file.Get(path) + if obj: + return obj.Clone() + return None + + +def ratio_hist(num, den, ytitle): + h = num.Clone() + h.Reset() + h.Divide(num, den, 1.0, 1.0, "B") # binomial errors + h.GetYaxis().SetTitle(ytitle) + h.GetYaxis().SetRangeUser(0.0, 1.15) + return h + + +def draw_overlay(hists_labels, title, outpath): + c = ROOT.TCanvas("c", title, 700, 600) + leg = ROOT.TLegend(0.62, 0.78, 0.88, 0.90) + leg.SetBorderSize(0) + leg.SetFillStyle(0) + drawn = False + keep = [] + for idx, (h, label) in enumerate(hists_labels): + if not h or h.GetEntries() == 0: + continue + col = COLORS[idx % len(COLORS)] + h.SetLineColor(col) + h.SetMarkerColor(col) + h.SetMarkerStyle(20) + h.SetMarkerSize(0.7) + h.SetTitle(title) + h.Draw("E1" if not drawn else "E1 SAME") + leg.AddEntry(h, label, "lep") + keep.append(h) + drawn = True + if not drawn: + c.Close() + return False + leg.Draw() + c.SaveAs(outpath) + c.Close() + return True + + +def main(opts): + os.makedirs(opts.out, exist_ok=True) + samples = [] + for spec in opts.inputs: + path, _, label = spec.partition(":") + samples.append((label or os.path.splitext(os.path.basename(path))[0], DQMReader(path))) + + made = [] # (folder_title, png_basename) + for folder, ftitle, spec in FOLDERS: + prefix = folder.replace("/", "_") + # Ratio plots. + for outname, (num, den, ytitle) in spec["ratios"].items(): + hists = [] + for label, reader in samples: + hn, hd = reader.get(folder, num), reader.get(folder, den) + hists.append((ratio_hist(hn, hd, ytitle) if hn and hd else None, label)) + png = "%s__%s.png" % (prefix, outname) + if draw_overlay(hists, "%s: %s" % (ftitle, outname), os.path.join(opts.out, png)): + made.append((ftitle, png)) + # Direct distributions. + for dname in spec["dists"]: + hists = [(reader.get(folder, dname), label) for label, reader in samples] + # normalize distributions for shape comparison across samples + for h, _ in hists: + if h and h.Integral() > 0: + h.Scale(1.0 / h.Integral()) + png = "%s__%s.png" % (prefix, dname) + if draw_overlay(hists, "%s: %s" % (ftitle, dname), os.path.join(opts.out, png)): + made.append((ftitle, png)) + + # index.html grouped by folder. + groups = {} + for ftitle, png in made: + groups.setdefault(ftitle, []).append(png) + with open(os.path.join(opts.out, "index.html"), "w", encoding="utf-8") as f: + f.write("") + f.write("Truth-graph validation plots") + f.write("") + f.write("

Truth-graph (Branch) validation plots

") + f.write("

Samples: %s

" % ", ".join(label for label, _ in samples)) + for ftitle, pngs in groups.items(): + f.write("

%s

" % ftitle) + for png in pngs: + f.write("" % (png, png)) + f.write("") + + print("Wrote %d plots + index.html to %s/" % (len(made), opts.out)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("inputs", nargs="+", metavar="FILE[:LABEL]", + help="DQM file(s); optional :LABEL for the legend (e.g. ttbar.root:TTbar).") + parser.add_argument("-o", "--out", default="truthGraphValidationPlots", help="output directory") + main(parser.parse_args()) diff --git a/PhysicsTools/TruthInfo/src/Branch.cc b/PhysicsTools/TruthInfo/src/Branch.cc new file mode 100644 index 0000000000000..a5f9ac041cda4 --- /dev/null +++ b/PhysicsTools/TruthInfo/src/Branch.cc @@ -0,0 +1,217 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "PhysicsTools/TruthInfo/interface/Branch.h" + +#include +#include +#include +#include + +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" + +namespace { + + bool isNeutrino(int32_t pdgId) { + const int32_t id = std::abs(pdgId); + return id == 12 || id == 14 || id == 16; + } + + // Ordinary hadron whose quark content includes `flavor` (5 = b, 4 = c). + bool hadronHasQuark(int32_t pdgId, int32_t flavor) { + const int32_t id = std::abs(pdgId); + if (id < 100 || id >= 1000000000) + return false; + const int32_t nq1 = (id / 1000) % 10; + const int32_t nq2 = (id / 100) % 10; + const int32_t nq3 = (id / 10) % 10; + return nq1 == flavor || nq2 == flavor || nq3 == flavor; + } + + // Mirror of TruthGraphProducer::packEventId, which memcpys the EncodedEventId + // bytes into the low word of a uint64_t. Decode into a trivial uint32_t and + // rebuild through the public ctor (EncodedEventId is a uint32_t wrapper). + EncodedEventId decodeEventId(uint64_t packedEventId) { + uint32_t raw = 0; + std::memcpy(&raw, &packedEventId, sizeof(raw)); + return EncodedEventId(raw); + } + +} // namespace + +namespace truth { + + Branch::Branch(Graph const* graph, uint32_t rootId, ClosureSpec spec) + : graph_(graph), roots_{rootId}, spec_(std::move(spec)) {} + + Branch::Branch(Graph const* graph, std::vector rootIds, ClosureSpec spec) + : graph_(graph), roots_(std::move(rootIds)), spec_(std::move(spec)) {} + + Particle Branch::root() const { return valid() ? graph_->particle(roots_.front()) : Particle{}; } + + std::vector Branch::roots() const { + std::vector out; + if (!valid()) + return out; + out.reserve(roots_.size()); + for (uint32_t id : roots_) + out.push_back(graph_->particle(id)); + return out; + } + + std::vector Branch::traverse() const { + if (!valid()) + return {}; + + const uint32_t n = graph_->nParticles(); + std::vector visited(n, 0); + std::queue> queue; // (particleId, depth) + std::vector order; + + for (const uint32_t root : roots_) { + if (root < n && !visited[root]) { + visited[root] = 1; + queue.emplace(root, 0); + } + } + + while (!queue.empty()) { + const auto [id, depth] = queue.front(); + queue.pop(); + order.push_back(id); + + bool expand = true; + switch (spec_.kind) { + case ClosureKind::DepthN: + expand = depth < spec_.maxDepth; + break; + case ClosureKind::UntilPdgId: + // Stop at (but include) a particle whose id is in the stop list, + // unless it is itself a root. + expand = depth == 0 || + std::find(spec_.stopPdgIds.begin(), spec_.stopPdgIds.end(), graph_->particles()[id].pdgId) == + spec_.stopPdgIds.end(); + break; + case ClosureKind::Predicate: + expand = depth == 0 || !(spec_.stopAt && spec_.stopAt(graph_->particle(id))); + break; + case ClosureKind::Subtree: + case ClosureKind::StableLeaves: + expand = true; + break; + } + + if (!expand) + continue; + + for (const uint32_t vertexId : graph_->decayVertices(id)) { + if (vertexId >= graph_->nVertices()) + continue; + for (const uint32_t childId : graph_->outgoingParticles(vertexId)) { + if (childId < n && !visited[childId]) { + visited[childId] = 1; + queue.emplace(childId, depth + 1); + } + } + } + } + + // For StableLeaves keep only roots and final-state (childless) particles. + if (spec_.kind == ClosureKind::StableLeaves) { + const auto isRoot = [this](uint32_t id) { return std::find(roots_.begin(), roots_.end(), id) != roots_.end(); }; + std::erase_if(order, [&](uint32_t id) { return !isRoot(id) && !graph_->particle(id).isLeaf(); }); + } + + std::sort(order.begin(), order.end()); + order.erase(std::unique(order.begin(), order.end()), order.end()); + return order; + } + + std::vector Branch::memberIds() const { return traverse(); } + + std::vector Branch::members() const { + std::vector out; + for (uint32_t id : traverse()) + out.push_back(graph_->particle(id)); + return out; + } + + std::vector Branch::stableLeaves() const { + std::vector out; + if (!valid()) + return out; + for (uint32_t id : traverse()) { + auto p = graph_->particle(id); + if (p.isLeaf()) + out.push_back(p); + } + return out; + } + + math::XYZTLorentzVectorD Branch::p4() const { + math::XYZTLorentzVectorD sum; + for (auto const& leaf : stableLeaves()) + sum += leaf.momentum(); + return sum; + } + + math::XYZTLorentzVectorD Branch::visibleP4() const { + math::XYZTLorentzVectorD sum; + for (auto const& leaf : stableLeaves()) { + if (!isNeutrino(leaf.pdgId())) + sum += leaf.momentum(); + } + return sum; + } + + double Branch::invisibleEnergy() const { return p4().energy() - visibleP4().energy(); } + + int32_t Branch::rootPdgId() const { return valid() ? graph_->particles()[roots_.front()].pdgId : 0; } + + std::optional Branch::originWithPdgId(int32_t pdgId) const { + if (!valid()) + return std::nullopt; + if (rootPdgId() == pdgId) + return root(); + return root().firstAncestorWithPdgId(pdgId); + } + + bool Branch::hasHeavyFlavor(int32_t quarkFlavor) const { + for (uint32_t id : traverse()) { + if (hadronHasQuark(graph_->particles()[id].pdgId, quarkFlavor)) + return true; + } + return false; + } + + int32_t Branch::genEvent() const { return valid() ? graph_->particles()[roots_.front()].genEvent : -1; } + + int Branch::bunchCrossing() const { + return valid() ? decodeEventId(graph_->particles()[roots_.front()].eventId).bunchCrossing() : 0; + } + + int Branch::event() const { return valid() ? decodeEventId(graph_->particles()[roots_.front()].eventId).event() : 0; } + + std::optional Branch::commonAncestor(Branch const& other) const { + if (!valid() || !other.valid() || graph_ != other.graph_) + return std::nullopt; + std::vector seeds = roots(); + for (auto const& r : other.roots()) + seeds.push_back(r); + return graph_->lowestCommonAncestor(seeds); + } + + Branch Branch::merged(Branch const& other) const { + if (!valid()) + return other; + if (!other.valid() || graph_ != other.graph_) + return *this; + std::vector ids = roots_; + ids.insert(ids.end(), other.roots_.begin(), other.roots_.end()); + std::sort(ids.begin(), ids.end()); + ids.erase(std::unique(ids.begin(), ids.end()), ids.end()); + return Branch(graph_, std::move(ids), spec_); + } + +} // namespace truth diff --git a/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc b/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc new file mode 100644 index 0000000000000..6af5931243688 --- /dev/null +++ b/PhysicsTools/TruthInfo/src/BranchHitAssociator.cc @@ -0,0 +1,205 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" + +#include +#include +#include +#include + +namespace truth { + + BranchHitAssociator::BranchHitAssociator(LogicalGraphHitIndex const& hitIndex, + std::vector candidateRoots, + Metric metric, + HitChannel channel, + bool emptyRootsMeansAll) + : hitIndex_(&hitIndex), metric_(metric), channel_(channel), roots_(std::move(candidateRoots)) { + if (roots_.empty() && emptyRootsMeansAll) { + roots_.resize(hitIndex_->nParticles()); + std::iota(roots_.begin(), roots_.end(), 0u); + } + + // Per-cell total sim energy (denominator for branch fractions): sum of every + // particle's direct-hit energy on that cell. Use the requested channel. + // directStorage is grouped by particle, not globally sorted, so collect and + // coalesce into a sorted (detId -> energy) table for binary-search lookup. + const auto& directStorage = hitIndex_->channel(channel_).directHits; + std::vector> cells; + cells.reserve(directStorage.size()); + for (auto const& hit : directStorage) + cells.emplace_back(hit.detId, hit.energy); + std::sort(cells.begin(), cells.end(), [](auto const& a, auto const& b) { return a.first < b.first; }); + + cellEnergyKeys_.reserve(cells.size()); + cellEnergyValues_.reserve(cells.size()); + for (auto const& [detId, energy] : cells) { + if (!cellEnergyKeys_.empty() && cellEnergyKeys_.back() == detId) + cellEnergyValues_.back() += energy; + else { + cellEnergyKeys_.push_back(detId); + cellEnergyValues_.push_back(energy); + } + } + + // Inverted index detId -> candidate roots, from each candidate's subgraph + // hits. Built as a flat (detId, root) list, sorted, then packed CSR-style so + // lookups are a binary search plus a contiguous root span (no hashing). + rootSelfEnergySq_.assign(hitIndex_->nParticles(), 0.0); + std::vector> pairs; // (detId, root) + for (const uint32_t root : roots_) { + if (root >= hitIndex_->nParticles()) + continue; + double selfEnergySq = 0.0; + for (auto const& hit : rootHits(root)) { + pairs.emplace_back(hit.detId, root); + selfEnergySq += static_cast(hit.energy) * hit.energy; + } + rootSelfEnergySq_[root] = selfEnergySq; + } + std::sort(pairs.begin(), pairs.end()); // by detId, then root + + cellRootsOffsets_.push_back(0); + cellRoots_.reserve(pairs.size()); + for (std::size_t i = 0; i < pairs.size();) { + const uint32_t detId = pairs[i].first; + cellRootsKeys_.push_back(detId); + std::size_t j = i; + while (j < pairs.size() && pairs[j].first == detId) { + cellRoots_.push_back(pairs[j].second); + ++j; + } + cellRootsOffsets_.push_back(static_cast(cellRoots_.size())); + i = j; + } + } + + std::span BranchHitAssociator::rootHits(uint32_t rootId) const { + return hitIndex_->subgraphHits(channel_, rootId); + } + + std::span BranchHitAssociator::rootsForCell(uint32_t detId) const { + auto it = std::lower_bound(cellRootsKeys_.begin(), cellRootsKeys_.end(), detId); + if (it == cellRootsKeys_.end() || *it != detId) + return {}; + const std::size_t k = static_cast(it - cellRootsKeys_.begin()); + const uint32_t b = cellRootsOffsets_[k]; + const uint32_t e = cellRootsOffsets_[k + 1]; + return std::span(cellRoots_.data() + b, e - b); + } + + float BranchHitAssociator::cellTotalEnergy(uint32_t detId) const { + auto it = std::lower_bound(cellEnergyKeys_.begin(), cellEnergyKeys_.end(), detId); + if (it == cellEnergyKeys_.end() || *it != detId) + return 0.f; + return cellEnergyValues_[static_cast(it - cellEnergyKeys_.begin())]; + } + + std::vector BranchHitAssociator::bestBranches(std::span recoHitsIn, + std::size_t maxResults) const { + std::vector result; + if (recoHitsIn.empty()) + return result; + + // Sort the reco object's hits by detId for the merge-join. + std::vector reco(recoHitsIn.begin(), recoHitsIn.end()); + std::sort(reco.begin(), reco.end(), [](RecoHit const& a, RecoHit const& b) { return a.detId < b.detId; }); + + // Self-normalization (denominator) and the set of candidate roots. + double denominator = 0.0; + std::vector candidates; + for (auto const& h : reco) { + denominator += static_cast(h.fraction * h.energy) * (h.fraction * h.energy); + auto roots = rootsForCell(h.detId); + candidates.insert(candidates.end(), roots.begin(), roots.end()); + } + std::sort(candidates.begin(), candidates.end()); + candidates.erase(std::unique(candidates.begin(), candidates.end()), candidates.end()); + if (denominator <= 0.0) + denominator = 1.0; + + for (const uint32_t root : candidates) { + auto branchHits = rootHits(root); // sorted by detId (LogicalGraphHitIndexBuilder guarantee) + + double sharedEnergy = 0.0; + double scoreNum = 0.0; + uint32_t sharedCells = 0; + + // Branch-normalized (reverse) accumulators over the shared cells. + double sharedBranchEnergySq = 0.0; + double branchExcessNum = 0.0; + + // Merge-join reco hits and the branch subgraph hits by detId. + std::size_t i = 0; + std::size_t j = 0; + while (i < reco.size()) { + const RecoHit& rh = reco[i]; + + // advance branch pointer to rh.detId + while (j < branchHits.size() && branchHits[j].detId < rh.detId) + ++j; + + float branchFraction = 0.f; + float branchEnergy = 0.f; + float cellTotal = 0.f; + const bool shared = (j < branchHits.size() && branchHits[j].detId == rh.detId); + if (shared) { + cellTotal = cellTotalEnergy(rh.detId); + branchEnergy = branchHits[j].energy; + branchFraction = cellTotal > 0.f ? branchEnergy / cellTotal : 0.f; + ++sharedCells; + } + + if (metric_ == Metric::SharedEnergy) { + sharedEnergy += std::min(branchFraction * rh.energy, rh.fraction * rh.energy); + const float excess = std::max(0.f, rh.fraction - branchFraction); + scoreNum += static_cast(excess * rh.energy) * (excess * rh.energy); + if (shared) { + sharedBranchEnergySq += static_cast(branchEnergy) * branchEnergy; + const float branchExcessEnergy = std::max(0.f, branchFraction - rh.fraction) * cellTotal; + branchExcessNum += static_cast(branchExcessEnergy) * branchExcessEnergy; + } + } + ++i; + } + + BranchMatch m; + m.rootParticleId = root; + if (metric_ == Metric::SharedEnergy) { + if (sharedCells == 0) + continue; + m.sharedEnergy = static_cast(sharedEnergy); + m.score = static_cast(scoreNum / denominator); + // Reverse score: the fraction of the branch self-energy the reco object + // fails to capture. Branch-only cells (not visited in the merge-join above) + // are entirely un-captured, contributing (branchDenom - sharedBranchEnergySq) + // to the numerator; the shared cells contribute branchExcessNum. + const double branchDenom = rootSelfEnergySq_[root]; + const double branchScoreNum = std::max(0.0, (branchDenom - sharedBranchEnergySq) + branchExcessNum); + m.reverseScore = branchDenom > 0.0 ? static_cast(branchScoreNum / branchDenom) : 0.f; + } else { + if (sharedCells == 0) + continue; + m.sharedEnergy = static_cast(sharedCells); + m.score = 1.f - static_cast(sharedCells) / static_cast(reco.size()); + // Reverse score: fraction of the branch's cells the reco object misses. + const std::size_t branchCellCount = branchHits.size(); + m.reverseScore = + branchCellCount > 0 ? 1.f - static_cast(sharedCells) / static_cast(branchCellCount) : 1.f; + } + result.push_back(m); + } + + std::sort(result.begin(), result.end(), [](BranchMatch const& a, BranchMatch const& b) { + return a.score != b.score ? a.score < b.score : a.rootParticleId < b.rootParticleId; + }); + + if (maxResults > 0 && result.size() > maxResults) + result.resize(maxResults); + + return result; + } + +} // namespace truth diff --git a/PhysicsTools/TruthInfo/src/BranchSelector.cc b/PhysicsTools/TruthInfo/src/BranchSelector.cc new file mode 100644 index 0000000000000..d2fd59bd0abf5 --- /dev/null +++ b/PhysicsTools/TruthInfo/src/BranchSelector.cc @@ -0,0 +1,47 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "PhysicsTools/TruthInfo/interface/BranchSelector.h" + +#include + +#include "HepPDT/ParticleID.hh" + +namespace truth { + + bool BranchSelector::operator()(Branch const& branch) const { + if (!branch.valid()) + return false; + + if (config_.signalOnly && !branch.isSignal()) + return false; + + if (config_.intimeOnly && !branch.isInTime()) + return false; + + const int32_t pdgId = branch.rootPdgId(); + + if (config_.chargedOnly && HepPDT::ParticleID(pdgId).threeCharge() == 0) + return false; + + if (!config_.pdgIds.empty() && + std::find(config_.pdgIds.begin(), config_.pdgIds.end(), pdgId) == config_.pdgIds.end()) + return false; + + // Kinematics from the defining root particle. Copy by value: root() returns + // a temporary Particle, so a reference to its momentum() would dangle. + const auto p4 = branch.root().momentum(); + const double pt = p4.pt(); + if (pt < config_.ptMin || pt > config_.ptMax) + return false; + + const double eta = p4.eta(); + const bool insideEta = eta >= config_.etaMin && eta <= config_.etaMax; + if (config_.invertEta ? insideEta : !insideEta) + return false; + + return true; + } + +} // namespace truth diff --git a/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc b/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc new file mode 100644 index 0000000000000..e15bac28303dd --- /dev/null +++ b/PhysicsTools/TruthInfo/src/LogicalGraphHitIndexBuilder.cc @@ -0,0 +1,180 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h" + +#include +#include +#include + +namespace truth { + + LogicalGraphHitIndexBuilder::LogicalGraphHitIndexBuilder(uint32_t nParticles) + : nParticles_(nParticles), children_(nParticles) { + for (auto& channel : directHits_) + channel.resize(nParticles); + } + + void LogicalGraphHitIndexBuilder::setSimTrackForParticle(uint32_t particleId, uint32_t trackId) { + if (particleId >= nParticles_) + return; + + trackIdToParticle_[trackId] = particleId; + } + + void LogicalGraphHitIndexBuilder::addParticleChild(uint32_t parentParticleId, uint32_t childParticleId) { + if (parentParticleId >= nParticles_ || childParticleId >= nParticles_) + return; + + children_[parentParticleId].push_back(childParticleId); + } + + void LogicalGraphHitIndexBuilder::addHit( + HitChannel channel, uint32_t trackId, uint32_t detId, float energy, uint32_t recHitIndex) { + if (energy <= 0.f) + return; + + auto it = trackIdToParticle_.find(trackId); + if (it == trackIdToParticle_.end()) + return; + + const std::size_t ch = static_cast(channel); + appendHit(directHits_[ch][it->second], detId, recHitIndex, energy); + channelTouched_[ch] = true; + } + + void LogicalGraphHitIndexBuilder::appendHit(HitList& hits, uint32_t detId, uint32_t recHitIndex, float energy) { + hits.push_back(Hit{detId, recHitIndex, energy}); + } + + void LogicalGraphHitIndexBuilder::coalesce(HitList& hits) { + if (hits.empty()) + return; + + // Sort by detId, then recHitIndex. kInvalidRecHitIndex == UINT32_MAX sorts + // last, so the first entry of each detId run already carries the valid index + // when one exists. + std::sort(hits.begin(), hits.end(), [](Hit const& a, Hit const& b) { + if (a.detId != b.detId) + return a.detId < b.detId; + return a.recHitIndex < b.recHitIndex; + }); + + // In-place merge of consecutive entries that share a detId. + std::size_t w = 0; + for (std::size_t r = 0; r < hits.size(); ++r) { + if (w > 0 && hits[w - 1].detId == hits[r].detId) { + hits[w - 1].energy += hits[r].energy; + if (hits[w - 1].recHitIndex == Hit::kInvalidRecHitIndex && hits[r].recHitIndex != Hit::kInvalidRecHitIndex) + hits[w - 1].recHitIndex = hits[r].recHitIndex; + } else { + hits[w++] = hits[r]; + } + } + hits.resize(w); + + hits.erase(std::remove_if(hits.begin(), hits.end(), [](Hit const& h) { return h.energy <= 0.f; }), hits.end()); + } + + void LogicalGraphHitIndexBuilder::collectSubgraphParticles(uint32_t particleId, + std::vector& visited, + std::vector& touched, + std::vector& stack, + std::vector& order) const { + order.clear(); + if (particleId >= nParticles_) + return; + + // Iterative DFS over the distinct descendants, cycle-safe via `visited`. A + // descendant reachable through more than one path (a re-convergent DAG, e.g. a + // particle whose production vertex has several incoming particles that share a + // common ancestor) is enqueued and summed only once; merging the already + // aggregated child subgraphs instead would add such a descendant's per-cell + // energy once per path (coalesce() sums equal detIds), inflating the subgraph. + stack.clear(); + stack.push_back(particleId); + visited[particleId] = 1; + touched.push_back(particleId); + + while (!stack.empty()) { + const uint32_t current = stack.back(); + stack.pop_back(); + order.push_back(current); + + for (uint32_t childId : children_[current]) { + if (childId >= nParticles_ || visited[childId]) + continue; + visited[childId] = 1; + touched.push_back(childId); + stack.push_back(childId); + } + } + } + + void LogicalGraphHitIndexBuilder::buildHitCSR(std::vector const& lists, + std::vector& offsets, + std::vector& storage) { + offsets.clear(); + storage.clear(); + offsets.reserve(lists.size() + 1); + offsets.push_back(0); + + std::size_t total = 0; + for (auto const& list : lists) + total += list.size(); + storage.reserve(total); + + // Lists are already coalesced (sorted by detId, deduplicated), so the CSR is + // a plain concatenation: each particle's span stays contiguous and ordered. + for (auto const& list : lists) { + storage.insert(storage.end(), list.begin(), list.end()); + offsets.push_back(static_cast(storage.size())); + } + } + + LogicalGraphHitIndex LogicalGraphHitIndexBuilder::finish() { + std::vector channels(kNumHitChannels); + + for (std::size_t ch = 0; ch < kNumHitChannels; ++ch) { + // Empty channels (not selected / detector absent) stay default-constructed, + // skipping the per-particle subgraph aggregation and CSR build entirely. + if (!channelTouched_[ch]) + continue; + + auto& direct = directHits_[ch]; + + // Coalesce the per-particle direct-hit lists once, so the subgraph + // aggregation and the CSR build both operate on sorted, de-duplicated spans. + for (auto& hits : direct) + coalesce(hits); + + std::vector subgraph(nParticles_); + std::vector visited(nParticles_, 0); + std::vector touched; + std::vector stack; + std::vector order; + for (uint32_t particleId = 0; particleId < nParticles_; ++particleId) { + collectSubgraphParticles(particleId, visited, touched, stack, order); + + auto& out = subgraph[particleId]; + for (const uint32_t descendant : order) + out.insert(out.end(), direct[descendant].begin(), direct[descendant].end()); + coalesce(out); + + // Reset only the entries we set, keeping the per-particle cost proportional + // to the subgraph size rather than nParticles_. + for (const uint32_t id : touched) + visited[id] = 0; + touched.clear(); + } + + auto& out = channels[ch]; + buildHitCSR(direct, out.directOffsets, out.directHits); + buildHitCSR(subgraph, out.subgraphOffsets, out.subgraphHits); + } + + return LogicalGraphHitIndex(nParticles_, std::move(channels)); + } + +} // namespace truth diff --git a/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc b/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc new file mode 100644 index 0000000000000..ae54dae0c9963 --- /dev/null +++ b/PhysicsTools/TruthInfo/src/TruthLogicalGraphPostProcessor.cc @@ -0,0 +1,1404 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FWCore/MessageLogger/interface/MessageLogger.h" +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" + +namespace { + + struct DSU { + std::vector parent; + std::vector rank; + + explicit DSU(int n) : parent(n), rank(n, 0) { + for (int i = 0; i < n; ++i) + parent[i] = i; + } + + int find(int x) { + while (parent[x] != x) { + parent[x] = parent[parent[x]]; + x = parent[x]; + } + return x; + } + + void unite(int a, int b) { + a = find(a); + b = find(b); + + if (a == b) + return; + + if (rank[a] < rank[b]) + std::swap(a, b); + + parent[b] = a; + + if (rank[a] == rank[b]) + ++rank[a]; + } + }; + + bool containsPdgId(std::vector const& pdgIds, int32_t pdgId) { + return std::find(pdgIds.begin(), pdgIds.end(), pdgId) != pdgIds.end(); + } + + bool containsParticleId(std::vector const& particleIds, uint32_t particleId) { + return std::find(particleIds.begin(), particleIds.end(), particleId) != particleIds.end(); + } + + // True if pdgId is an ordinary hadron whose quark content includes the given + // flavor (5 = b, 4 = c, ...), using the PDG hadron-numbering digits. + bool hadronHasQuark(int32_t pdgId, int32_t flavor) { + const int32_t id = std::abs(pdgId); + if (id < 100 || id >= 1000000000) // leptons/bosons/diquark-free codes and nuclei are not hadrons here + return false; + const int32_t nq1 = (id / 1000) % 10; + const int32_t nq2 = (id / 100) % 10; + const int32_t nq3 = (id / 10) % 10; + return nq1 == flavor || nq2 == flavor || nq3 == flavor; + } + + bool matchesSeed(truth::Graph const& graph, + uint32_t particleId, + truth::LogicalGraphPostProcessingConfig const& config) { + const int32_t pdgId = graph.particles()[particleId].pdgId; + + if (containsPdgId(config.seedPdgIds, pdgId)) + return true; + + for (const int32_t flavor : config.seedHadronFlavors) { + if (hadronHasQuark(pdgId, flavor)) + return true; + } + + return false; + } + + bool isIgnoredParticle(truth::Graph const& graph, + uint32_t particleId, + std::vector const& ignoredPdgIds, + std::vector const& ignoredParticleIds) { + if (particleId >= graph.nParticles()) + return false; + + if (containsParticleId(ignoredParticleIds, particleId)) + return true; + + if (containsPdgId(ignoredPdgIds, graph.particles()[particleId].pdgId)) + return true; + + return false; + } + + bool isStableGenParticle(truth::Graph const& graph, uint32_t particleId) { + if (particleId >= graph.nParticles()) + return false; + + auto const& particle = graph.particles()[particleId]; + + return particle.hasGen() && particle.status == 1; + } + + void buildCSR(uint32_t nSources, + std::vector>& pairs, + std::vector& offsets, + std::vector& flat) { + std::sort(pairs.begin(), pairs.end()); + pairs.erase(std::unique(pairs.begin(), pairs.end()), pairs.end()); + + pairs.erase( + std::remove_if(pairs.begin(), pairs.end(), [nSources](auto const& edge) { return edge.first >= nSources; }), + pairs.end()); + + offsets.assign(nSources + 1, 0); + + for (auto const& edge : pairs) { + ++offsets[edge.first + 1]; + } + + for (uint32_t i = 1; i <= nSources; ++i) { + offsets[i] += offsets[i - 1]; + } + + flat.assign(pairs.size(), 0); + + auto cursor = offsets; + for (auto const& edge : pairs) { + flat[cursor[edge.first]++] = edge.second; + } + } + + // Rebuild the four CSR adjacency arrays of `output` from the edges of + // `input`, remapping particle and vertex ids and dropping edges with an + // unmapped endpoint. `extraProductionEdges` are additional + // (newVertex, newParticle) production-side edges and `extraDecayEdges` are + // additional (newParticle, newVertex) decay-side edges - e.g. those wiring the + // artificial Interaction/Upstream/UnderlyingEvent vertices and their connector + // particles. buildCSR sorts and deduplicates, so the collection order here does + // not affect the result. + void rebuildAdjacency(truth::Graph const& input, + std::vector const& oldParticleToNew, + std::vector const& oldVertexToNew, + std::vector> const& extraProductionEdges, + std::vector> const& extraDecayEdges, + truth::Graph& output) { + const uint32_t nParticles = input.nParticles(); + + std::vector> particleToDecayVertexPairs; + std::vector> particleToProductionVertexPairs; + std::vector> vertexToOutgoingParticlePairs; + std::vector> vertexToIncomingParticlePairs; + + for (uint32_t oldVertex = 0; oldVertex < input.nVertices(); ++oldVertex) { + const int32_t newVertex = oldVertexToNew[oldVertex]; + if (newVertex < 0) + continue; + + for (const uint32_t oldParticle : input.incomingParticles(oldVertex)) { + if (oldParticle >= nParticles) + continue; + + const int32_t newParticle = oldParticleToNew[oldParticle]; + if (newParticle < 0) + continue; + + particleToDecayVertexPairs.emplace_back(static_cast(newParticle), static_cast(newVertex)); + vertexToIncomingParticlePairs.emplace_back(static_cast(newVertex), + static_cast(newParticle)); + } + + for (const uint32_t oldParticle : input.outgoingParticles(oldVertex)) { + if (oldParticle >= nParticles) + continue; + + const int32_t newParticle = oldParticleToNew[oldParticle]; + if (newParticle < 0) + continue; + + vertexToOutgoingParticlePairs.emplace_back(static_cast(newVertex), + static_cast(newParticle)); + particleToProductionVertexPairs.emplace_back(static_cast(newParticle), + static_cast(newVertex)); + } + } + + for (auto const& [newVertex, newParticle] : extraProductionEdges) { + vertexToOutgoingParticlePairs.emplace_back(newVertex, newParticle); + particleToProductionVertexPairs.emplace_back(newParticle, newVertex); + } + + for (auto const& [newParticle, newVertex] : extraDecayEdges) { + particleToDecayVertexPairs.emplace_back(newParticle, newVertex); + vertexToIncomingParticlePairs.emplace_back(newVertex, newParticle); + } + + buildCSR(output.nParticles(), + particleToDecayVertexPairs, + output.particleToDecayVertexOffsets(), + output.particleToDecayVertices()); + + buildCSR(output.nParticles(), + particleToProductionVertexPairs, + output.particleToProductionVertexOffsets(), + output.particleToProductionVertices()); + + buildCSR(output.nVertices(), + vertexToOutgoingParticlePairs, + output.vertexToOutgoingParticleOffsets(), + output.vertexToOutgoingParticles()); + + buildCSR(output.nVertices(), + vertexToIncomingParticlePairs, + output.vertexToIncomingParticleOffsets(), + output.vertexToIncomingParticles()); + } + + bool directCollapsibleGenParticleChain(truth::Graph const& graph, + uint32_t particleId, + uint32_t& childId, + uint32_t& decayVertexId) { + if (particleId >= graph.nParticles()) + return false; + + auto const& particle = graph.particles()[particleId]; + + if (!particle.hasGen()) + return false; + + // Never collapse stable final-state GEN particles. + if (particle.status == 1) + return false; + + if (particle.pdgId == 0) + return false; + + const auto decayVertices = graph.decayVertices(particleId); + if (decayVertices.size() != 1) + return false; + + const uint32_t vertexId = decayVertices.front(); + if (vertexId >= graph.nVertices()) + return false; + + auto const& vertex = graph.vertices()[vertexId]; + + if (!vertex.hasGen() || vertex.hasSim()) + return false; + + const auto incoming = graph.incomingParticles(vertexId); + const auto outgoing = graph.outgoingParticles(vertexId); + + if (incoming.size() != 1 || incoming.front() != particleId) + return false; + + if (outgoing.size() != 1) + return false; + + const uint32_t candidateChild = outgoing.front(); + if (candidateChild >= graph.nParticles()) + return false; + + if (candidateChild == particleId) + return false; + + auto const& child = graph.particles()[candidateChild]; + + if (!child.hasGen()) + return false; + + if (child.pdgId != particle.pdgId) + return false; + + childId = candidateChild; + decayVertexId = vertexId; + + return true; + } + + truth::Graph collapseIntermediateGenParticleChains(truth::Graph const& input) { + if (input.empty()) + return input; + + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + std::vector directChild(nParticles, -1); + std::vector skipVertex(nVertices, 0); + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + uint32_t childId = 0; + uint32_t decayVertexId = 0; + + if (!directCollapsibleGenParticleChain(input, particleId, childId, decayVertexId)) + continue; + + directChild[particleId] = static_cast(childId); + skipVertex[decayVertexId] = 1; + } + + // Representative of a particle = end of its directChild collapse chain. + // Resolve with path compression so each node is visited once overall + // (amortized O(nParticles)) instead of re-walking the chain per particle + // (O(nParticles^2) for a long chain). state: 0 unvisited, 1 on the current + // walk, 2 resolved; the on-walk marker also makes a (graph is a DAG, so this + // cannot happen) cycle terminate deterministically rather than spin. + std::vector particleRepresentative(nParticles, 0); + std::vector state(nParticles, 0); + std::vector path; + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (state[particleId] == 2) + continue; + + path.clear(); + uint32_t current = particleId; + while (state[current] == 0) { + const int32_t next = directChild[current]; + if (next < 0) + break; // chain end -> representative is `current` + state[current] = 1; + path.push_back(current); + current = static_cast(next); + } + + const uint32_t representative = (state[current] == 2) ? particleRepresentative[current] : current; + if (state[current] != 2) { + particleRepresentative[current] = representative; + state[current] = 2; + } + for (const uint32_t x : path) { + particleRepresentative[x] = representative; + state[x] = 2; + } + } + + truth::Graph output; + + std::unordered_map representativeToNewParticle; + representativeToNewParticle.reserve(nParticles); + + std::vector oldParticleToNew(nParticles, -1); + + for (uint32_t oldParticle = 0; oldParticle < nParticles; ++oldParticle) { + const uint32_t representative = particleRepresentative[oldParticle]; + + auto inserted = + representativeToNewParticle.emplace(representative, static_cast(output.particles().size())); + + const uint32_t newParticle = inserted.first->second; + + if (inserted.second) { + output.particles().push_back(input.particles()[representative]); + } + + oldParticleToNew[oldParticle] = static_cast(newParticle); + } + + std::vector keepVertex(nVertices, 0); + + for (uint32_t oldVertex = 0; oldVertex < nVertices; ++oldVertex) { + if (skipVertex[oldVertex]) + continue; + + for (uint32_t oldParticle : input.incomingParticles(oldVertex)) { + if (oldParticle < oldParticleToNew.size() && oldParticleToNew[oldParticle] >= 0) { + keepVertex[oldVertex] = 1; + break; + } + } + + if (keepVertex[oldVertex]) + continue; + + for (uint32_t oldParticle : input.outgoingParticles(oldVertex)) { + if (oldParticle < oldParticleToNew.size() && oldParticleToNew[oldParticle] >= 0) { + keepVertex[oldVertex] = 1; + break; + } + } + } + + std::vector oldVertexToNew(nVertices, -1); + + for (uint32_t oldVertex = 0; oldVertex < nVertices; ++oldVertex) { + if (!keepVertex[oldVertex]) + continue; + + oldVertexToNew[oldVertex] = static_cast(output.vertices().size()); + output.vertices().push_back(input.vertices()[oldVertex]); + } + + rebuildAdjacency(input, oldParticleToNew, oldVertexToNew, {}, {}, output); + + return output; + } + + void markDownstreamFromParticle(truth::Graph const& graph, + uint32_t particleId, + std::vector& keepParticle, + std::vector& keepVertex) { + if (particleId >= graph.nParticles()) + return; + + std::queue queue; + + if (!keepParticle[particleId]) { + keepParticle[particleId] = 1; + queue.push(particleId); + } + + while (!queue.empty()) { + const uint32_t currentParticle = queue.front(); + queue.pop(); + + for (uint32_t vertexId : graph.decayVertices(currentParticle)) { + if (vertexId >= graph.nVertices()) + continue; + + keepVertex[vertexId] = 1; + + for (uint32_t childId : graph.outgoingParticles(vertexId)) { + if (childId >= graph.nParticles()) + continue; + + if (!keepParticle[childId]) { + keepParticle[childId] = 1; + queue.push(childId); + } + } + } + } + } + + // Keep up to parentDepth generations of ancestors above each root as context + // only: the ancestor particles and the connecting vertices are kept, but + // their other descendants and their own deeper ancestry are not. + void markAncestorContext(truth::Graph const& graph, + std::vector const& roots, + uint32_t parentDepth, + std::vector& keepParticle, + std::vector& keepVertex) { + if (parentDepth == 0) + return; + + std::vector seen(graph.nParticles(), 0); + std::queue> queue; + + for (const uint32_t root : roots) { + if (root >= graph.nParticles()) + continue; + + if (!seen[root]) { + seen[root] = 1; + queue.emplace(root, 0); + } + } + + while (!queue.empty()) { + const auto [particleId, depth] = queue.front(); + queue.pop(); + + if (depth >= parentDepth) + continue; + + for (const uint32_t vertexId : graph.productionVertices(particleId)) { + if (vertexId >= graph.nVertices()) + continue; + + keepVertex[vertexId] = 1; + + for (const uint32_t parentId : graph.incomingParticles(vertexId)) { + if (parentId >= graph.nParticles()) + continue; + + keepParticle[parentId] = 1; + + if (!seen[parentId]) { + seen[parentId] = 1; + queue.emplace(parentId, depth + 1); + } + } + } + } + } + + // Keep each root's hard-scatter co-products: its production vertex and the + // *other* outgoing particles of that vertex, together with their decay + // subtrees. These siblings share the seed's production vertex (e.g. the VBF + // tagging quarks recoiling against the Higgs, which hadronise into the forward + // jets) - they are not ancestors, so seedParentDepth never reaches them. + void markProductionSiblings(truth::Graph const& graph, + std::vector const& roots, + std::vector& keepParticle, + std::vector& keepVertex) { + for (const uint32_t root : roots) { + if (root >= graph.nParticles()) + continue; + + for (const uint32_t vertexId : graph.productionVertices(root)) { + if (vertexId >= graph.nVertices()) + continue; + + keepVertex[vertexId] = 1; + + for (const uint32_t siblingId : graph.outgoingParticles(vertexId)) { + if (siblingId >= graph.nParticles() || siblingId == root) + continue; + + markDownstreamFromParticle(graph, siblingId, keepParticle, keepVertex); + } + } + } + } + + // Decode a particle's packed EncodedEventId (mirror of TruthGraphProducer:: + // packEventId, which memcpys the EncodedEventId bytes into the low word). + EncodedEventId decodeEventId(uint64_t packedEventId) { + uint32_t raw = 0; + std::memcpy(&raw, &packedEventId, sizeof(raw)); + return EncodedEventId(raw); + } + + // Pile-up filter (orthogonal to the seed selection): drop already-kept particles + // by the provenance of their pp collision. signalOnly keeps only (bx 0, event 0); + // a non-empty keepBunchCrossings keeps only the listed bunch crossings; the two + // AND. A no-op when both are unset (every particle is signal in a no-PU sample). + void applyBunchCrossingFilter(truth::Graph const& graph, + bool signalOnly, + std::vector const& keepBunchCrossings, + std::vector& keepParticle, + std::vector& stableSpectator) { + if (!signalOnly && keepBunchCrossings.empty()) + return; + + for (uint32_t particleId = 0; particleId < graph.nParticles(); ++particleId) { + if (!keepParticle[particleId]) + continue; + + const EncodedEventId eid = decodeEventId(graph.particles()[particleId].eventId); + + bool keep = true; + if (signalOnly) + keep = eid.bunchCrossing() == 0 && eid.event() == 0; + if (keep && !keepBunchCrossings.empty()) + keep = std::find(keepBunchCrossings.begin(), keepBunchCrossings.end(), eid.bunchCrossing()) != + keepBunchCrossings.end(); + + if (!keep) { + keepParticle[particleId] = 0; + stableSpectator[particleId] = 0; + } + } + } + + // Restrict matches to the most upstream ones: a match that is a strict + // descendant of another match is covered by that match's subgraph and is not + // an independent root. Single multi-source downstream BFS, O(V + E). + std::vector mostUpstreamOf(truth::Graph const& graph, std::vector const& matches) { + const uint32_t nParticles = graph.nParticles(); + + std::vector strictDescendant(nParticles, 0); + std::vector visited(nParticles, 0); + std::queue queue; + + for (const uint32_t match : matches) { + if (match < nParticles && !visited[match]) { + visited[match] = 1; + queue.push(match); + } + } + + while (!queue.empty()) { + const uint32_t particleId = queue.front(); + queue.pop(); + + for (const uint32_t vertexId : graph.decayVertices(particleId)) { + if (vertexId >= graph.nVertices()) + continue; + + for (const uint32_t childId : graph.outgoingParticles(vertexId)) { + if (childId >= nParticles) + continue; + + strictDescendant[childId] = 1; + + if (!visited[childId]) { + visited[childId] = 1; + queue.push(childId); + } + } + } + } + + std::vector roots; + roots.reserve(matches.size()); + + for (const uint32_t match : matches) { + if (match < nParticles && !strictDescendant[match]) + roots.push_back(match); + } + + return roots; + } + + // Follow the radiating-copy chain of a particle: while the current copy has + // exactly one decay vertex with exactly one same-PDG daughter, advance to it. + // Pure 1 -> 1 copy chains are already gone if collapseIntermediateGenParticles + // ran before; this handles surviving chains like Z -> Z gamma. Any ambiguity + // (several decay vertices, several same-PDG daughters) stops the walk. + uint32_t lastCopyOf(truth::Graph const& graph, uint32_t rootId) { + const int32_t pdgId = graph.particles()[rootId].pdgId; + uint32_t current = rootId; + + for (uint32_t guard = 0; guard < graph.nParticles(); ++guard) { + if (graph.particles()[current].status == 1) + break; + + const auto decayVertices = graph.decayVertices(current); + if (decayVertices.size() != 1) + break; + + uint32_t sameIdChild = 0; + uint32_t nSameId = 0; + + for (const uint32_t childId : graph.outgoingParticles(decayVertices.front())) { + if (childId < graph.nParticles() && childId != current && graph.particles()[childId].pdgId == pdgId) { + sameIdChild = childId; + ++nSameId; + } + } + + if (nSameId != 1) + break; + + current = sameIdChild; + } + + return current; + } + + // Sorted PDG ids of the effective decay products of a root: the outgoing + // particles of the decay vertices of its last radiating copy. + std::vector effectiveDecayProductPdgIds(truth::Graph const& graph, uint32_t rootId) { + const uint32_t lastCopy = lastCopyOf(graph, rootId); + + std::vector pdgIds; + + for (const uint32_t vertexId : graph.decayVertices(lastCopy)) { + if (vertexId >= graph.nVertices()) + continue; + + for (const uint32_t childId : graph.outgoingParticles(vertexId)) { + if (childId < graph.nParticles()) + pdgIds.push_back(graph.particles()[childId].pdgId); + } + } + + std::sort(pdgIds.begin(), pdgIds.end()); + + return pdgIds; + } + + // Multiset containment on sorted ranges: extras in `have` are allowed. + bool multisetContains(std::vector const& sortedHave, std::vector const& sortedNeed) { + return std::includes(sortedHave.begin(), sortedHave.end(), sortedNeed.begin(), sortedNeed.end()); + } + + // Direct decay-pattern search: a vertex whose outgoing PDG id multiset + // contains a group selects that vertex (as common production context) and the + // matched outgoing particles as roots. Matching is local to one vertex, so + // unrelated particles from different branches can never be combined. + void findDecayPatternMatches(truth::Graph const& graph, + std::vector> const& sortedGroups, + std::vector& roots, + std::vector& matchedVertices) { + std::vector outgoingPdgIds; + + for (uint32_t vertexId = 0; vertexId < graph.nVertices(); ++vertexId) { + const auto outgoing = graph.outgoingParticles(vertexId); + if (outgoing.empty()) + continue; + + outgoingPdgIds.clear(); + + for (const uint32_t childId : outgoing) { + if (childId < graph.nParticles()) + outgoingPdgIds.push_back(graph.particles()[childId].pdgId); + } + + std::sort(outgoingPdgIds.begin(), outgoingPdgIds.end()); + + bool vertexMatched = false; + + for (auto const& group : sortedGroups) { + if (!multisetContains(outgoingPdgIds, group)) + continue; + + vertexMatched = true; + + for (const uint32_t childId : outgoing) { + if (childId < graph.nParticles() && containsPdgId(group, graph.particles()[childId].pdgId)) + roots.push_back(childId); + } + } + + if (vertexMatched) + matchedVertices.push_back(vertexId); + } + + std::sort(roots.begin(), roots.end()); + roots.erase(std::unique(roots.begin(), roots.end()), roots.end()); + } + + void dropVerticesWithoutVisibleParticles(truth::Graph const& graph, + std::vector const& keepParticle, + std::vector& keepVertex) { + for (uint32_t vertexId = 0; vertexId < graph.nVertices(); ++vertexId) { + if (!keepVertex[vertexId]) + continue; + + bool hasVisibleIncoming = false; + bool hasVisibleOutgoing = false; + + for (uint32_t particleId : graph.incomingParticles(vertexId)) { + if (particleId < graph.nParticles() && keepParticle[particleId]) { + hasVisibleIncoming = true; + break; + } + } + + for (uint32_t particleId : graph.outgoingParticles(vertexId)) { + if (particleId < graph.nParticles() && keepParticle[particleId]) { + hasVisibleOutgoing = true; + break; + } + } + + if (!hasVisibleIncoming && !hasVisibleOutgoing) + keepVertex[vertexId] = 0; + } + } + + // attachRole[i] is 0 for particles that are not attached to an artificial + // source, or the uint8_t value of the Upstream/UnderlyingEvent VertexRole + // otherwise. Per interaction (keyed by genEvent) a single Interaction source + // vertex is created and fans out, through connector particles, to the Upstream + // and UnderlyingEvent sub-vertices the attached particles hang off; all three + // carry the genEvent/eventId of the activity they summarize so overlaid + // pile-up interactions stay distinguishable. + truth::Graph rebuildFilteredGraph(truth::Graph const& input, + std::vector const& keepParticle, + std::vector const& keepVertex, + std::vector const& attachRole) { + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + truth::Graph output; + + std::vector oldParticleToNew(nParticles, -1); + std::vector oldVertexToNew(nVertices, -1); + + output.particles().reserve(nParticles); + output.vertices().reserve(nVertices + 3); + + for (uint32_t oldParticle = 0; oldParticle < nParticles; ++oldParticle) { + if (!keepParticle[oldParticle]) + continue; + + oldParticleToNew[oldParticle] = static_cast(output.particles().size()); + output.particles().push_back(input.particles()[oldParticle]); + } + + for (uint32_t oldVertex = 0; oldVertex < nVertices; ++oldVertex) { + if (!keepVertex[oldVertex]) + continue; + + oldVertexToNew[oldVertex] = static_cast(output.vertices().size()); + output.vertices().push_back(input.vertices()[oldVertex]); + } + + // Artificial source structure, one per interaction (keyed by the packed + // EncodedEventId, i.e. one node per pp collision: signal is bx 0 / event 0, + // each pile-up interaction its own): + // + // (Interaction vertex, source) + // --connector particle--> (Upstream vertex) --> ISR/upstream roots + // --connector particle--> (UnderlyingEvent vertex) --> spectators + // + // so the whole interaction descends from a single Interaction vertex: the + // signal is everything reachable from the signal Interaction vertex, and each + // overlaid pile-up interaction gets its own. Keying by eventId (not genEvent) + // keeps an interaction whose GEN history splits into several components under + // one Interaction vertex. The connector particles are artificial + // (genNode = simNode = -1) and carry the interaction provenance. + struct InteractionNodes { + int32_t interactionVertex = -1; + int32_t upstreamVertex = -1; + int32_t underlyingEventVertex = -1; + }; + + std::map interactions; // key = eventId (EncodedEventId) + std::vector> extraProductionEdges; // (vertex -> particle) + std::vector> extraDecayEdges; // (particle -> vertex) + + auto makeArtificialVertex = + [&](uint8_t role, int32_t genEvent, uint64_t eventId, math::XYZTLorentzVectorD const& position) { + truth::VertexData vertex; + vertex.genNode = -1; + vertex.simNode = -1; + vertex.role = role; + vertex.genEvent = genEvent; + vertex.eventId = eventId; + vertex.position = position; + + const uint32_t id = static_cast(output.vertices().size()); + output.vertices().push_back(vertex); + return id; + }; + + // The real production vertex of an attached particle is the primary + // interaction point of its pp collision: the Upstream (ISR) roots and the + // UnderlyingEvent spectators are all produced there. That vertex was dropped + // from the output (which is why the particle needs an artificial source), but + // it still carries its 4-position in `input`, so the artificial source nodes + // inherit the correct interaction-point 4-position instead of the origin. + auto productionPosition = [&input](uint32_t oldParticle) -> math::XYZTLorentzVectorD { + const auto prodVertices = input.productionVertices(oldParticle); + if (!prodVertices.empty()) + return input.vertices()[prodVertices.front()].position; + return math::XYZTLorentzVectorD(); + }; + + for (uint32_t oldParticle = 0; oldParticle < nParticles; ++oldParticle) { + const uint8_t role = attachRole[oldParticle]; + if (role == 0) + continue; + + const int32_t newParticle = oldParticleToNew[oldParticle]; + if (newParticle < 0) + continue; + + const int32_t genEvent = input.particles()[oldParticle].genEvent; + const uint64_t eventId = input.particles()[oldParticle].eventId; + const math::XYZTLorentzVectorD interactionPoint = productionPosition(oldParticle); + + InteractionNodes& nodes = interactions[eventId]; + if (nodes.interactionVertex < 0) + nodes.interactionVertex = static_cast(makeArtificialVertex( + static_cast(truth::VertexRole::Interaction), genEvent, eventId, interactionPoint)); + + int32_t& subVertex = (role == static_cast(truth::VertexRole::UnderlyingEvent)) + ? nodes.underlyingEventVertex + : nodes.upstreamVertex; + + if (subVertex < 0) { + subVertex = static_cast(makeArtificialVertex(role, genEvent, eventId, interactionPoint)); + + // Connector particle: produced at the Interaction vertex, decays at this + // Upstream/UnderlyingEvent sub-vertex, so the sub-vertex (and everything + // below it) descends from the single Interaction vertex. + truth::ParticleData connector; + connector.genNode = -1; + connector.simNode = -1; + connector.pdgId = 0; + connector.status = 0; + connector.genEvent = genEvent; + connector.eventId = eventId; + + const uint32_t connectorId = static_cast(output.particles().size()); + output.particles().push_back(connector); + + extraProductionEdges.emplace_back(static_cast(nodes.interactionVertex), connectorId); + extraDecayEdges.emplace_back(connectorId, static_cast(subVertex)); + } + + extraProductionEdges.emplace_back(static_cast(subVertex), static_cast(newParticle)); + } + + rebuildAdjacency(input, oldParticleToNew, oldVertexToNew, extraProductionEdges, extraDecayEdges, output); + + return output; + } + + truth::Graph filterGraphBySelection(truth::Graph const& input, + truth::LogicalGraphPostProcessingConfig const& config) { + if (input.empty()) + return input; + + // Skip empty groups: they would match every vertex. + std::vector> sortedGroups; + sortedGroups.reserve(config.decayPdgIdGroups.size()); + + for (auto const& group : config.decayPdgIdGroups) { + if (!group.empty()) { + sortedGroups.push_back(group); + std::sort(sortedGroups.back().begin(), sortedGroups.back().end()); + } + } + + const bool haveSeeds = !config.seedPdgIds.empty() || !config.seedHadronFlavors.empty(); + const bool haveGroups = !sortedGroups.empty(); + + if (!haveSeeds && !haveGroups) + return input; + + // Debug escape hatch: no real particle has PDG id 0, so seedPdgIds = {0} + // explicitly requests the full, unfiltered graph. + if (containsPdgId(config.seedPdgIds, 0)) + return input; + + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + std::vector roots; + std::vector patternVertices; + + if (haveSeeds) { + std::vector matches; + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (matchesSeed(input, particleId, config)) + matches.push_back(particleId); + } + + if (!matches.empty()) { + roots = mostUpstreamOf(input, matches); + + if (haveGroups) { + // Keep only seed roots whose effective decay matches a group, e.g. + // Z -> mu+ mu- but not Z -> e+ e-. + std::erase_if(roots, [&](uint32_t root) { + const auto products = effectiveDecayProductPdgIds(input, root); + return std::none_of(sortedGroups.begin(), sortedGroups.end(), [&](auto const& group) { + return multisetContains(products, group); + }); + }); + } + } else if (haveGroups) { + // The generator did not write the requested resonance explicitly: + // fall back to the direct decay-pattern search. + findDecayPatternMatches(input, sortedGroups, roots, patternVertices); + } + } else { + findDecayPatternMatches(input, sortedGroups, roots, patternVertices); + } + + if (roots.empty()) { + edm::LogWarning("TruthLogicalGraphPostProcessor") + << "Configured truth graph selection (seedPdgIds and/or decayPdgIdGroups) matched nothing in this event; " + << (config.keepStableSpectators ? "keeping only stable GEN particles as underlying-event spectators." + : "the selected graph is empty."); + } + + std::vector keepParticle(nParticles, 0); + std::vector keepVertex(nVertices, 0); + + for (const uint32_t root : roots) { + markDownstreamFromParticle(input, root, keepParticle, keepVertex); + } + + // Pattern-matched vertices are kept as the common production context of + // their matched outgoing particles. + for (const uint32_t vertexId : patternVertices) { + keepVertex[vertexId] = 1; + } + + markAncestorContext(input, roots, config.seedParentDepth, keepParticle, keepVertex); + + if (config.keepProductionSiblings) + markProductionSiblings(input, roots, keepParticle, keepVertex); + + // Optionally keep every stable final-state GEN particle outside the + // selection; these become the underlying-event spectators. Disabled by + // keepStableSpectators=false for a focused subgraph. + std::vector stableSpectator(nParticles, 0); + + if (config.keepStableSpectators) { + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (!isStableGenParticle(input, particleId)) + continue; + + if (isIgnoredParticle(input, particleId, config.ignoredPdgIds, config.ignoredParticleIds)) + continue; + + if (keepParticle[particleId]) + continue; + + keepParticle[particleId] = 1; + stableSpectator[particleId] = 1; + } + } + + dropVerticesWithoutVisibleParticles(input, keepParticle, keepVertex); + + // Assign an artificial-source role to every kept particle whose real + // production vertices were all dropped: stable spectators -> UnderlyingEvent, + // selected roots / truncated ancestors at the upstream boundary -> Upstream + // (ISR). True sources of the input graph stay sources. When + // attachSelectionSources is false these particles instead become true graph + // roots (no production vertex), so each selected seed yields a self-contained + // subgraph starting directly at the seed (e.g. ten taus -> ten components). + std::vector attachRole(nParticles, 0); + + if (config.attachSelectionSources) { + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (!keepParticle[particleId]) + continue; + + const auto productionVertices = input.productionVertices(particleId); + + if (productionVertices.empty() && !stableSpectator[particleId]) + continue; + + const bool hasKeptProduction = + std::any_of(productionVertices.begin(), productionVertices.end(), [&](uint32_t vertexId) { + return vertexId < nVertices && keepVertex[vertexId]; + }); + + if (!hasKeptProduction) { + attachRole[particleId] = static_cast(stableSpectator[particleId] ? truth::VertexRole::UnderlyingEvent + : truth::VertexRole::Upstream); + } + } + } + + return rebuildFilteredGraph(input, keepParticle, keepVertex, attachRole); + } + + // Stand-alone pile-up filter. The bunch-crossing pass must run independently of + // the seed/decay selection (which short-circuits when no seeds are configured), + // so a "keep the full graph but drop pile-up" configuration (signalOnly and/or + // keepBunchCrossings, no seeds) actually removes the out-of-time / pile-up + // particles. Keeps every in-time particle and the topology connecting them; + // no-op unless a pile-up filter is configured. + truth::Graph filterGraphByBunchCrossing(truth::Graph const& input, + truth::LogicalGraphPostProcessingConfig const& config) { + if (input.empty() || (!config.signalOnly && config.keepBunchCrossings.empty())) + return input; + + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + std::vector keepParticle(nParticles, 1); + std::vector stableSpectator(nParticles, 0); // unused outside applyBunchCrossingFilter + applyBunchCrossingFilter(input, config.signalOnly, config.keepBunchCrossings, keepParticle, stableSpectator); + + std::vector keepVertex(nVertices, 1); + dropVerticesWithoutVisibleParticles(input, keepParticle, keepVertex); + + // No artificial sources: this is pile-up removal, not a focused selection. + std::vector attachRole(nParticles, 0); + return rebuildFilteredGraph(input, keepParticle, keepVertex, attachRole); + } + + truth::Graph collapseIgnoredParticles(truth::Graph const& input, + std::vector const& ignoredPdgIds, + std::vector const& ignoredParticleIds) { + if (input.empty()) + return input; + + if (ignoredPdgIds.empty() && ignoredParticleIds.empty()) + return input; + + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + std::vector removeParticle(nParticles, 0); + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (isIgnoredParticle(input, particleId, ignoredPdgIds, ignoredParticleIds)) + removeParticle[particleId] = 1; + } + + if (std::none_of(removeParticle.begin(), removeParticle.end(), [](uint8_t value) { return value != 0; })) + return input; + + DSU vertexDSU(static_cast(nVertices)); + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (!removeParticle[particleId]) + continue; + + std::vector connectedVertices; + + for (uint32_t vertexId : input.productionVertices(particleId)) { + if (vertexId < nVertices) + connectedVertices.push_back(vertexId); + } + + for (uint32_t vertexId : input.decayVertices(particleId)) { + if (vertexId < nVertices) + connectedVertices.push_back(vertexId); + } + + if (connectedVertices.size() < 2) + continue; + + const uint32_t first = connectedVertices.front(); + + for (std::size_t i = 1; i < connectedVertices.size(); ++i) { + vertexDSU.unite(static_cast(first), static_cast(connectedVertices[i])); + } + } + + truth::Graph output; + + std::vector oldParticleToNew(nParticles, -1); + + for (uint32_t oldParticle = 0; oldParticle < nParticles; ++oldParticle) { + if (removeParticle[oldParticle]) + continue; + + oldParticleToNew[oldParticle] = static_cast(output.particles().size()); + output.particles().push_back(input.particles()[oldParticle]); + } + + std::unordered_map vertexRepToNew; + vertexRepToNew.reserve(nVertices); + + std::vector oldVertexToNew(nVertices, -1); + + // Vertices in one DSU group share the id of the first visible member; + // vertices with no visible particle at all stay unmapped and disappear. + for (uint32_t oldVertex = 0; oldVertex < nVertices; ++oldVertex) { + const auto hasVisible = [&](auto const& particles) { + return std::any_of(particles.begin(), particles.end(), [&](uint32_t oldParticle) { + return oldParticle < nParticles && oldParticleToNew[oldParticle] >= 0; + }); + }; + + if (!hasVisible(input.incomingParticles(oldVertex)) && !hasVisible(input.outgoingParticles(oldVertex))) + continue; + + const int rep = vertexDSU.find(static_cast(oldVertex)); + auto inserted = vertexRepToNew.emplace(rep, static_cast(output.vertices().size())); + + if (inserted.second) { + output.vertices().push_back(input.vertices()[oldVertex]); + } + + oldVertexToNew[oldVertex] = static_cast(inserted.first->second); + } + + rebuildAdjacency(input, oldParticleToNew, oldVertexToNew, {}, {}, output); + + return output; + } + + // Remove every SIM particle whose calo+tracker sim-hit subgraph is empty, + // together with its whole downstream subtree. particleDirectHit[i] flags the + // particles that carry a sim-hit on their own SimTrack (supplied by the + // producer, aligned to input ids). subgraphHasHit[p] = "p or some logical + // descendant of p carries a hit"; it is computed by propagating the direct-hit + // flag UP the production edges (an ancestor inherits a hit from any descendant). + truth::Graph dropHitlessSimSubgraphs(truth::Graph const& input, std::vector const& particleDirectHit) { + const uint32_t nParticles = input.nParticles(); + const uint32_t nVertices = input.nVertices(); + + if (nParticles == 0 || particleDirectHit.size() != nParticles) + return input; + + // Upward closure of the direct-hit set: a particle has a non-empty subgraph + // iff it is an ancestor-or-self of some particle that carries a hit. + std::vector subgraphHasHit(nParticles, 0); + std::vector worklist; + worklist.reserve(nParticles); + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (particleDirectHit[particleId]) { + subgraphHasHit[particleId] = 1; + worklist.push_back(particleId); + } + } + + for (std::size_t head = 0; head < worklist.size(); ++head) { + const uint32_t particleId = worklist[head]; + for (const uint32_t vertexId : input.productionVertices(particleId)) { + if (vertexId >= nVertices) + continue; + for (const uint32_t parentId : input.incomingParticles(vertexId)) { + if (parentId < nParticles && !subgraphHasHit[parentId]) { + subgraphHasHit[parentId] = 1; + worklist.push_back(parentId); + } + } + } + } + + // Removal seeds = SIM particles with an empty subgraph. Sweep their whole + // downstream closure so GEN-only descendants (e.g. neutrinos) go with them + // and no kept particle is left orphaned. + std::vector removeParticle(nParticles, 0); + std::vector removalQueue; + + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) { + if (input.particles()[particleId].hasSim() && !subgraphHasHit[particleId]) { + removeParticle[particleId] = 1; + removalQueue.push_back(particleId); + } + } + + for (std::size_t head = 0; head < removalQueue.size(); ++head) { + const uint32_t particleId = removalQueue[head]; + for (const uint32_t vertexId : input.decayVertices(particleId)) { + if (vertexId >= nVertices) + continue; + for (const uint32_t childId : input.outgoingParticles(vertexId)) { + if (childId < nParticles && !removeParticle[childId]) { + removeParticle[childId] = 1; + removalQueue.push_back(childId); + } + } + } + } + + if (std::none_of(removeParticle.begin(), removeParticle.end(), [](uint8_t value) { return value != 0; })) + return input; + + std::vector keepParticle(nParticles, 0); + for (uint32_t particleId = 0; particleId < nParticles; ++particleId) + keepParticle[particleId] = removeParticle[particleId] ? 0 : 1; + + // Keep a vertex iff it still has at least one kept outgoing particle: this + // preserves the production vertex of every kept particle (and, since a kept + // particle never has a removed parent, its incoming side stays valid) while + // dropping decay vertices whose products were all pruned, so the parent + // simply becomes a leaf. + std::vector keepVertex(nVertices, 0); + for (uint32_t vertexId = 0; vertexId < nVertices; ++vertexId) { + for (const uint32_t particleId : input.outgoingParticles(vertexId)) { + if (particleId < nParticles && keepParticle[particleId]) { + keepVertex[vertexId] = 1; + break; + } + } + } + + const std::vector attachRole(nParticles, 0); + return rebuildFilteredGraph(input, keepParticle, keepVertex, attachRole); + } + +} // namespace + +namespace truth { + + TruthLogicalGraphPostProcessor::TruthLogicalGraphPostProcessor(LogicalGraphPostProcessingConfig config) + : config_(std::move(config)) {} + + edm::ParameterSetDescription TruthLogicalGraphPostProcessor::psetDescription() { + edm::ParameterSetDescription desc; + + desc.add("collapseIntermediateGenParticles", true) + ->setComment( + "If true, collapse GEN chains P -> V -> C where P has status != 1, C is the only daughter, " + "and P and C have the same PDG id. Status-1 GEN particles are never collapsed by this rule."); + + desc.add("dropHitlessSimSubgraphs", true) + ->setComment( + "If true, remove every SIM logical particle whose calorimeter + tracker sim-hit subgraph is empty, " + "together with its whole downstream subtree (GEN-only descendants such as neutrinos go with it; the " + "GEN skeleton outside removed SIM subtrees is preserved). Requires the producer to supply the " + "per-particle sim-hit presence (it consumes the calo/tracker sim-hit collections); a no-op otherwise."); + + desc.add>("seedPdgIds", {}) + ->setComment( + "If non-empty, particles with these exact PDG ids seed the selection: the most upstream particle of " + "each matching chain becomes a root and its full downstream subgraph is kept. The special value 0 " + "disables the selection and keeps the full graph (debugging). Stable GEN particles outside the " + "selection are kept and attached to one artificial source vertex."); + + desc.add("seedParentDepth", 0) + ->setComment( + "Number of ancestor generations kept above each selected root as context only: the ancestors and " + "connecting vertices are kept, but not their other descendants. Kept particles whose production " + "vertices all fall outside the selection are attached to an artificial Upstream (ISR) source vertex."); + + desc.add>("seedHadronFlavors", {}) + ->setComment( + "Seed on hadrons by heavy-flavor content (5 = b, 4 = c): a hadron whose quark content includes any of " + "these flavors becomes a seed, e.g. {5} selects all B-hadron decay subgraphs. OR-ed with seedPdgIds."); + + desc.add("keepStableSpectators", true) + ->setComment( + "If true, stable final-state GEN particles outside the selected subgraph are kept and attached to an " + "artificial UnderlyingEvent source vertex (tagged with their genEvent/eventId for pile-up provenance). " + "If false, they are dropped, giving a focused subgraph with only the selection and its Upstream (ISR) " + "context. Only meaningful when a selection (seedPdgIds/decayPdgIdGroups) is active."); + + desc.add("attachSelectionSources", true) + ->setComment( + "If true, kept particles whose production vertices all fall outside the selection are attached to an " + "artificial Upstream/UnderlyingEvent source vertex. If false, they become true graph roots, so each " + "selected seed yields a self-contained subgraph starting directly at the seed (e.g. ten taus -> ten " + "disjoint components). Only meaningful when a selection is active."); + + desc.add("keepProductionSiblings", false) + ->setComment( + "If true, also keep each selected root's hard-scatter co-products: its production vertex and the other " + "outgoing particles of that vertex (with their decay subtrees). Exposes the recoiling partons that share " + "the seed's production vertex - e.g. the VBF tagging quarks that become forward jets and produce the " + "Higgs - which are siblings of the seed, not ancestors, so seedParentDepth never reaches them. Only " + "meaningful when a selection is active."); + + desc.add("signalOnly", false) + ->setComment( + "Pile-up filter: if true, keep only the signal interaction (EncodedEventId bunchCrossing 0 and event 0), " + "dropping all pile-up. Orthogonal to the seed selection (composes with any preset)."); + + desc.add>("keepBunchCrossings", {}) + ->setComment( + "Pile-up filter: if non-empty, keep only particles whose EncodedEventId bunchCrossing is in this list " + "(e.g. {0} = in-time only). Empty keeps all bunch crossings; AND-ed with signalOnly."); + + { + edm::ParameterSetDescription groupDesc; + groupDesc.add>("pdgIds", {}) + ->setComment("Unordered, charge-sensitive multiset of required PDG ids, e.g. (13, -13)."); + + desc.addVPSet("decayPdgIdGroups", groupDesc, {}) + ->setComment( + "Decay patterns of interest; groups are OR-ed. Without seedPdgIds: a vertex whose outgoing PDG ids " + "contain a group as a sub-multiset is selected and the matched particles plus their downstream " + "subgraphs are kept. With seedPdgIds: only seed roots whose effective decay products (after following " + "same-PDG radiating copy chains) contain a group are kept; if the event has no particle with a seed " + "PDG id at all, the direct vertex search is used as a fallback."); + } + + desc.add>("ignoredPdgIds", {}) + ->setComment( + "Particles with these exact PDG ids are always removed from the final logical graph. If internal, " + "their production and decay vertices are merged so the graph remains connected."); + + desc.add>("ignoredParticleIds", {}) + ->setComment( + "Logical particle ids to remove from the final logical graph. These ids refer to the graph state at " + "the moment the ignored-particle collapsing step is applied."); + + return desc; + } + + LogicalGraphPostProcessingConfig TruthLogicalGraphPostProcessor::configFromPSet(edm::ParameterSet const& pset) { + LogicalGraphPostProcessingConfig config; + + config.collapseIntermediateGenParticles = pset.getParameter("collapseIntermediateGenParticles"); + config.dropHitlessSimSubgraphs = pset.getParameter("dropHitlessSimSubgraphs"); + config.seedPdgIds = pset.getParameter>("seedPdgIds"); + config.seedHadronFlavors = pset.getParameter>("seedHadronFlavors"); + config.seedParentDepth = pset.getParameter("seedParentDepth"); + config.keepStableSpectators = pset.getParameter("keepStableSpectators"); + config.attachSelectionSources = pset.getParameter("attachSelectionSources"); + config.keepProductionSiblings = pset.getParameter("keepProductionSiblings"); + config.signalOnly = pset.getParameter("signalOnly"); + config.keepBunchCrossings = pset.getParameter>("keepBunchCrossings"); + + for (auto const& groupPSet : pset.getParameter>("decayPdgIdGroups")) { + config.decayPdgIdGroups.push_back(groupPSet.getParameter>("pdgIds")); + } + config.ignoredPdgIds = pset.getParameter>("ignoredPdgIds"); + config.ignoredParticleIds = pset.getParameter>("ignoredParticleIds"); + + return config; + } + + Graph TruthLogicalGraphPostProcessor::process(Graph input, std::vector const& particleDirectHit) const { + // Run before any collapsing/selection so particleDirectHit stays aligned to + // the input particle ids the producer computed it for. + if (config_.dropHitlessSimSubgraphs && !particleDirectHit.empty()) { + input = dropHitlessSimSubgraphs(input, particleDirectHit); + } + + if (config_.collapseIntermediateGenParticles) { + input = collapseIntermediateGenParticleChains(input); + } + + // Pile-up removal runs before the seed selection so it composes with any + // preset (including the full-graph / no-seed case) and operates on the + // post-collapse indexing. + input = filterGraphByBunchCrossing(input, config_); + + input = filterGraphBySelection(input, config_); + + if (!config_.ignoredPdgIds.empty() || !config_.ignoredParticleIds.empty()) { + input = collapseIgnoredParticles(input, config_.ignoredPdgIds, config_.ignoredParticleIds); + } + + return input; + } + +} // namespace truth diff --git a/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp b/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp new file mode 100644 index 0000000000000..7986473d453d5 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/BranchHitAssociator_t.cpp @@ -0,0 +1,197 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include +#include + +#include "PhysicsTools/TruthInfo/interface/BranchHitAssociator.h" +#include "PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h" + +namespace { + + // A minimal user reco object: it only has to expose truthHits(). + struct MyRecoObject { + std::vector hits; + [[nodiscard]] const std::vector& truthHits() const { return hits; } + }; + + // particle 0 (track 100) -> child particle 1 (track 101) + // p0 direct: cell10 (e1), cell11 (e1) + // p1 direct: cell11 (e1), cell12 (e2) + // => subgraph(0) = {10:1, 11:2, 12:2}; subgraph(1) = {11:1, 12:2} + // cellTotal = {10:1, 11:2, 12:2} + truth::LogicalGraphHitIndex buildIndex() { + truth::LogicalGraphHitIndexBuilder b(2); + b.setSimTrackForParticle(0, 100); + b.setSimTrackForParticle(1, 101); + b.addParticleChild(0, 1); + b.addHit(truth::HitChannel::HGCalCalo, 100, 10, 1.0f, 0); + b.addHit(truth::HitChannel::HGCalCalo, 100, 11, 1.0f, 0); + b.addHit(truth::HitChannel::HGCalCalo, 101, 11, 1.0f, 0); + b.addHit(truth::HitChannel::HGCalCalo, 101, 12, 2.0f, 0); + return b.finish(); + } + + // Same topology populated on the *tracker* channel (cells 20,21,22), plus one + // calo cell (10) that the tracker associator must ignore. + truth::LogicalGraphHitIndex buildTrackerIndex() { + truth::LogicalGraphHitIndexBuilder b(2); + b.setSimTrackForParticle(0, 100); + b.setSimTrackForParticle(1, 101); + b.addParticleChild(0, 1); + b.addHit(truth::HitChannel::HGCalCalo, 100, 10, 1.0f, 0); // calo channel + b.addHit(truth::HitChannel::Tracker, 100, 20, 1.0f); + b.addHit(truth::HitChannel::Tracker, 100, 21, 1.0f); + b.addHit(truth::HitChannel::Tracker, 101, 21, 1.0f); + b.addHit(truth::HitChannel::Tracker, 101, 22, 2.0f); + return b.finish(); + } + +} // namespace + +class TestBranchHitAssociator : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TestBranchHitAssociator); + CPPUNIT_TEST(testSharedEnergyBestBranch); + CPPUNIT_TEST(testSharedHitsMetric); + CPPUNIT_TEST(testGenericRecoObjectInterface); + CPPUNIT_TEST(testTrackerChannel); + CPPUNIT_TEST(testEmptyRootsMatchNothingWhenRestricted); + CPPUNIT_TEST(testReverseScoreIsBranchNormalized); + CPPUNIT_TEST_SUITE_END(); + +public: + void testSharedEnergyBestBranch(); + void testSharedHitsMetric(); + void testGenericRecoObjectInterface(); + void testTrackerChannel(); + void testEmptyRootsMatchNothingWhenRestricted(); + void testReverseScoreIsBranchNormalized(); +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestBranchHitAssociator); + +void TestBranchHitAssociator::testSharedEnergyBestBranch() { + auto index = buildIndex(); + truth::BranchHitAssociator assoc(index); // SharedEnergy, all roots + + // A reco object that perfectly covers cells 10,11,12 (fraction 1). + std::vector reco{{10, 1.0f, 1.0f}, {11, 2.0f, 1.0f}, {12, 2.0f, 1.0f}}; + auto matches = assoc.bestBranches(reco); + + CPPUNIT_ASSERT(!matches.empty()); + // Root 0's subtree covers every cell with fraction 1 -> perfect match (score 0). + CPPUNIT_ASSERT_EQUAL(uint32_t(0), matches.front().rootParticleId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, matches.front().score, 1e-6); + + // Root 1 covers cell 11 (frac 0.5) and 12 (frac 1) but not 10 -> worse score. + bool foundRoot1 = false; + for (auto const& m : matches) + if (m.rootParticleId == 1) { + foundRoot1 = true; + CPPUNIT_ASSERT(m.score > matches.front().score); + } + CPPUNIT_ASSERT(foundRoot1); +} + +void TestBranchHitAssociator::testSharedHitsMetric() { + auto index = buildIndex(); + truth::BranchHitAssociator assoc(index, {}, truth::BranchHitAssociator::Metric::SharedHits); + + std::vector reco{{10, 1.0f, 1.0f}, {11, 1.0f, 1.0f}, {12, 1.0f, 1.0f}}; + auto matches = assoc.bestBranches(reco, /*maxResults=*/1); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), matches.size()); + // Root 0 shares all 3 cells -> best (score 0); sharedEnergy field carries the count. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), matches.front().rootParticleId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(3.0, matches.front().sharedEnergy, 1e-6); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, matches.front().score, 1e-6); +} + +void TestBranchHitAssociator::testGenericRecoObjectInterface() { + auto index = buildIndex(); + truth::BranchHitAssociator assoc(index); + + // The generic interface: any object with truthHits() works. + MyRecoObject obj; + obj.hits = {{11, 2.0f, 1.0f}, {12, 2.0f, 1.0f}}; + auto matches = assoc.bestBranches(obj); + + CPPUNIT_ASSERT(!matches.empty()); + // Root 0's subtree covers cells 11,12 with fraction 1 (reco->branch perfect), + // so it is the best match; root 1 only partially covers cell 11. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), matches.front().rootParticleId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, matches.front().score, 1e-6); + CPPUNIT_ASSERT(matches.size() >= 2); // both root 0 and root 1 are candidates +} + +void TestBranchHitAssociator::testTrackerChannel() { + auto index = buildTrackerIndex(); + truth::BranchHitAssociator assoc( + index, {}, truth::BranchHitAssociator::Metric::SharedHits, truth::HitChannel::Tracker); + + // Tracker cells 20,21,22 are fully covered by root 0's tracker subgraph. + std::vector reco{{20, 1.0f, 1.0f}, {21, 1.0f, 1.0f}, {22, 1.0f, 1.0f}}; + auto matches = assoc.bestBranches(reco, /*maxResults=*/1); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), matches.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), matches.front().rootParticleId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(3.0, matches.front().sharedEnergy, 1e-6); + + // Channel separation: the tracker associator ignores the calo-only cell (10)... + std::vector caloReco{{10, 1.0f, 1.0f}}; + CPPUNIT_ASSERT(assoc.bestBranches(caloReco).empty()); + // ...and a calo associator ignores the tracker cells. + truth::BranchHitAssociator caloAssoc( + index, {}, truth::BranchHitAssociator::Metric::SharedHits, truth::HitChannel::HGCalCalo); + CPPUNIT_ASSERT(caloAssoc.bestBranches(reco).empty()); +} + +void TestBranchHitAssociator::testEmptyRootsMatchNothingWhenRestricted() { + auto index = buildIndex(); + + // Empty roots with emptyRootsMeansAll=false => no candidate branches, so even a + // perfectly-covering reco object matches nothing. (Regression: a configured + // pdg-id restriction that selects no particle in an event must not silently fall + // back to matching every branch.) + truth::BranchHitAssociator restricted(index, + {}, + truth::BranchHitAssociator::Metric::SharedEnergy, + truth::HitChannel::HGCalCalo, + /*emptyRootsMeansAll=*/false); + std::vector reco{{10, 1.0f, 1.0f}, {11, 2.0f, 1.0f}, {12, 2.0f, 1.0f}}; + CPPUNIT_ASSERT(restricted.bestBranches(reco).empty()); + + // Sanity: the default (empty roots => all) still matches the same object. + truth::BranchHitAssociator all(index); + CPPUNIT_ASSERT(!all.bestBranches(reco).empty()); +} + +void TestBranchHitAssociator::testReverseScoreIsBranchNormalized() { + auto index = buildIndex(); + truth::BranchHitAssociator assoc(index); + + // Reco object fully covers cells 10,11,12. Root 1's branch is only {11,12}, + // which the reco object fully contains. + std::vector reco{{10, 1.0f, 1.0f}, {11, 2.0f, 1.0f}, {12, 2.0f, 1.0f}}; + auto matches = assoc.bestBranches(reco); + + bool sawRoot1 = false; + for (auto const& m : matches) { + if (m.rootParticleId == 0) { + // Root 0's subtree == the reco object: perfect both ways. + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, m.score, 1e-6); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, m.reverseScore, 1e-6); + } else if (m.rootParticleId == 1) { + sawRoot1 = true; + // Reco-centric: the reco object also hits cell 10, which root 1 does not + // explain -> score > 0. Branch-centric: the reco object covers all of root + // 1's branch -> reverseScore == 0. This asymmetry is the point of the fix. + CPPUNIT_ASSERT(m.score > 0.f); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, m.reverseScore, 1e-6); + } + } + CPPUNIT_ASSERT(sawRoot1); +} diff --git a/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp b/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp new file mode 100644 index 0000000000000..32a4c4d833879 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/BranchSelector_t.cpp @@ -0,0 +1,123 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include +#include + +#include "PhysicsTools/TruthInfo/interface/Branch.h" +#include "PhysicsTools/TruthInfo/interface/BranchSelector.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" + +namespace { + + uint64_t packEventId(int bx, int ev) { + EncodedEventId id(bx, ev); + uint64_t out = 0; + std::memcpy(&out, &id, sizeof(EncodedEventId)); + return out; + } + + // Three standalone root particles (no edges): each is its own Branch root. + // p0: mu- pt=50, eta=0, signal + // p1: nu pt=5, forward, signal + // p2: e- pt=30, eta~1.7, pile-up (bunchCrossing 1) + truth::Graph buildParticles() { + truth::Graph g; + g.particles().resize(3); + auto set = [&](uint32_t i, int32_t pdg, double px, double py, double pz, double e, uint64_t eid) { + auto& p = g.particles()[i]; + p.genNode = 100 + i; + p.pdgId = pdg; + p.status = 1; + p.genEvent = 0; + p.eventId = eid; + p.momentum = math::XYZTLorentzVectorD(px, py, pz, e); + }; + set(0, 13, 50., 0., 0., 50., packEventId(0, 0)); + set(1, 14, 0., 5., 100., 100., packEventId(0, 0)); + set(2, 11, 30., 0., 80., 85.44, packEventId(1, 0)); + + // empty CSR (no edges) consistent with 3 particles, 0 vertices. + g.particleToDecayVertexOffsets().assign(4, 0); + g.particleToProductionVertexOffsets().assign(4, 0); + g.vertexToOutgoingParticleOffsets().assign(1, 0); + g.vertexToIncomingParticleOffsets().assign(1, 0); + CPPUNIT_ASSERT(g.isConsistent()); + return g; + } + +} // namespace + +class TestBranchSelector : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TestBranchSelector); + CPPUNIT_TEST(testPtCut); + CPPUNIT_TEST(testEtaCut); + CPPUNIT_TEST(testPdgIdAndCharge); + CPPUNIT_TEST(testSignalAndInTime); + CPPUNIT_TEST_SUITE_END(); + +public: + void testPtCut(); + void testEtaCut(); + void testPdgIdAndCharge(); + void testSignalAndInTime(); +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestBranchSelector); + +void TestBranchSelector::testPtCut() { + auto g = buildParticles(); + truth::BranchSelector::Config cfg; + cfg.ptMin = 10.; + truth::BranchSelector sel(cfg); + CPPUNIT_ASSERT(sel(truth::Branch(&g, 0))); // pt 50 + CPPUNIT_ASSERT(!sel(truth::Branch(&g, 1))); // pt 5 + CPPUNIT_ASSERT(sel(truth::Branch(&g, 2))); // pt 30 +} + +void TestBranchSelector::testEtaCut() { + auto g = buildParticles(); + truth::BranchSelector::Config cfg; + cfg.etaMin = -1.0; + cfg.etaMax = 1.0; + truth::BranchSelector sel(cfg); + CPPUNIT_ASSERT(sel(truth::Branch(&g, 0))); // eta 0 + CPPUNIT_ASSERT(!sel(truth::Branch(&g, 1))); // forward + CPPUNIT_ASSERT(!sel(truth::Branch(&g, 2))); // eta ~1.7 +} + +void TestBranchSelector::testPdgIdAndCharge() { + auto g = buildParticles(); + truth::BranchSelector::Config muOnly; + muOnly.pdgIds = {13}; + CPPUNIT_ASSERT(truth::BranchSelector(muOnly)(truth::Branch(&g, 0))); + CPPUNIT_ASSERT(!truth::BranchSelector(muOnly)(truth::Branch(&g, 2))); // e- not in list + + truth::BranchSelector::Config chargedCfg; + chargedCfg.chargedOnly = true; + truth::BranchSelector charged(chargedCfg); + CPPUNIT_ASSERT(charged(truth::Branch(&g, 0))); // mu- charged + CPPUNIT_ASSERT(!charged(truth::Branch(&g, 1))); // nu neutral + CPPUNIT_ASSERT(charged(truth::Branch(&g, 2))); // e- charged +} + +void TestBranchSelector::testSignalAndInTime() { + auto g = buildParticles(); + truth::BranchSelector::Config signalCfg; + signalCfg.signalOnly = true; + truth::BranchSelector signal(signalCfg); + CPPUNIT_ASSERT(signal(truth::Branch(&g, 0))); // bx 0, event 0 + CPPUNIT_ASSERT(signal(truth::Branch(&g, 1))); // bx 0, event 0 + CPPUNIT_ASSERT(!signal(truth::Branch(&g, 2))); // pile-up (bx 1) + + truth::BranchSelector::Config intimeCfg; + intimeCfg.intimeOnly = true; + truth::BranchSelector intime(intimeCfg); + CPPUNIT_ASSERT(!intime(truth::Branch(&g, 2))); // bx 1 + CPPUNIT_ASSERT(truth::Branch(&g, 2).isFromPileup()); +} diff --git a/PhysicsTools/TruthInfo/test/Branch_t.cpp b/PhysicsTools/TruthInfo/test/Branch_t.cpp new file mode 100644 index 0000000000000..899c556dac11c --- /dev/null +++ b/PhysicsTools/TruthInfo/test/Branch_t.cpp @@ -0,0 +1,226 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include +#include +#include +#include + +#include "PhysicsTools/TruthInfo/interface/Branch.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" + +namespace { + + // Minimal graph builder mirroring the one in the postprocessor test. + struct GraphBuilder { + explicit GraphBuilder(uint32_t nParticles, uint32_t nVertices) { + graph.particles().resize(nParticles); + graph.vertices().resize(nVertices); + } + void setParticle(uint32_t id, int32_t pdgId, int16_t status, double e = 1.0) { + auto& p = graph.particles()[id]; + p.genNode = 100 + id; + p.simNode = -1; + p.pdgId = pdgId; + p.status = status; + p.genEvent = 0; + p.eventId = 0; + p.momentum = math::XYZTLorentzVectorD(0., 0., e, e); + } + void addDecay(uint32_t particleId, uint32_t vertexId) { + d2v.emplace_back(particleId, vertexId); + v2i.emplace_back(vertexId, particleId); + } + void addProduction(uint32_t vertexId, uint32_t particleId) { + v2o.emplace_back(vertexId, particleId); + p2v.emplace_back(particleId, vertexId); + } + static void csr(uint32_t n, + std::vector>& pairs, + std::vector& off, + std::vector& flat) { + std::sort(pairs.begin(), pairs.end()); + pairs.erase(std::unique(pairs.begin(), pairs.end()), pairs.end()); + off.assign(n + 1, 0); + for (auto const& pr : pairs) + ++off[pr.first + 1]; + for (uint32_t i = 1; i <= n; ++i) + off[i] += off[i - 1]; + flat.assign(pairs.size(), 0); + auto cur = off; + for (auto const& pr : pairs) + flat[cur[pr.first]++] = pr.second; + } + truth::Graph finish() { + csr(graph.nParticles(), d2v, graph.particleToDecayVertexOffsets(), graph.particleToDecayVertices()); + csr(graph.nParticles(), p2v, graph.particleToProductionVertexOffsets(), graph.particleToProductionVertices()); + csr(graph.nVertices(), v2o, graph.vertexToOutgoingParticleOffsets(), graph.vertexToOutgoingParticles()); + csr(graph.nVertices(), v2i, graph.vertexToIncomingParticleOffsets(), graph.vertexToIncomingParticles()); + CPPUNIT_ASSERT(graph.isConsistent()); + return graph; + } + truth::Graph graph; + std::vector> d2v, p2v, v2o, v2i; + }; + + // top -> {W+, b}; W+ -> {mu+, nu_mu}; b -> B0; B0 -> {D-, pi+} + truth::Graph buildTtbarLike() { + GraphBuilder b(8, 4); + b.setParticle(0, 6, 2, 100.); // top + b.setParticle(1, 24, 2, 80.); // W+ + b.setParticle(2, 5, 2, 20.); // b + b.setParticle(3, 511, 2, 18.); // B0 + b.setParticle(4, -13, 1, 40.); // mu+ (leaf) + b.setParticle(5, 14, 1, 30.); // nu_mu (leaf, invisible) + b.setParticle(6, -411, 1, 10.); // D- (leaf) + b.setParticle(7, 211, 1, 5.); // pi+ (leaf) + b.addDecay(0, 0); + b.addProduction(0, 1); + b.addProduction(0, 2); + b.addDecay(1, 1); + b.addProduction(1, 4); + b.addProduction(1, 5); + b.addDecay(2, 2); + b.addProduction(2, 3); + b.addDecay(3, 3); + b.addProduction(3, 6); + b.addProduction(3, 7); + return b.finish(); + } + + std::size_t countPdg(std::vector const& ps, int32_t pdg) { + return std::count_if(ps.begin(), ps.end(), [pdg](auto const& p) { return p.pdgId() == pdg; }); + } + +} // namespace + +class TestBranch : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TestBranch); + CPPUNIT_TEST(testClosures); + CPPUNIT_TEST(testKinematics); + CPPUNIT_TEST(testTaggingAndProvenance); + CPPUNIT_TEST(testRelations); + CPPUNIT_TEST(testInvalidViews); + CPPUNIT_TEST_SUITE_END(); + +public: + void testClosures(); + void testKinematics(); + void testTaggingAndProvenance(); + void testRelations(); + void testInvalidViews(); +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestBranch); + +void TestBranch::testClosures() { + auto g = buildTtbarLike(); + + // Subtree from the top: all 8 particles. + CPPUNIT_ASSERT_EQUAL(std::size_t(8), truth::Branch(&g, 0).members().size()); + + // StableLeaves: top (root) + 4 leaves. + auto leaves = truth::Branch(&g, 0, truth::ClosureSpec::stableLeaves()).members(); + CPPUNIT_ASSERT_EQUAL(std::size_t(5), leaves.size()); + + // DepthN(1): top + W + b. + CPPUNIT_ASSERT_EQUAL(std::size_t(3), truth::Branch(&g, 0, truth::ClosureSpec::depth(1)).members().size()); + + // UntilPdgId stopping at the B0: top, W, b, B0, mu+, nu_mu (D-/pi+ excluded). + auto untilB = truth::Branch(&g, 0, truth::ClosureSpec::untilPdgId({511})).members(); + CPPUNIT_ASSERT_EQUAL(std::size_t(6), untilB.size()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), countPdg(untilB, 511)); + CPPUNIT_ASSERT_EQUAL(std::size_t(0), countPdg(untilB, -411)); + + // Predicate stopping at any b-hadron: same effect as untilPdgId({511}) here. + auto untilHF = truth::Branch(&g, 0, truth::ClosureSpec::predicate([](truth::Particle p) { + const int id = std::abs(p.pdgId()); + return id > 100 && ((id / 100) % 10 == 5 || (id / 1000) % 10 == 5); + })).members(); + CPPUNIT_ASSERT_EQUAL(std::size_t(0), countPdg(untilHF, -411)); +} + +void TestBranch::testKinematics() { + auto g = buildTtbarLike(); + truth::Branch top(&g, 0); + + // p4 = sum of stable leaves (mu+ 40, nu 30, D- 10, pi+ 5) = 85 in E. + CPPUNIT_ASSERT_DOUBLES_EQUAL(85.0, top.energy(), 1e-6); + // visible excludes the neutrino (30). + CPPUNIT_ASSERT_DOUBLES_EQUAL(55.0, top.visibleEnergy(), 1e-6); + CPPUNIT_ASSERT_DOUBLES_EQUAL(30.0, top.invisibleEnergy(), 1e-6); +} + +void TestBranch::testTaggingAndProvenance() { + auto g = buildTtbarLike(); + + truth::Branch bBranch(&g, 2); // rooted at the b quark + CPPUNIT_ASSERT_EQUAL(int32_t(5), bBranch.rootPdgId()); + // the b-branch originates from the top. + auto origin = bBranch.originWithPdgId(6); + CPPUNIT_ASSERT(origin.has_value()); + CPPUNIT_ASSERT_EQUAL(int32_t(6), origin->pdgId()); + // heavy-flavor content: contains a b-hadron (B0) and a c-hadron (D-). + CPPUNIT_ASSERT(bBranch.hasHeavyFlavor(5)); + CPPUNIT_ASSERT(bBranch.hasHeavyFlavor(4)); + + // a leptonic W branch has no heavy flavor. + truth::Branch wBranch(&g, 1); + CPPUNIT_ASSERT(!wBranch.hasHeavyFlavor(5)); + + // provenance: built with eventId 0 -> in-time, not pile-up. + CPPUNIT_ASSERT(truth::Branch(&g, 0).isInTime()); + CPPUNIT_ASSERT(!truth::Branch(&g, 0).isFromPileup()); +} + +void TestBranch::testRelations() { + auto g = buildTtbarLike(); + truth::Branch wBranch(&g, 1); + truth::Branch bBranch(&g, 2); + + // W and b branches share the top as common ancestor. + auto common = wBranch.commonAncestor(bBranch); + CPPUNIT_ASSERT(common.has_value()); + CPPUNIT_ASSERT_EQUAL(int32_t(6), common->pdgId()); + + // merged branch has both roots and the union of subtrees. + auto merged = wBranch.merged(bBranch); + CPPUNIT_ASSERT_EQUAL(std::size_t(2), merged.rootIds().size()); + // W subtree (W,mu,nu) + b subtree (b,B0,D-,pi+) = 7 members. + CPPUNIT_ASSERT_EQUAL(std::size_t(7), merged.members().size()); +} + +void TestBranch::testInvalidViews() { + // A default-constructed view and an out-of-range Graph::particle()/vertex() are + // invalid (null graph). The scalar getters must return defaults instead of + // dereferencing the null graph_ (regression for the unguarded data()). + auto g = buildTtbarLike(); + + for (truth::Particle p : {truth::Particle{}, g.particle(9999)}) { + CPPUNIT_ASSERT(!p.valid()); + CPPUNIT_ASSERT_EQUAL(int32_t(0), p.pdgId()); + CPPUNIT_ASSERT_EQUAL(int16_t(0), p.status()); + CPPUNIT_ASSERT(!p.hasGen()); + CPPUNIT_ASSERT(!p.hasSim()); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, p.momentum().energy(), 1e-9); + CPPUNIT_ASSERT(!p.hasCheckpoints()); + CPPUNIT_ASSERT(p.checkpoints().empty()); + CPPUNIT_ASSERT(p.parents().empty()); + CPPUNIT_ASSERT(p.children().empty()); + CPPUNIT_ASSERT(p.descendants().empty()); + CPPUNIT_ASSERT(!p.hasAncestorPdgId(6)); + } + + for (truth::Vertex v : {truth::Vertex{}, g.vertex(9999)}) { + CPPUNIT_ASSERT(!v.valid()); + CPPUNIT_ASSERT(!v.hasGen()); + CPPUNIT_ASSERT(!v.hasSim()); + CPPUNIT_ASSERT_DOUBLES_EQUAL(0.0, v.position().energy(), 1e-9); + CPPUNIT_ASSERT(v.incomingParticles().empty()); + CPPUNIT_ASSERT(v.outgoingParticles().empty()); + } +} diff --git a/PhysicsTools/TruthInfo/test/BuildFile.xml b/PhysicsTools/TruthInfo/test/BuildFile.xml new file mode 100644 index 0000000000000..e059c40ed4c1b --- /dev/null +++ b/PhysicsTools/TruthInfo/test/BuildFile.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp b/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp new file mode 100644 index 0000000000000..08a1742c9ed31 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/LogicalGraphHitIndexBuilder_t.cpp @@ -0,0 +1,105 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include + +#include "SimDataFormats/TruthInfo/interface/LogicalGraphHitIndex.h" +#include "PhysicsTools/TruthInfo/interface/LogicalGraphHitIndexBuilder.h" + +// These tests lock in the layout property the Branch view relies on: a particle's +// subgraph hits are a single contiguous std::span, sorted by detId, deduplicated +// by detId with energy accumulated across the whole subtree. That makes a +// Subtree branch's hits == subgraphHits(truth::HitChannel::HGCalCalo, root) with zero gather, and orders them +// for merge-join matching against reco objects. +class TestLogicalGraphHitIndexBuilder : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TestLogicalGraphHitIndexBuilder); + CPPUNIT_TEST(testSubgraphHitsAreSortedContiguousAndAccumulated); + CPPUNIT_TEST(testDirectHitsAreSortedByDetId); + CPPUNIT_TEST(testSubgraphDiamondCountsSharedDescendantOnce); + CPPUNIT_TEST_SUITE_END(); + +public: + void testSubgraphHitsAreSortedContiguousAndAccumulated(); + void testDirectHitsAreSortedByDetId(); + void testSubgraphDiamondCountsSharedDescendantOnce(); +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestLogicalGraphHitIndexBuilder); + +void TestLogicalGraphHitIndexBuilder::testSubgraphHitsAreSortedContiguousAndAccumulated() { + // particle 0 (track 100) -> child particle 1 (track 101) + truth::LogicalGraphHitIndexBuilder builder(2); + builder.setSimTrackForParticle(0, 100); + builder.setSimTrackForParticle(1, 101); + builder.addParticleChild(0, 1); + + builder.addHit(truth::HitChannel::HGCalCalo, 100, /*detId=*/10, /*energy=*/1.0f, /*recHitIndex=*/0); + builder.addHit(truth::HitChannel::HGCalCalo, 100, /*detId=*/5, /*energy=*/2.0f, /*recHitIndex=*/1); + builder.addHit( + truth::HitChannel::HGCalCalo, 101, /*detId=*/10, /*energy=*/3.0f, /*recHitIndex=*/0); // same detId as parent + builder.addHit(truth::HitChannel::HGCalCalo, 101, /*detId=*/20, /*energy=*/1.5f, /*recHitIndex=*/2); + + auto index = builder.finish(); + + auto sub = index.subgraphHits(truth::HitChannel::HGCalCalo, 0); + // subtree of 0 = {5, 10, 20}, with detId 10 accumulated across parent+child. + CPPUNIT_ASSERT_EQUAL(std::size_t(3), sub.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t(5), sub[0].detId); + CPPUNIT_ASSERT_EQUAL(uint32_t(10), sub[1].detId); + CPPUNIT_ASSERT_EQUAL(uint32_t(20), sub[2].detId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(4.0, sub[1].energy, 1e-6); // 1.0 (parent) + 3.0 (child) + + // sorted strictly ascending by detId (merge-join ready). + for (std::size_t i = 1; i < sub.size(); ++i) + CPPUNIT_ASSERT(sub[i - 1].detId < sub[i].detId); + + // child subtree is just its own hits. + auto subChild = index.subgraphHits(truth::HitChannel::HGCalCalo, 1); + CPPUNIT_ASSERT_EQUAL(std::size_t(2), subChild.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t(10), subChild[0].detId); + CPPUNIT_ASSERT_EQUAL(uint32_t(20), subChild[1].detId); +} + +void TestLogicalGraphHitIndexBuilder::testDirectHitsAreSortedByDetId() { + truth::LogicalGraphHitIndexBuilder builder(1); + builder.setSimTrackForParticle(0, 7); + builder.addHit(truth::HitChannel::HGCalCalo, 7, 30, 1.0f, 0); + builder.addHit(truth::HitChannel::HGCalCalo, 7, 3, 1.0f, 1); + builder.addHit(truth::HitChannel::HGCalCalo, 7, 17, 1.0f, 2); + + auto index = builder.finish(); + auto direct = index.directHits(truth::HitChannel::HGCalCalo, 0); + CPPUNIT_ASSERT_EQUAL(std::size_t(3), direct.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t(3), direct[0].detId); + CPPUNIT_ASSERT_EQUAL(uint32_t(17), direct[1].detId); + CPPUNIT_ASSERT_EQUAL(uint32_t(30), direct[2].detId); +} + +void TestLogicalGraphHitIndexBuilder::testSubgraphDiamondCountsSharedDescendantOnce() { + // Re-convergent DAG: 0 -> 1 -> 3 and 0 -> 2 -> 3. Particle 3 is a descendant of + // 0 along two distinct paths; its hit must contribute to subgraphHits(0) exactly + // once. (Regression: the old recursive child-subgraph merge summed it once per + // path, since coalesce() sums equal detIds, doubling the energy.) + truth::LogicalGraphHitIndexBuilder builder(4); + builder.setSimTrackForParticle(0, 100); + builder.setSimTrackForParticle(1, 101); + builder.setSimTrackForParticle(2, 102); + builder.setSimTrackForParticle(3, 103); + builder.addParticleChild(0, 1); + builder.addParticleChild(0, 2); + builder.addParticleChild(1, 3); + builder.addParticleChild(2, 3); + + builder.addHit(truth::HitChannel::HGCalCalo, 103, /*detId=*/50, /*energy=*/2.0f, /*recHitIndex=*/0); + + auto index = builder.finish(); + + auto sub = index.subgraphHits(truth::HitChannel::HGCalCalo, 0); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), sub.size()); + CPPUNIT_ASSERT_EQUAL(uint32_t(50), sub[0].detId); + CPPUNIT_ASSERT_DOUBLES_EQUAL(2.0, sub[0].energy, 1e-6); // counted once, not 4.0 +} diff --git a/PhysicsTools/TruthInfo/test/README_tools.md b/PhysicsTools/TruthInfo/test/README_tools.md new file mode 100644 index 0000000000000..94701d87107ff --- /dev/null +++ b/PhysicsTools/TruthInfo/test/README_tools.md @@ -0,0 +1,80 @@ +# TruthInfo test & debugging tools + +Reusable helpers for producing and inspecting truth graphs. All require `cmsenv` +(run from `CMSSW_17_0_0_pre2/src`). + +| Tool | Purpose | +|---|---| +| `dumpTruthGraphsFromGENSIMRECO_cfg.py` | cmsRun config: build the raw + logical truth graph from a GEN-SIM/RECO file and dump DOT (+NanoAOD rechit/simhit tables). Selection flags: `-s/--seeds`, `-g/--groups`, `-d/--parentDepth`, `-i/--ignore`, `-m/--merge`, `-c/--collapse`, `--showAll`, `-n`, `-o`, `-t`. `-s 0` keeps the full graph. | +| `truthGraphConnectivity.py` | FWLite debugger: per-event count of weakly-connected components and how many SimTrack/SimVertex are disconnected from a generator primary (the orphans). Exits non-zero if any event has orphans. `--link {parentIndex,ancestor,combined}`. | +| `../python/truthGraphSelections.py` | Per-process selection presets: maps a generator fragment (or label) to one of seven archetypes (gun / resonance / vbf / ggf / top / heavyflavor / full) and returns the right `postProcessing` selection. `selectionForFragment(name, **overrides)` (dict), `postProcessingPSet(...)` (cms.PSet), `dumperArgs(...)` / CLI (`python3 truthGraphSelections.py `) emit the dumper flags. | +| `makeTruthGallery.sh` | Build the per-process DOT/SVG gallery (full + natural-seed selection) from a relval library dir; the per-sample selection is resolved by `truthGraphSelections.py` from each workflow's fragment. | +| `makeBranchValidationPlots.sh` | Render the Branch DQM validation plots (overlaying a few samples) from the per-workflow harvested DQM, via `scripts/makeTruthGraphValidationPlots.py`. | +| `runTruthRelvals.sh` | Run the 8 enableTruth Run4 D120 no-PU truth-validation workflows via `runTheMatrix`. | +| `TruthLogicalGraphPostProcessor_t.cpp` | cppunit tests for the logical-graph postprocessing (selection, merging, collapsing). | + +## Typical flow +```bash +cmsenv # from CMSSW_17_0_0_pre2/src +runTruthRelvals.sh /path/library # produce the sample library (step1..5) +truthGraphConnectivity.py /path/library/34050.88_*/step3.root # sanity: orphans == 0 +makeTruthGallery.sh /path/library /path/dot_gallery # DOT + SVG gallery +makeBranchValidationPlots.sh /path/library /path/branch_plots # Branch DQM validation plots +``` + +## Focused selections (phases 1-3) +The postprocessing supports focused, physics-oriented views: +- `--no-keepSpectators` drops underlying-event spectators, leaving the selection + plus its truncated upstream attached to a labeled **ISR/upstream** source node. + Spectators (when kept) sit on a separate **underlying event** node; both + artificial nodes carry the genEvent/eventId of the activity they summarize + (pile-up provenance). +- `-f/--flavors` seeds on hadrons by heavy-flavor content (`-f 5` = B hadrons, + `-f 4` = D hadrons), OR-ed with `-s/--seeds`. +- `--keepProductionSiblings` keeps the seed's **hard-scatter co-products**: the + other outgoing particles of its production vertex (and their subtrees). These are + siblings of the seed, not ancestors, so `-d/--parentDepth` never reaches them - + e.g. seeding on the Higgs in VBF, this brings in the recoiling tagging quarks and + their forward jets, and shows the real hard vertex in place of the artificial + Upstream node. +- **Pile-up filter** (orthogonal to the seed, composes with any preset): `--signal-only` + keeps only the signal interaction (EncodedEventId bunchCrossing 0, event 0), dropping + all pile-up; `--bunch-crossings 0` keeps only the listed bunch crossings (in-time only). +```bash +# clean Z -> mu mu view with an explicit ISR node: +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root -s 23 -d 1 --no-keepSpectators +# all B-hadron decay subgraphs: +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root -f 5 --no-keepSpectators +# VBF Higgs with the tagging quarks/jets that produced it: +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root -s 25 -d 1 --keepProductionSiblings +``` +Rather than remember the right flags per process, let `truthGraphSelections.py` +pick them from the generator fragment (seven presets, fully overridable): +```bash +# the dumper flags for any fragment / label: +python3 ../python/truthGraphSelections.py VBFHZZ4Nu_14TeV +# -> -s 25 -d 1 --keepSpectators --attachSources --keepProductionSiblings +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root \ + $(python3 ../python/truthGraphSelections.py ZMM_14) +``` + +## Jet -> originating particle +The graph answers "which particle did this jet come from" once you have the +jet's truth constituents (GEN-jet constituents now; hit-matched reco +constituents via the LogicalGraphHitIndex later): +```cpp +// jetParticles: the truth::Particle of each constituent +auto origin = graph.lowestCommonAncestor(jetParticles); // e.g. the b quark of a b-jet +auto top = jetParticles.front().firstAncestorWithPdgId(6); // the originating top +``` +`lowestCommonAncestor` returns the closest shared ancestor; `firstAncestorWithPdgId` +walks up to a specific origin species. + +## One-off selection / coherence scan +`dumpTruthGraphsFromGENSIMRECO_cfg.py` drives all selection studies, e.g.: +```bash +# Z -> mu mu only (drop Z -> ee), depth-0 context: +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root -s 23 -g 13,-13 -d 0 +# full graph for debugging: +cmsRun dumpTruthGraphsFromGENSIMRECO_cfg.py file:step3.root -s 0 --showAll +``` diff --git a/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp b/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp new file mode 100644 index 0000000000000..e0069000b1af9 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/TruthLogicalGraphPostProcessor_t.cpp @@ -0,0 +1,1802 @@ +// Original author: Felice Pantaleo (CERN) +// Part of the MC-truth-graph prototype - under heavy development, not yet open +// to external contributions (see PhysicsTools/TruthInfo/README.md). + +#include "Utilities/Testing/interface/CppUnit_testdriver.icpp" +#include "cppunit/extensions/HelperMacros.h" + +#include +#include +#include +#include +#include +#include + +#include "FWCore/Utilities/interface/Exception.h" +#include "SimDataFormats/TruthInfo/interface/Graph.h" +#include "PhysicsTools/TruthInfo/interface/TruthLogicalGraphPostProcessor.h" +#include "SimDataFormats/EncodedEventId/interface/EncodedEventId.h" + +namespace { + + void buildCSR(uint32_t nSources, + std::vector>& pairs, + std::vector& offsets, + std::vector& flat) { + std::sort(pairs.begin(), pairs.end()); + pairs.erase(std::unique(pairs.begin(), pairs.end()), pairs.end()); + + offsets.assign(nSources + 1, 0); + + for (auto const& pair : pairs) { + CPPUNIT_ASSERT(pair.first < nSources); + ++offsets[pair.first + 1]; + } + + for (uint32_t i = 1; i <= nSources; ++i) { + offsets[i] += offsets[i - 1]; + } + + flat.assign(pairs.size(), 0); + auto cursor = offsets; + + for (auto const& pair : pairs) { + flat[cursor[pair.first]++] = pair.second; + } + } + + struct GraphBuilder { + explicit GraphBuilder(uint32_t nParticles, uint32_t nVertices) { + graph.particles().resize(nParticles); + graph.vertices().resize(nVertices); + } + + void setGenParticle(uint32_t particleId, int32_t pdgId, int16_t status, int32_t genNode) { + CPPUNIT_ASSERT(particleId < graph.nParticles()); + + auto& particle = graph.particles()[particleId]; + particle.genNode = genNode; + particle.simNode = -1; + particle.pdgId = pdgId; + particle.status = status; + particle.statusFlags = 0; + particle.eventId = 0; + particle.genEvent = 0; + } + + void setSimParticle(uint32_t particleId, int32_t pdgId, int32_t simNode) { + CPPUNIT_ASSERT(particleId < graph.nParticles()); + + auto& particle = graph.particles()[particleId]; + particle.genNode = -1; + particle.simNode = simNode; + particle.pdgId = pdgId; + particle.status = 0; + particle.statusFlags = 0; + particle.eventId = 0; // signal interaction: EncodedEventId(0, 0) packs to 0 + particle.genEvent = -1; + } + + void setGenSimParticle(uint32_t particleId, int32_t pdgId, int16_t status, int32_t genNode, int32_t simNode) { + CPPUNIT_ASSERT(particleId < graph.nParticles()); + + auto& particle = graph.particles()[particleId]; + particle.genNode = genNode; + particle.simNode = simNode; + particle.pdgId = pdgId; + particle.status = status; + particle.statusFlags = 0; + particle.eventId = 0; // signal interaction: EncodedEventId(0, 0) packs to 0 + particle.genEvent = 0; + } + + void setGenVertex(uint32_t vertexId, int32_t genNode) { + CPPUNIT_ASSERT(vertexId < graph.nVertices()); + + auto& vertex = graph.vertices()[vertexId]; + vertex.genNode = genNode; + vertex.simNode = -1; + vertex.eventId = 0; + vertex.genEvent = 0; + } + + void setSimVertex(uint32_t vertexId, int32_t simNode) { + CPPUNIT_ASSERT(vertexId < graph.nVertices()); + + auto& vertex = graph.vertices()[vertexId]; + vertex.genNode = -1; + vertex.simNode = simNode; + vertex.eventId = 0; // signal interaction + vertex.genEvent = -1; + } + + void setGenSimVertex(uint32_t vertexId, int32_t genNode, int32_t simNode) { + CPPUNIT_ASSERT(vertexId < graph.nVertices()); + + auto& vertex = graph.vertices()[vertexId]; + vertex.genNode = genNode; + vertex.simNode = simNode; + vertex.eventId = 0; // signal interaction + vertex.genEvent = 0; + } + + void addDecay(uint32_t particleId, uint32_t vertexId) { + CPPUNIT_ASSERT(particleId < graph.nParticles()); + CPPUNIT_ASSERT(vertexId < graph.nVertices()); + + particleToDecayVertexPairs.emplace_back(particleId, vertexId); + vertexToIncomingParticlePairs.emplace_back(vertexId, particleId); + } + + void addProduction(uint32_t vertexId, uint32_t particleId) { + CPPUNIT_ASSERT(vertexId < graph.nVertices()); + CPPUNIT_ASSERT(particleId < graph.nParticles()); + + vertexToOutgoingParticlePairs.emplace_back(vertexId, particleId); + particleToProductionVertexPairs.emplace_back(particleId, vertexId); + } + + truth::Graph finish() { + buildCSR(graph.nParticles(), + particleToDecayVertexPairs, + graph.particleToDecayVertexOffsets(), + graph.particleToDecayVertices()); + + buildCSR(graph.nParticles(), + particleToProductionVertexPairs, + graph.particleToProductionVertexOffsets(), + graph.particleToProductionVertices()); + + buildCSR(graph.nVertices(), + vertexToOutgoingParticlePairs, + graph.vertexToOutgoingParticleOffsets(), + graph.vertexToOutgoingParticles()); + + buildCSR(graph.nVertices(), + vertexToIncomingParticlePairs, + graph.vertexToIncomingParticleOffsets(), + graph.vertexToIncomingParticles()); + + CPPUNIT_ASSERT(graph.isConsistent()); + + return graph; + } + + truth::Graph graph; + + std::vector> particleToDecayVertexPairs; + std::vector> particleToProductionVertexPairs; + std::vector> vertexToOutgoingParticlePairs; + std::vector> vertexToIncomingParticlePairs; + }; + + uint32_t countParticlesWithPdgId(truth::Graph const& graph, int32_t pdgId) { + uint32_t count = 0; + + for (auto const& particle : graph.particles()) { + if (particle.pdgId == pdgId) + ++count; + } + + return count; + } + + uint32_t countStableGenParticles(truth::Graph const& graph) { + uint32_t count = 0; + + for (auto const& particle : graph.particles()) { + if (particle.hasGen() && particle.status == 1) + ++count; + } + + return count; + } + + bool hasGenSimParticleWithPdgId(truth::Graph const& graph, int32_t pdgId) { + return std::any_of(graph.particles().begin(), graph.particles().end(), [pdgId](auto const& particle) { + return particle.pdgId == pdgId && particle.hasGen() && particle.hasSim(); + }); + } + + bool hasArtificialVertex(truth::Graph const& graph) { + return std::any_of(graph.vertices().begin(), graph.vertices().end(), [](auto const& vertex) { + return !vertex.hasGen() && !vertex.hasSim(); + }); + } + + // The artificial *sub*-vertex a particle attaches to (Upstream or + // UnderlyingEvent), i.e. skipping the per-interaction Interaction root that + // those sub-vertices descend from. + uint32_t artificialVertexId(truth::Graph const& graph) { + for (uint32_t i = 0; i < graph.nVertices(); ++i) { + auto const& vertex = graph.vertices()[i]; + + if (vertex.isArtificial() && vertex.vertexRole() != truth::VertexRole::Interaction) + return i; + } + + CPPUNIT_ASSERT(false); + return 0; + } + + uint32_t findParticleWithPdgId(truth::Graph const& graph, int32_t pdgId) { + for (uint32_t i = 0; i < graph.nParticles(); ++i) { + if (graph.particles()[i].pdgId == pdgId) + return i; + } + + CPPUNIT_ASSERT(false); + return 0; + } + + uint32_t findVertexWithRole(truth::Graph const& graph, truth::VertexRole role) { + for (uint32_t i = 0; i < graph.nVertices(); ++i) { + if (graph.vertices()[i].vertexRole() == role) + return i; + } + + CPPUNIT_ASSERT(false); + return 0; + } + + uint32_t countArtificialVerticesWithRole(truth::Graph const& graph, truth::VertexRole role) { + uint32_t count = 0; + for (auto const& vertex : graph.vertices()) { + if (vertex.isArtificial() && vertex.vertexRole() == role) + ++count; + } + return count; + } + + // True if, walking up its production chain, the particle reaches an artificial + // source vertex (Interaction / Upstream / UnderlyingEvent) - i.e. it is grounded + // on the per-interaction artificial-source structure rather than on a real vertex. + bool descendsFromArtificialSource(truth::Graph const& graph, uint32_t particleId) { + std::vector seenParticle(graph.nParticles(), 0); + std::vector seenVertex(graph.nVertices(), 0); + std::queue queue; + queue.push(particleId); + seenParticle[particleId] = 1; + + while (!queue.empty()) { + const uint32_t particle = queue.front(); + queue.pop(); + + for (const uint32_t vertex : graph.productionVertices(particle)) { + if (vertex >= graph.nVertices() || seenVertex[vertex]) + continue; + seenVertex[vertex] = 1; + + if (graph.vertices()[vertex].isArtificial()) + return true; + + for (const uint32_t parent : graph.incomingParticles(vertex)) { + if (parent < graph.nParticles() && !seenParticle[parent]) { + seenParticle[parent] = 1; + queue.push(parent); + } + } + } + } + return false; + } + + truth::LogicalGraphPostProcessingConfig defaultConfig() { + truth::LogicalGraphPostProcessingConfig config; + config.collapseIntermediateGenParticles = false; + config.seedPdgIds = {}; + config.seedParentDepth = 0; + config.ignoredPdgIds = {}; + config.ignoredParticleIds = {}; + return config; + } + + truth::Graph runPostProcessing(truth::Graph graph, truth::LogicalGraphPostProcessingConfig const& config) { + truth::TruthLogicalGraphPostProcessor processor(config); + return processor.process(std::move(graph)); + } + + truth::Graph runPostProcessing(truth::Graph graph, + truth::LogicalGraphPostProcessingConfig const& config, + std::vector const& particleDirectHit) { + truth::TruthLogicalGraphPostProcessor processor(config); + return processor.process(std::move(graph), particleDirectHit); + } + +} // namespace + +class TestTruthLogicalGraphPostProcessor : public CppUnit::TestFixture { + CPPUNIT_TEST_SUITE(TestTruthLogicalGraphPostProcessor); + CPPUNIT_TEST(testStatusOneGenParticlesAreNeverCollapsed); + CPPUNIT_TEST(testStableGenSimParticlesSurviveIntermediateCollapse); + CPPUNIT_TEST(testSeedCutKeepsUnrelatedStableGenSimParticlesThroughArtificialVertex); + CPPUNIT_TEST(testSeedCutHidesUnselectedParentsOfKeptVertices); + CPPUNIT_TEST(testIgnoredParticlesAreCollapsedAway); + CPPUNIT_TEST(testSeedCutWithIgnoredParticles); + CPPUNIT_TEST(testIgnoredParticleIdsAreCollapsedAway); + CPPUNIT_TEST(testSeedRootIsMostUpstreamThroughRadiatingCopyChain); + CPPUNIT_TEST(testSeedParentDepthKeepsAncestorContextOnly); + CPPUNIT_TEST(testKeepProductionSiblingsKeepsHardCoProducts); + CPPUNIT_TEST(testSeedWithDecayGroupKeepsOnlyMatchingDecays); + CPPUNIT_TEST(testZToTauTauDoesNotMatchMuonDecayGroup); + CPPUNIT_TEST(testDecayGroupFallbackWhenSeedAbsent); + CPPUNIT_TEST(testSeedPdgIdZeroKeepsFullGraphForDebugging); + CPPUNIT_TEST(testArtificialSourceRolesAndProvenance); + CPPUNIT_TEST(testKeepStableSpectatorsFalseDropsSpectators); + CPPUNIT_TEST(testSeedHadronFlavorSelectsBHadron); + CPPUNIT_TEST(testJetOriginLowestCommonAncestor); + CPPUNIT_TEST(testHitlessSimSubgraphsAreDropped); + CPPUNIT_TEST(testAttachSelectionSourcesFalseRootsSeedsDirectly); + CPPUNIT_TEST(testEventIdKeyingSplitsInteractions); + CPPUNIT_TEST(testSignalOnlyAndBunchCrossingFilterDropPileup); + CPPUNIT_TEST(testEveryParticleDescendsFromArtificialSource); + CPPUNIT_TEST_SUITE_END(); + +public: + void testStatusOneGenParticlesAreNeverCollapsed(); + void testStableGenSimParticlesSurviveIntermediateCollapse(); + void testSeedCutKeepsUnrelatedStableGenSimParticlesThroughArtificialVertex(); + void testSeedCutHidesUnselectedParentsOfKeptVertices(); + void testIgnoredParticlesAreCollapsedAway(); + void testSeedCutWithIgnoredParticles(); + void testIgnoredParticleIdsAreCollapsedAway(); + void testSeedRootIsMostUpstreamThroughRadiatingCopyChain(); + void testSeedParentDepthKeepsAncestorContextOnly(); + void testKeepProductionSiblingsKeepsHardCoProducts(); + void testSeedWithDecayGroupKeepsOnlyMatchingDecays(); + void testZToTauTauDoesNotMatchMuonDecayGroup(); + void testDecayGroupFallbackWhenSeedAbsent(); + void testSeedPdgIdZeroKeepsFullGraphForDebugging(); + void testArtificialSourceRolesAndProvenance(); + void testKeepStableSpectatorsFalseDropsSpectators(); + void testSeedHadronFlavorSelectsBHadron(); + void testJetOriginLowestCommonAncestor(); + void testHitlessSimSubgraphsAreDropped(); + void testAttachSelectionSourcesFalseRootsSeedsDirectly(); + void testEventIdKeyingSplitsInteractions(); + void testSignalOnlyAndBunchCrossingFilterDropPileup(); + void testEveryParticleDescendsFromArtificialSource(); +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestTruthLogicalGraphPostProcessor); + +void TestTruthLogicalGraphPostProcessor::testStatusOneGenParticlesAreNeverCollapsed() { + try { + GraphBuilder builder(2, 1); + + // This topology is intentionally unphysical: a status-1 GEN particle has a + // decay vertex to another same-PDG status-1 particle. The postprocessor must + // still never collapse a status-1 GEN particle. + builder.setGenParticle(0, 22, 1, 100); + builder.setGenParticle(1, 22, 1, 101); + builder.setGenVertex(0, 200); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.collapseIntermediateGenParticles = true; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), output.nParticles()); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countStableGenParticles(output)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testStableGenSimParticlesSurviveIntermediateCollapse() { + try { + GraphBuilder builder(3, 2); + + // gamma(status 2) -> gamma(status 1, GEN+SIM) + // e-(status 1, GEN+SIM) is an independent stable final-state particle. + builder.setGenParticle(0, 22, 2, 100); + builder.setGenSimParticle(1, 22, 1, 101, 1001); + builder.setGenSimParticle(2, 11, 1, 102, 1002); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addProduction(1, 2); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.collapseIntermediateGenParticles = true; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + // The intermediate gamma can collapse into the final stable gamma, but the + // status-1 GEN+SIM gamma must remain materialized. + CPPUNIT_ASSERT_EQUAL(uint32_t(2), output.nParticles()); + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 22)); + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 11)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countStableGenParticles(output)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedCutKeepsUnrelatedStableGenSimParticlesThroughArtificialVertex() { + try { + GraphBuilder builder(4, 3); + + // Interesting branch: + // Z -> gamma(status 1, GEN+SIM) + // + // Unrelated stable final state: + // artificial filtering must keep e-(status 1, GEN+SIM), but attach it to + // one artificial vertex instead of keeping its unrelated production chain. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenSimParticle(1, 22, 1, 101, 1001); + builder.setGenParticle(2, 999, 2, 102); + builder.setGenSimParticle(3, 11, 1, 103, 1003); + + builder.setGenSimVertex(0, 200, 2000); + builder.setGenVertex(1, 201); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + + builder.addDecay(2, 1); + builder.addProduction(1, 3); + + // A second production vertex for the stable electron, to make sure the + // filtered graph does not keep unrelated upstream structure. + builder.addProduction(2, 3); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {22}; + config.seedParentDepth = 1; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 22)); + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 11)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t electron = findParticleWithPdgId(output, 11); + const auto productionVertices = output.productionVertices(electron); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), productionVertices.size()); + + const uint32_t collapsedVertex = artificialVertexId(output); + CPPUNIT_ASSERT_EQUAL(collapsedVertex, productionVertices.front()); + + const auto artificialOutgoing = output.outgoingParticles(collapsedVertex); + CPPUNIT_ASSERT(std::find(artificialOutgoing.begin(), artificialOutgoing.end(), electron) != + artificialOutgoing.end()); + + // The sub-vertex is not a source: it descends from the per-interaction + // Interaction vertex through one artificial connector particle. + const auto artificialIncoming = output.incomingParticles(collapsedVertex); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), artificialIncoming.size()); + const auto connectorProduction = output.productionVertices(artificialIncoming.front()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), connectorProduction.size()); + CPPUNIT_ASSERT(output.vertices()[connectorProduction.front()].vertexRole() == truth::VertexRole::Interaction); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedCutHidesUnselectedParentsOfKeptVertices() { + try { + GraphBuilder builder(5, 3); + + // DAG topology: + // + // H -> v0 -> pi0 + // Z --------^ + // pi0 -> v1 -> gamma + // e- stable, unrelated + // + // The seed is H. Keeping downstream from H keeps v0. The unselected Z parent + // of v0 is not part of the selection and seedParentDepth is 0, so it must be + // hidden: v0 appears with H as its only incoming particle. + builder.setGenParticle(0, 25, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.setGenParticle(2, 111, 2, 102); + builder.setGenSimParticle(3, 22, 1, 103, 1003); + builder.setGenSimParticle(4, 11, 1, 104, 1004); + + builder.setGenVertex(0, 200); + builder.setGenVertex(1, 201); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addDecay(1, 0); + builder.addProduction(0, 2); + + builder.addDecay(2, 1); + builder.addProduction(1, 3); + + builder.addProduction(2, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {25}; + config.seedParentDepth = 0; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 25)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 111)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 22)); + + // The unrelated stable electron is still kept, but via the artificial vertex. + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 11)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t pi0 = findParticleWithPdgId(output, 111); + const auto pi0ProductionVertices = output.productionVertices(pi0); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), pi0ProductionVertices.size()); + + const auto incoming = output.incomingParticles(pi0ProductionVertices.front()); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), incoming.size()); + CPPUNIT_ASSERT_EQUAL(int32_t(25), output.particles()[incoming.front()].pdgId); + + const uint32_t electron = findParticleWithPdgId(output, 11); + const auto electronProductionVertices = output.productionVertices(electron); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), electronProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificialVertexId(output), electronProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testIgnoredParticlesAreCollapsedAway() { + try { + GraphBuilder builder(3, 2); + + // Z -> gamma -> e- + // + // If gamma is ignored, it should disappear and the two vertices around it + // should be merged, preserving a navigable Z -> e- connection. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, 22, 2, 101); + builder.setGenSimParticle(2, 11, 1, 102, 1002); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + + builder.addDecay(1, 1); + builder.addProduction(1, 2); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.ignoredPdgIds = {22}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 11)); + + const uint32_t z = findParticleWithPdgId(output, 23); + const uint32_t electron = findParticleWithPdgId(output, 11); + + const auto decayVertices = output.decayVertices(z); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), decayVertices.size()); + + const auto outgoing = output.outgoingParticles(decayVertices.front()); + CPPUNIT_ASSERT(std::find(outgoing.begin(), outgoing.end(), electron) != outgoing.end()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedRootIsMostUpstreamThroughRadiatingCopyChain() { + try { + GraphBuilder builder(6, 3); + + // q -> vq -> Z0 + // Z0 -> v0 -> { Z1, gamma } (radiating copy chain, survives chain collapse) + // Z1 -> v1 -> { mu+, mu- } + // + // With seedPdgIds = {23} only Z0 is a root: Z1 is a strict descendant of + // another match. The q parent is outside the selection (depth 0), so Z0 is + // attached to the artificial source vertex while Z1 keeps its real + // production vertex. + builder.setGenParticle(0, 1, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.setGenParticle(2, 23, 2, 102); + builder.setGenSimParticle(3, 22, 1, 103, 1003); + builder.setGenSimParticle(4, -13, 1, 104, 1004); + builder.setGenSimParticle(5, 13, 1, 105, 1005); + + builder.setGenVertex(0, 200); + builder.setGenVertex(1, 201); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + + builder.addDecay(2, 2); + builder.addProduction(2, 4); + builder.addProduction(2, 5); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 1)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -13)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 13)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + // Exactly one Z (the most upstream root, with its q parent dropped) hangs + // off the artificial source vertex; the downstream copy keeps its real one. + const uint32_t artificial = artificialVertexId(output); + uint32_t nZAttachedToArtificial = 0; + + for (uint32_t particleId = 0; particleId < output.nParticles(); ++particleId) { + if (output.particles()[particleId].pdgId != 23) + continue; + + const auto productionVertices = output.productionVertices(particleId); + + if (productionVertices.size() == 1 && productionVertices.front() == artificial) + ++nZAttachedToArtificial; + } + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), nZAttachedToArtificial); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedParentDepthKeepsAncestorContextOnly() { + try { + GraphBuilder builder(7, 3); + + // g g -> vp -> { Z, q } + // Z -> vz -> { mu+, mu- } + // q -> vq -> { pi+ (stable) } + // + // With seedParentDepth = 1 the gluons and vp are kept as context, but the + // sibling q and its decay chain are not: ancestors no longer pull in their + // own downstream. The stable pion survives through the artificial vertex. + builder.setGenParticle(0, 21, 2, 100); + builder.setGenParticle(1, 21, 2, 101); + builder.setGenParticle(2, 23, 2, 102); + builder.setGenParticle(3, 1, 2, 103); + builder.setGenSimParticle(4, 211, 1, 104, 1004); + builder.setGenSimParticle(5, -13, 1, 105, 1005); + builder.setGenSimParticle(6, 13, 1, 106, 1006); + + builder.setGenVertex(0, 200); + builder.setGenVertex(1, 201); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addDecay(1, 0); + builder.addProduction(0, 2); + builder.addProduction(0, 3); + + builder.addDecay(3, 1); + builder.addProduction(1, 4); + + builder.addDecay(2, 2); + builder.addProduction(2, 5); + builder.addProduction(2, 6); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 1; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 21)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 1)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 211)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t z = findParticleWithPdgId(output, 23); + const auto zProductionVertices = output.productionVertices(z); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), zProductionVertices.size()); + + // Both gluons are visible as context above the Z, and the hidden sibling + // quark leaves the production vertex with the Z as its only outgoing. + CPPUNIT_ASSERT_EQUAL(std::size_t(2), output.incomingParticles(zProductionVertices.front()).size()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), output.outgoingParticles(zProductionVertices.front()).size()); + + const uint32_t pion = findParticleWithPdgId(output, 211); + const auto pionProductionVertices = output.productionVertices(pion); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), pionProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificialVertexId(output), pionProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testKeepProductionSiblingsKeepsHardCoProducts() { + try { + GraphBuilder builder(7, 3); + + // g g -> vp -> { Z, q }; Z -> vz -> { mu+, mu- }; q -> vq -> { pi+ (stable) } + // + // Same topology as the seedParentDepth test, but seeding on the Z with + // keepProductionSiblings keeps the recoiling quark q - the Z's sibling at the + // shared production vertex - and its decay subtree (the pion jet). This is the + // VBF case in miniature (the quark would be a tagging jet); seedParentDepth + // alone never reaches it because it is a co-product, not an ancestor. + builder.setGenParticle(0, 21, 2, 100); + builder.setGenParticle(1, 21, 2, 101); + builder.setGenParticle(2, 23, 2, 102); + builder.setGenParticle(3, 1, 2, 103); + builder.setGenSimParticle(4, 211, 1, 104, 1004); + builder.setGenSimParticle(5, -13, 1, 105, 1005); + builder.setGenSimParticle(6, 13, 1, 106, 1006); + + builder.setGenVertex(0, 200); + builder.setGenVertex(1, 201); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addDecay(1, 0); + builder.addProduction(0, 2); + builder.addProduction(0, 3); + + builder.addDecay(3, 1); + builder.addProduction(1, 4); + + builder.addDecay(2, 2); + builder.addProduction(2, 5); + builder.addProduction(2, 6); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + // Drop spectators so the quark/pion can only enter via keepProductionSiblings. + config.keepStableSpectators = false; + config.keepProductionSiblings = true; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + // The recoiling quark (unlike seedParentDepth, which drops it) and its pion + // jet are kept. + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 1)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 211)); + + const uint32_t z = findParticleWithPdgId(output, 23); + const uint32_t quark = findParticleWithPdgId(output, 1); + const auto zProductionVertices = output.productionVertices(z); + const auto quarkProductionVertices = output.productionVertices(quark); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), zProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), quarkProductionVertices.size()); + + // The Z and the quark share the real hard-scatter production vertex, which now + // exposes both as outgoing - the recoiling co-product is visible. + CPPUNIT_ASSERT_EQUAL(zProductionVertices.front(), quarkProductionVertices.front()); + CPPUNIT_ASSERT_EQUAL(std::size_t(2), output.outgoingParticles(zProductionVertices.front()).size()); + + // The pion came in through the quark's real decay chain (the jet), so its + // production vertex is the quark's decay vertex - not an artificial source. + const uint32_t pion = findParticleWithPdgId(output, 211); + const auto pionProductionVertices = output.productionVertices(pion); + const auto quarkDecayVertices = output.decayVertices(quark); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), pionProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), quarkDecayVertices.size()); + CPPUNIT_ASSERT_EQUAL(quarkDecayVertices.front(), pionProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedWithDecayGroupKeepsOnlyMatchingDecays() { + try { + GraphBuilder builder(7, 3); + + // Za -> v0 -> { Za', gamma } (radiating copy) + // Za' -> v1 -> { mu+, mu- } + // Zb -> v2 -> { e- } + // + // seedPdgIds = {23}, decayPdgIdGroups = {{13, -13}}: the decay match is + // evaluated at the most upstream root after following the copy chain, so + // Za (-> mu mu) is kept and Zb (-> e e) is dropped. The stable electrons + // survive only as spectators on the artificial vertex. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.setGenParticle(2, 23, 2, 102); + builder.setGenSimParticle(3, 22, 1, 103, 1003); + builder.setGenSimParticle(4, -13, 1, 104, 1004); + builder.setGenSimParticle(5, 13, 1, 105, 1005); + builder.setGenSimParticle(6, 11, 1, 106, 1006); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addProduction(0, 3); + + builder.addDecay(1, 1); + builder.addProduction(1, 4); + builder.addProduction(1, 5); + + builder.addDecay(2, 2); + builder.addProduction(2, 6); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.decayPdgIdGroups = {{13, -13}}; + config.seedParentDepth = 0; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + // Both copies of the matching Z chain are kept; the Z -> e e one is gone. + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -13)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 13)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 11)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t muon = findParticleWithPdgId(output, 13); + const auto muonProductionVertices = output.productionVertices(muon); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), muonProductionVertices.size()); + CPPUNIT_ASSERT(muonProductionVertices.front() != artificialVertexId(output)); + + const uint32_t electron = findParticleWithPdgId(output, 11); + const auto electronProductionVertices = output.productionVertices(electron); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), electronProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificialVertexId(output), electronProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testZToTauTauDoesNotMatchMuonDecayGroup() { + try { + GraphBuilder builder(7, 3); + + // Z -> v0 -> { tau+, tau- } + // tau+ -> v1 -> { mu+, anti-nu } + // tau- -> v2 -> { mu-, nu } + // + // The decay match is local to the (copy-collapsed) decay vertex of the + // root: the muons from the tau decays must NOT make Z -> tau tau match + // {13, -13}. Nothing matches, so only stable particles survive, attached + // to the artificial vertex. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, -15, 2, 101); + builder.setGenParticle(2, 15, 2, 102); + builder.setGenSimParticle(3, -13, 1, 103, 1003); + builder.setGenSimParticle(4, 13, 1, 104, 1004); + builder.setGenParticle(5, -16, 1, 105); + builder.setGenParticle(6, 16, 1, 106); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addProduction(0, 2); + + builder.addDecay(1, 1); + builder.addProduction(1, 3); + builder.addProduction(1, 5); + + builder.addDecay(2, 2); + builder.addProduction(2, 4); + builder.addProduction(2, 6); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.decayPdgIdGroups = {{13, -13}}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 15)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, -15)); + + // Stable muons and neutrinos survive as spectators only: four real particles + // plus one artificial connector, under two artificial vertices + // (Interaction -> UnderlyingEvent). + CPPUNIT_ASSERT_EQUAL(uint32_t(5), output.nParticles()); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), output.nVertices()); + + const uint32_t muon = findParticleWithPdgId(output, 13); + const auto muonProductionVertices = output.productionVertices(muon); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), muonProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificialVertexId(output), muonProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testDecayGroupFallbackWhenSeedAbsent() { + try { + GraphBuilder builder(5, 2); + + // The generator wrote no explicit Z: vp -> { mu+, mu-, gamma }. + // Unrelated branch: X -> vx -> { pi+ (stable) }. + // + // seedPdgIds = {23} finds nothing, so the decay-pattern fallback selects + // the mu+ mu- vertex (extra photon allowed). The matched vertex is kept as + // the common production context; the pion survives via the artificial + // vertex and X is dropped. + builder.setGenSimParticle(0, -13, 1, 100, 1000); + builder.setGenSimParticle(1, 13, 1, 101, 1001); + builder.setGenSimParticle(2, 22, 1, 102, 1002); + builder.setGenParticle(3, 999, 2, 103); + builder.setGenSimParticle(4, 211, 1, 104, 1004); + + builder.setGenVertex(0, 200); + builder.setGenVertex(1, 201); + + builder.addProduction(0, 0); + builder.addProduction(0, 1); + builder.addProduction(0, 2); + + builder.addDecay(3, 1); + builder.addProduction(1, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.decayPdgIdGroups = {{13, -13}}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 999)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -13)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 13)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 211)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t artificial = artificialVertexId(output); + + // The muons hang off their real, kept production vertex, not the + // artificial one; the stable photon at the same vertex stays there too. + const uint32_t muon = findParticleWithPdgId(output, 13); + const auto muonProductionVertices = output.productionVertices(muon); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), muonProductionVertices.size()); + CPPUNIT_ASSERT(muonProductionVertices.front() != artificial); + + const uint32_t gamma = findParticleWithPdgId(output, 22); + const auto gammaProductionVertices = output.productionVertices(gamma); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), gammaProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(muonProductionVertices.front(), gammaProductionVertices.front()); + + const uint32_t pion = findParticleWithPdgId(output, 211); + const auto pionProductionVertices = output.productionVertices(pion); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), pionProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificial, pionProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedPdgIdZeroKeepsFullGraphForDebugging() { + try { + GraphBuilder builder(7, 3); + + // Same topology as the Z -> tau tau test, where the selection would match + // nothing. The PDG id 0 wildcard must bypass the selection entirely and + // keep the full graph. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, -15, 2, 101); + builder.setGenParticle(2, 15, 2, 102); + builder.setGenSimParticle(3, -13, 1, 103, 1003); + builder.setGenSimParticle(4, 13, 1, 104, 1004); + builder.setGenParticle(5, -16, 1, 105); + builder.setGenParticle(6, 16, 1, 106); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addProduction(0, 2); + + builder.addDecay(1, 1); + builder.addProduction(1, 3); + builder.addProduction(1, 5); + + builder.addDecay(2, 2); + builder.addProduction(2, 4); + builder.addProduction(2, 6); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {0}; + config.decayPdgIdGroups = {{13, -13}}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(7), output.nParticles()); + CPPUNIT_ASSERT_EQUAL(uint32_t(3), output.nVertices()); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT(!hasArtificialVertex(output)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedCutWithIgnoredParticles() { + try { + GraphBuilder builder(6, 4); + + // Interesting branch: + // H -> pi0 -> gamma(status 1, GEN+SIM) + // + // Extra parent on the kept pi0 production vertex: + // Z --------^ (unselected, hidden by the seed cut) + // + // Unrelated stable final-state e- is kept through the artificial vertex. + // + // Then ignoredPdgIds removes pi0, merging H directly to gamma. + builder.setGenParticle(0, 25, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.setGenParticle(2, 111, 2, 102); + builder.setGenSimParticle(3, 22, 1, 103, 1003); + builder.setGenParticle(4, 999, 2, 104); + builder.setGenSimParticle(5, 11, 1, 105, 1005); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenVertex(2, 202); + builder.setGenSimVertex(3, 203, 2003); + + builder.addDecay(0, 0); + builder.addDecay(1, 0); + builder.addProduction(0, 2); + + builder.addDecay(2, 1); + builder.addProduction(1, 3); + + builder.addDecay(4, 2); + builder.addProduction(2, 5); + + builder.addProduction(3, 5); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {25}; + config.seedParentDepth = 0; + config.ignoredPdgIds = {111}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 25)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 111)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 11)); + + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 22)); + CPPUNIT_ASSERT(hasGenSimParticleWithPdgId(output, 11)); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + const uint32_t gamma = findParticleWithPdgId(output, 22); + const auto gammaProductionVertices = output.productionVertices(gamma); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), gammaProductionVertices.size()); + + const auto incoming = output.incomingParticles(gammaProductionVertices.front()); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), incoming.size()); + CPPUNIT_ASSERT_EQUAL(int32_t(25), output.particles()[incoming.front()].pdgId); + + const uint32_t electron = findParticleWithPdgId(output, 11); + const auto electronProductionVertices = output.productionVertices(electron); + + CPPUNIT_ASSERT_EQUAL(std::size_t(1), electronProductionVertices.size()); + CPPUNIT_ASSERT_EQUAL(artificialVertexId(output), electronProductionVertices.front()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testIgnoredParticleIdsAreCollapsedAway() { + try { + GraphBuilder builder(4, 3); + + // Z -> a -> gamma -> e- + // + // Only particle id 2 is ignored. This verifies that ignoredParticleIds is + // independent from PDG id matching: the status-1 gamma is removed because its + // logical id is explicitly listed, not because all photons are ignored. + builder.setGenParticle(0, 23, 2, 100); + builder.setGenParticle(1, 36, 2, 101); + builder.setGenSimParticle(2, 22, 1, 102, 1002); + builder.setGenSimParticle(3, 11, 1, 103, 1003); + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenSimVertex(2, 202, 2002); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + + builder.addDecay(1, 1); + builder.addProduction(1, 2); + + builder.addDecay(2, 2); + builder.addProduction(2, 3); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.ignoredParticleIds = {2}; + + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 36)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 22)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 11)); + + const uint32_t a = findParticleWithPdgId(output, 36); + const uint32_t electron = findParticleWithPdgId(output, 11); + + const auto decayVertices = output.decayVertices(a); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), decayVertices.size()); + + const auto outgoing = output.outgoingParticles(decayVertices.front()); + CPPUNIT_ASSERT(std::find(outgoing.begin(), outgoing.end(), electron) != outgoing.end()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testArtificialSourceRolesAndProvenance() { + try { + GraphBuilder builder(5, 3); + + // q -> Z -> mu+ mu- ; plus an unrelated stable pi+ (underlying event). + builder.setGenParticle(0, 1, 2, 100); // q + builder.setGenParticle(1, 23, 2, 101); // Z + builder.setGenSimParticle(2, -13, 1, 102, 1002); + builder.setGenSimParticle(3, 13, 1, 103, 1003); + builder.setGenSimParticle(4, 211, 1, 104, 1004); // stable spectator + + builder.setGenVertex(0, 200); // q -> Z (dropped at depth 0) + builder.setGenSimVertex(1, 201, 2001); // Z -> mu mu + builder.setGenVertex(2, 202); // -> pi+ (dropped) + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + builder.addProduction(2, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + config.keepStableSpectators = true; + + auto output = runPostProcessing(std::move(graph), config); + CPPUNIT_ASSERT(output.isConsistent()); + + // Z (root with truncated upstream) -> Upstream node; pi+ -> UnderlyingEvent + // node; both descend from a single Interaction node for the one interaction. + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countArtificialVerticesWithRole(output, truth::VertexRole::Upstream)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countArtificialVerticesWithRole(output, truth::VertexRole::UnderlyingEvent)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countArtificialVerticesWithRole(output, truth::VertexRole::Interaction)); + + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 1)); // q dropped at depth 0 + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 211)); + + // Provenance: artificial sources carry the genEvent of the activity they summarize. + for (auto const& vertex : output.vertices()) { + if (vertex.isArtificial()) + CPPUNIT_ASSERT_EQUAL(int32_t(0), vertex.genEvent); + } + + const uint32_t z = findParticleWithPdgId(output, 23); + const auto zProd = output.productionVertices(z); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), zProd.size()); + CPPUNIT_ASSERT(output.vertices()[zProd.front()].vertexRole() == truth::VertexRole::Upstream); + + // The Upstream and UnderlyingEvent vertices each descend from the single + // Interaction vertex through one artificial connector particle. + const uint32_t interaction = findVertexWithRole(output, truth::VertexRole::Interaction); + const uint32_t upstream = findVertexWithRole(output, truth::VertexRole::Upstream); + const uint32_t underlyingEvent = findVertexWithRole(output, truth::VertexRole::UnderlyingEvent); + + for (const uint32_t sub : {upstream, underlyingEvent}) { + const auto incoming = output.incomingParticles(sub); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), incoming.size()); // the connector particle + const auto connectorProd = output.productionVertices(incoming.front()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), connectorProd.size()); + CPPUNIT_ASSERT_EQUAL(interaction, connectorProd.front()); + } + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testKeepStableSpectatorsFalseDropsSpectators() { + try { + GraphBuilder builder(5, 3); + + builder.setGenParticle(0, 1, 2, 100); + builder.setGenParticle(1, 23, 2, 101); + builder.setGenSimParticle(2, -13, 1, 102, 1002); + builder.setGenSimParticle(3, 13, 1, 103, 1003); + builder.setGenSimParticle(4, 211, 1, 104, 1004); // stable spectator + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenVertex(2, 202); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + builder.addProduction(2, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + config.keepStableSpectators = false; + + auto output = runPostProcessing(std::move(graph), config); + CPPUNIT_ASSERT(output.isConsistent()); + + // Spectator pion dropped; no UnderlyingEvent node. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 211)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countArtificialVerticesWithRole(output, truth::VertexRole::UnderlyingEvent)); + + // Focused subgraph: Z + two muons + one artificial connector, the Z hanging + // off an Upstream (ISR) node that descends from the Interaction node. + CPPUNIT_ASSERT_EQUAL(uint32_t(4), output.nParticles()); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countArtificialVerticesWithRole(output, truth::VertexRole::Upstream)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countArtificialVerticesWithRole(output, truth::VertexRole::Interaction)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSeedHadronFlavorSelectsBHadron() { + try { + GraphBuilder builder(5, 3); + + // b -> B0 -> D- mu+ ; unrelated stable pi+. + builder.setGenParticle(0, 5, 2, 100); // b quark (not a hadron) + builder.setGenParticle(1, 511, 2, 101); // B0 (b-hadron) + builder.setGenSimParticle(2, -411, 2, 102, 1002); // D- + builder.setGenSimParticle(3, -13, 1, 103, 1003); // mu+ + builder.setGenSimParticle(4, 211, 1, 104, 1004); // stable spectator + + builder.setGenVertex(0, 200); // b -> B0 + builder.setGenSimVertex(1, 201, 2001); // B0 -> D- mu+ + builder.setGenVertex(2, 202); // -> pi+ + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + builder.addProduction(2, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedHadronFlavors = {5}; // seed all b-hadrons + config.seedParentDepth = 0; + config.keepStableSpectators = false; + + auto output = runPostProcessing(std::move(graph), config); + CPPUNIT_ASSERT(output.isConsistent()); + + // The B0 (flavor-5 hadron) is the seed; its decay products are kept. + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 511)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -411)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -13)); + + // The bare b quark is NOT a hadron, so it is not a seed and is dropped at depth 0. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 5)); + // Spectator dropped (keepStableSpectators = false). + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 211)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testJetOriginLowestCommonAncestor() { + try { + GraphBuilder builder(5, 2); + + // ttbar-like: top -> W+ b ; b -> pi+ pi- (a b-jet's truth constituents). + builder.setGenParticle(0, 6, 2, 100); // top + builder.setGenParticle(1, 24, 2, 101); // W+ + builder.setGenParticle(2, 5, 2, 102); // b + builder.setGenSimParticle(3, 211, 1, 103, 1003); // pi+ + builder.setGenSimParticle(4, -211, 1, 104, 1004); // pi- + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addProduction(0, 2); + builder.addDecay(2, 1); + builder.addProduction(1, 3); + builder.addProduction(1, 4); + + auto graph = builder.finish(); + + // The b-jet constituents come from the b quark (closest common origin). + auto lcaB = graph.lowestCommonAncestor({graph.particle(3), graph.particle(4)}); + CPPUNIT_ASSERT(lcaB.has_value()); + CPPUNIT_ASSERT_EQUAL(int32_t(5), lcaB->pdgId()); + + // Walk up to the originating top. + auto top = graph.particle(3).firstAncestorWithPdgId(6); + CPPUNIT_ASSERT(top.has_value()); + CPPUNIT_ASSERT_EQUAL(int32_t(6), top->pdgId()); + + // Mixing constituents from the b and W sides yields the top as common origin. + auto lcaTop = graph.lowestCommonAncestor({graph.particle(3), graph.particle(4), graph.particle(1)}); + CPPUNIT_ASSERT(lcaTop.has_value()); + CPPUNIT_ASSERT_EQUAL(int32_t(6), lcaTop->pdgId()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testHitlessSimSubgraphsAreDropped() { + try { + // pi+ -> {gamma, n}, and n -> {p, nu}. Only the photon leaves a hit. + // + // V0 --> pi+(0) --V1--> gamma(1) [hit] + // \---> n(2) --V2--> p(3) [no hit] + // \--> nu(4) [GEN-only, no hit] + // + // The neutron's whole subgraph (n, p) is hitless, so it must be dropped + // together with its GEN-only daughter neutrino. The pi+ has no hit of its + // own but keeps a hit-bearing descendant (the photon), so it survives. + GraphBuilder builder(5, 3); + + builder.setSimParticle(0, 211, 1000); // pi+ + builder.setSimParticle(1, 22, 1001); // gamma (carries a hit) + builder.setSimParticle(2, 2112, 1002); // neutron + builder.setSimParticle(3, 2212, 1003); // proton + builder.setGenParticle(4, 12, 1, 104); // nu_e: GEN-only daughter of the neutron + + builder.setSimVertex(0, 2000); + builder.setSimVertex(1, 2001); + builder.setSimVertex(2, 2002); + + builder.addProduction(0, 0); // source vertex produces the pi+ + + builder.addDecay(0, 1); // pi+ decays at V1 + builder.addProduction(1, 1); // -> gamma + builder.addProduction(1, 2); // -> neutron + + builder.addDecay(2, 2); // neutron decays at V2 + builder.addProduction(2, 3); // -> proton + builder.addProduction(2, 4); // -> neutrino + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.dropHitlessSimSubgraphs = true; + + // particleDirectHit aligned to the input ids: only the photon (id 1). + const std::vector particleDirectHit = {0, 1, 0, 0, 0}; + + auto output = runPostProcessing(graph, config, particleDirectHit); + + CPPUNIT_ASSERT(output.isConsistent()); + + // pi+ (hitless itself, hit-bearing descendant) and the photon survive. + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 211)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 22)); + + // The hitless neutron subgraph, including the GEN-only neutrino, is gone. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 2112)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 2212)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 12)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), output.nParticles()); + + // The kept pi+ still points at the photon through its decay vertex, and the + // emptied neutron decay vertex has been dropped. + const uint32_t pion = findParticleWithPdgId(output, 211); + const uint32_t photon = findParticleWithPdgId(output, 22); + const auto decayVertices = output.decayVertices(pion); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), decayVertices.size()); + const auto outgoing = output.outgoingParticles(decayVertices.front()); + CPPUNIT_ASSERT_EQUAL(std::size_t(1), outgoing.size()); + CPPUNIT_ASSERT(std::find(outgoing.begin(), outgoing.end(), photon) != outgoing.end()); + + // Without a presence vector the pruning is a no-op: the full graph survives. + auto untouched = runPostProcessing(graph, config); + CPPUNIT_ASSERT_EQUAL(uint32_t(5), untouched.nParticles()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testAttachSelectionSourcesFalseRootsSeedsDirectly() { + try { + // q -> Z -> mu+ mu- ; plus an unrelated stable pi+ (underlying event). Same + // graph as the artificial-source test, but the selection is rooted directly + // at the seed: no upstream/underlying-event context and no artificial source. + GraphBuilder builder(5, 3); + + builder.setGenParticle(0, 1, 2, 100); // q + builder.setGenParticle(1, 23, 2, 101); // Z + builder.setGenSimParticle(2, -13, 1, 102, 1002); + builder.setGenSimParticle(3, 13, 1, 103, 1003); + builder.setGenSimParticle(4, 211, 1, 104, 1004); // stable spectator + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenVertex(2, 202); + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + builder.addProduction(2, 4); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + config.keepStableSpectators = false; + config.attachSelectionSources = false; + + auto output = runPostProcessing(std::move(graph), config); + CPPUNIT_ASSERT(output.isConsistent()); + + // No artificial source vertices at all: the Z is a true graph root. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countArtificialVerticesWithRole(output, truth::VertexRole::Upstream)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countArtificialVerticesWithRole(output, truth::VertexRole::UnderlyingEvent)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countArtificialVerticesWithRole(output, truth::VertexRole::Interaction)); + + // q dropped (depth 0), pi+ dropped (no spectators); only Z + the two muons remain. + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 1)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 211)); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 13) + countParticlesWithPdgId(output, -13)); + + // The seed has no production vertex: the subgraph starts directly at the Z. + const uint32_t z = findParticleWithPdgId(output, 23); + CPPUNIT_ASSERT(output.productionVertices(z).empty()); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testEventIdKeyingSplitsInteractions() { + try { + // Two q -> Z -> mu+ mu- chains tagged with different EncodedEventIds: one + // signal (eid 0), one pile-up (eid != 0). Each Z is a truncated root, so the + // per-interaction Interaction vertices are keyed by eventId and the two + // interactions stay separate - signal + one pile-up interaction. + GraphBuilder builder(8, 4); + + const uint64_t signalEid = 0; + const uint64_t pileupEid = 0x2a; // a distinct pile-up EncodedEventId + + builder.setGenParticle(0, 1, 2, 100); // q (signal) + builder.setGenParticle(1, 23, 2, 101); // Z (signal) + builder.setGenSimParticle(2, -13, 1, 102, 1002); + builder.setGenSimParticle(3, 13, 1, 103, 1003); + + builder.setGenParticle(4, 1, 2, 104); // q (pile-up) + builder.setGenParticle(5, 23, 2, 105); // Z (pile-up) + builder.setGenSimParticle(6, -13, 1, 106, 1006); + builder.setGenSimParticle(7, 13, 1, 107, 1007); + + // Tag the pile-up chain with its EncodedEventId. + for (const uint32_t i : {4u, 5u, 6u, 7u}) + builder.graph.particles()[i].eventId = pileupEid; + + builder.setGenVertex(0, 200); // q -> Z (signal, dropped at depth 0) + builder.setGenSimVertex(1, 201, 2001); // Z -> mu mu (signal) + builder.setGenVertex(2, 202); // q -> Z (pile-up, dropped at depth 0) + builder.setGenSimVertex(3, 203, 2003); // Z -> mu mu (pile-up) + + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + + builder.addDecay(4, 2); + builder.addProduction(2, 5); + builder.addDecay(5, 3); + builder.addProduction(3, 6); + builder.addProduction(3, 7); + + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 0; + config.keepStableSpectators = false; + + auto output = runPostProcessing(std::move(graph), config); + CPPUNIT_ASSERT(output.isConsistent()); + + // One Interaction (and one Upstream) vertex per interaction: signal + pile-up. + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countArtificialVerticesWithRole(output, truth::VertexRole::Interaction)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countArtificialVerticesWithRole(output, truth::VertexRole::Upstream)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countArtificialVerticesWithRole(output, truth::VertexRole::UnderlyingEvent)); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 23)); + + // The two Interaction vertices carry the two distinct EncodedEventIds. + bool sawSignal = false; + bool sawPileup = false; + for (auto const& vertex : output.vertices()) { + if (vertex.vertexRole() != truth::VertexRole::Interaction) + continue; + sawSignal = sawSignal || vertex.eventId == signalEid; + sawPileup = sawPileup || vertex.eventId == pileupEid; + } + CPPUNIT_ASSERT(sawSignal); + CPPUNIT_ASSERT(sawPileup); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testSignalOnlyAndBunchCrossingFilterDropPileup() { + try { + // Signal Z -> mu+ mu- (eid 0) plus an out-of-time pile-up Z -> mu+ mu- (bx 1). + // The pile-up filter is orthogonal to the seed selection, so it composes here + // with seedPdgIds = {23}. + const uint64_t pileupEid = EncodedEventId(1, 0).rawId(); // out-of-time pile-up (bunchCrossing 1) + + auto buildGraph = [&]() { + GraphBuilder builder(8, 4); + builder.setGenParticle(0, 1, 2, 100); // q (signal) + builder.setGenParticle(1, 23, 2, 101); // Z (signal) + builder.setGenSimParticle(2, -13, 1, 102, 1002); + builder.setGenSimParticle(3, 13, 1, 103, 1003); + builder.setGenParticle(4, 1, 2, 104); // q (pile-up) + builder.setGenParticle(5, 23, 2, 105); // Z (pile-up) + builder.setGenSimParticle(6, -13, 1, 106, 1006); + builder.setGenSimParticle(7, 13, 1, 107, 1007); + for (const uint32_t i : {4u, 5u, 6u, 7u}) + builder.graph.particles()[i].eventId = pileupEid; + + builder.setGenVertex(0, 200); + builder.setGenSimVertex(1, 201, 2001); + builder.setGenVertex(2, 202); + builder.setGenSimVertex(3, 203, 2003); + builder.addDecay(0, 0); + builder.addProduction(0, 1); + builder.addDecay(1, 1); + builder.addProduction(1, 2); + builder.addProduction(1, 3); + builder.addDecay(4, 2); + builder.addProduction(2, 5); + builder.addDecay(5, 3); + builder.addProduction(3, 6); + builder.addProduction(3, 7); + return builder.finish(); + }; + + // (a) No filter: both interactions are kept. + { + auto config = defaultConfig(); + config.seedPdgIds = {23}; + auto output = runPostProcessing(buildGraph(), config); + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT_EQUAL(uint32_t(2), countParticlesWithPdgId(output, 23)); + } + + // (b) signalOnly: only the signal Z survives, the pile-up is dropped. + { + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.signalOnly = true; + auto output = runPostProcessing(buildGraph(), config); + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + CPPUNIT_ASSERT_EQUAL(uint32_t(0), countParticlesWithPdgId(output, 1)); // the pile-up q is gone too + } + + // (c) keepBunchCrossings = {0}: the out-of-time (bx 1) pile-up is dropped. + { + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.keepBunchCrossings = {0}; + auto output = runPostProcessing(buildGraph(), config); + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); + } + + // (d) The pile-up filter is orthogonal to the seed selection and must work + // with NO seeds: signalOnly on the full graph keeps the signal interaction + // untouched and drops the pile-up one. (Regression: previously the filter was + // folded inside the seed selection, which short-circuits without seeds, so the + // pile-up Z survived.) + { + auto config = defaultConfig(); + config.signalOnly = true; + auto output = runPostProcessing(buildGraph(), config); + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 23)); // only the signal Z + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 1)); // signal q kept (full graph) + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, 13)); // signal mu- + CPPUNIT_ASSERT_EQUAL(uint32_t(1), countParticlesWithPdgId(output, -13)); // signal mu+ + } + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} + +void TestTruthLogicalGraphPostProcessor::testEveryParticleDescendsFromArtificialSource() { + try { + // beam source -> q qbar -> Z -> mu+ mu-, with stable underlying-event spectators + // (a photon and an electron) produced at the hard-interaction vertex. With a + // seed selection and attachSelectionSources, the post-processor must leave NO + // real (Normal) source vertex behind: every particle has to trace up to an + // artificial source (Interaction / Upstream / UnderlyingEvent), so nothing is + // left rooted at a real vertex. (A real source vertex surviving is what makes a + // cluster of particles look orphaned once such a vertex is hidden in the dump.) + GraphBuilder builder(7, 3); + builder.setGenParticle(0, 1, 21, 100); // q (incoming parton) + builder.setGenParticle(1, -1, 21, 101); // qbar (incoming parton) + builder.setGenParticle(2, 23, 22, 102); // Z + builder.setGenSimParticle(3, 13, 1, 103, 1003); // mu- + builder.setGenSimParticle(4, -13, 1, 104, 1004); // mu+ + builder.setGenSimParticle(5, 22, 1, 105, 1005); // underlying-event photon (stable spectator) + builder.setGenParticle(6, 11, 1, 106); // underlying-event electron (stable spectator) + + builder.setGenSimVertex(0, 200, 2000); // beam / source vertex (a real source: no incoming) + builder.setGenVertex(1, 201); // hard-interaction vertex + builder.setGenSimVertex(2, 202, 2002); // Z decay vertex + + builder.addProduction(0, 0); // beam -> q + builder.addProduction(0, 1); // beam -> qbar + builder.addDecay(0, 1); // q -> hard vertex + builder.addDecay(1, 1); // qbar -> hard vertex + builder.addProduction(1, 2); // hard -> Z + builder.addProduction(1, 5); // hard -> photon (spectator) + builder.addProduction(1, 6); // hard -> electron (spectator) + builder.addDecay(2, 2); // Z -> decay vertex + builder.addProduction(2, 3); // -> mu- + builder.addProduction(2, 4); // -> mu+ + auto graph = builder.finish(); + + auto config = defaultConfig(); + config.seedPdgIds = {23}; + config.seedParentDepth = 1; + config.keepProductionSiblings = true; + config.keepStableSpectators = true; + config.attachSelectionSources = true; + auto output = runPostProcessing(std::move(graph), config); + + CPPUNIT_ASSERT(output.isConsistent()); + CPPUNIT_ASSERT(hasArtificialVertex(output)); + + // No real (Normal) source vertex may survive: every source vertex is artificial. + for (uint32_t vertex = 0; vertex < output.nVertices(); ++vertex) { + if (output.incomingParticles(vertex).empty()) + CPPUNIT_ASSERT_MESSAGE("a real (Normal) source vertex survived the selection", + output.vertices()[vertex].isArtificial()); + } + + // And every particle traces up to an artificial source vertex. + for (uint32_t particle = 0; particle < output.nParticles(); ++particle) + CPPUNIT_ASSERT_MESSAGE("particle is not grounded on an artificial source", + descendsFromArtificialSource(output, particle)); + } catch (cms::Exception const& ex) { + std::cerr << ex.what() << std::endl; + CPPUNIT_ASSERT(false); + } +} diff --git a/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py b/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py new file mode 100644 index 0000000000000..c3b9d13753cbe --- /dev/null +++ b/PhysicsTools/TruthInfo/test/checkMixedTruthGraph_cfg.py @@ -0,0 +1,63 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# Phase-A pileup prototype: build the mixed (signal+pileup) raw TruthGraph from +# the MixingModule crossing frames, then the logical graph, then audit topology + +# the per-bunch-crossing (signal vs pileup) breakdown. Input must be a DIGI step +# produced with the SimTrack/SimVertex crossing frames enabled and kept. + +import FWCore.ParameterSet.Config as cms +from argparse import ArgumentParser + +parser = ArgumentParser() +parser.add_argument("inputFile", nargs='?', default="step2_cf.root", metavar='FILE') +parser.add_argument('-n', "--maxevts", type=int, default=3) +parser.add_argument("--rawsrc", default="truthGraphMixedProducer", + help="raw TruthGraph label: 'truthGraphMixedProducer' (Phase A) or 'mix' (accumulator)") +args = parser.parse_args() +if '/' not in args.inputFile and ':' not in args.inputFile: + args.inputFile = 'file:' + args.inputFile + +process = cms.Process("MIXTOPO") +process.load("FWCore.MessageService.MessageLogger_cfi") +process.maxEvents = cms.untracked.PSet(input=cms.untracked.int32(args.maxevts)) +process.source = cms.Source("PoolSource", fileNames=cms.untracked.vstring(args.inputFile)) +process.options = cms.untracked.PSet(wantSummary=cms.untracked.bool(False)) + +# The mixed raw graph (signal + pileup) is produced in the DIGI step from the +# transient crossing frames and read back here from the input file. + +# Logical graph from the mixed raw graph. SimTrack payload is signal-only +# (g4SimHits); pileup particles keep structure + EncodedEventId but no momentum. +process.truthLogicalGraphProducer = cms.EDProducer( + "TruthLogicalGraphProducer", + src=cms.InputTag(args.rawsrc), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + mergeGenSimVertices=cms.bool(True), + postProcessing=cms.PSet( + collapseIntermediateGenParticles=cms.bool(True), + seedPdgIds=cms.vint32(), + seedHadronFlavors=cms.vint32(), + seedParentDepth=cms.uint32(0), + keepStableSpectators=cms.bool(True), + decayPdgIdGroups=cms.VPSet(), + ignoredPdgIds=cms.vint32(), + ignoredParticleIds=cms.vuint32(), + ), +) + +process.topoChecker = cms.EDAnalyzer( + "TruthGraphTopologyChecker", + rawSrc=cms.InputTag(args.rawsrc), + src=cms.InputTag("truthLogicalGraphProducer"), +) + +process.MessageLogger.cerr.threshold = "INFO" +process.MessageLogger.cerr.default = cms.untracked.PSet(limit=cms.untracked.int32(0)) +process.MessageLogger.cerr.TruthGraphTopologyChecker = cms.untracked.PSet(limit=cms.untracked.int32(-1)) + +process.p = cms.Path(process.truthLogicalGraphProducer + process.topoChecker) diff --git a/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py b/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py new file mode 100644 index 0000000000000..4388812129a8c --- /dev/null +++ b/PhysicsTools/TruthInfo/test/checkTruthGraphTopology_cfg.py @@ -0,0 +1,69 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# Audits the raw TruthGraph and the logical truth::Graph for strange topologies +# (many-particle vertices, multi-parent / multi-production particles, cycles, +# disconnected components). Full graph, no seed selection. + +import FWCore.ParameterSet.Config as cms +from argparse import ArgumentParser + +parser = ArgumentParser() +parser.add_argument("inputFile", nargs='?', default="step3.root", metavar='FILE') +parser.add_argument('-n', "--maxevts", type=int, default=5) +# Knobs to isolate the cause of logical-graph pathologies. +parser.add_argument("--collapse", action="store_true", default=True) +parser.add_argument("--no-collapse", dest="collapse", action="store_false") +parser.add_argument("--merge-gensim", action="store_true", default=True) +parser.add_argument("--no-merge-gensim", dest="merge_gensim", action="store_false") +args = parser.parse_args() +if '/' not in args.inputFile and ':' not in args.inputFile: + args.inputFile = 'file:' + args.inputFile + +process = cms.Process("TOPOCHK") +process.load("FWCore.MessageService.MessageLogger_cfi") +process.maxEvents = cms.untracked.PSet(input=cms.untracked.int32(args.maxevts)) +process.source = cms.Source("PoolSource", fileNames=cms.untracked.vstring(args.inputFile)) +process.options = cms.untracked.PSet(wantSummary=cms.untracked.bool(False)) + +process.truthGraphProducer = cms.EDProducer( + "TruthGraphProducer", + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + addGenToSimEdges=cms.bool(True), +) + +process.truthLogicalGraphProducer = cms.EDProducer( + "TruthLogicalGraphProducer", + src=cms.InputTag("truthGraphProducer"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + mergeGenSimVertices=cms.bool(args.merge_gensim), + postProcessing=cms.PSet( + collapseIntermediateGenParticles=cms.bool(args.collapse), + seedPdgIds=cms.vint32(), # full graph, no selection + seedHadronFlavors=cms.vint32(), + seedParentDepth=cms.uint32(0), + keepStableSpectators=cms.bool(True), + decayPdgIdGroups=cms.VPSet(), + ignoredPdgIds=cms.vint32(), + ignoredParticleIds=cms.vuint32(), + ), +) + +process.topoChecker = cms.EDAnalyzer( + "TruthGraphTopologyChecker", + rawSrc=cms.InputTag("truthGraphProducer"), + src=cms.InputTag("truthLogicalGraphProducer"), +) + +process.MessageLogger.cerr.threshold = "INFO" +process.MessageLogger.cerr.default = cms.untracked.PSet(limit=cms.untracked.int32(0)) +process.MessageLogger.cerr.TruthGraphTopologyChecker = cms.untracked.PSet(limit=cms.untracked.int32(-1)) + +process.p = cms.Path(process.truthGraphProducer + process.truthLogicalGraphProducer + process.topoChecker) diff --git a/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py b/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py new file mode 100644 index 0000000000000..5f28d667a65f5 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/dumpTruthGraphsFromGENSIMRECO_cfg.py @@ -0,0 +1,347 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +import FWCore.ParameterSet.Config as cms + +# user options +import os +from argparse import ArgumentParser, BooleanOptionalAction +parser = ArgumentParser() +parser.add_argument("inputFile", nargs='?', default="step3.root", + metavar='FILE', help="Input file, default=%(default)r" ) +parser.add_argument('-o', "--outdir", default='', + help="output directory, default=%(default)r" ) +parser.add_argument('-n', "--maxevts", type=int, default=-1, + help="maximum number of events to process, default=%(default)s" ) +parser.add_argument('-m', "--merge", dest='mergeGenSim', action=BooleanOptionalAction, default=True, + help="merge GEN and SIM vertices (producer-level and by position)" ) +parser.add_argument('-c', "--collapse", action=BooleanOptionalAction, default=True, + help="collapse intermediate GenParticle copies" ) +parser.add_argument('-t', "--tag", default='', help="tag for out put file" ) +parser.add_argument('-s', "--seeds", default=None, + help="comma-separated seed PDG ids, e.g. '15,-15'; '0' keeps the full graph; " + "default=%(default)s uses the hardcoded list" ) +parser.add_argument('-g', "--groups", default=None, + help="semicolon-separated decay PDG id groups, e.g. '13,-14,16;-13,14,-16'" ) +parser.add_argument('-f', "--flavors", default=None, + help="comma-separated heavy-flavor quark ids to seed hadrons on, e.g. '5' for B hadrons, '4' for D" ) +parser.add_argument('-d', "--parentDepth", type=int, default=1, + help="ancestor generations kept above each root as context, default=%(default)s" ) +parser.add_argument('-i', "--ignore", default=None, + help="comma-separated PDG ids to remove from the final logical graph, e.g. '22'" ) +parser.add_argument("--keepSpectators", action=BooleanOptionalAction, default=True, + help="keep stable final-state spectators (underlying event) outside the selection; " + "use --no-keepSpectators for a focused subgraph" ) +parser.add_argument("--attachSources", action=BooleanOptionalAction, default=True, + help="attach selected roots to artificial Upstream/UnderlyingEvent source vertices; " + "use --no-attachSources to root each seed directly (e.g. ten taus -> ten subgraphs)" ) +parser.add_argument("--keepProductionSiblings", action=BooleanOptionalAction, default=False, + help="also keep the seed's hard-scatter co-products (the other outgoing particles of its " + "production vertex and their subtrees), e.g. the VBF tagging quarks/jets recoiling against " + "the Higgs - siblings of the seed that seedParentDepth never reaches" ) +parser.add_argument("--signal-only", dest='signalOnly', action=BooleanOptionalAction, default=False, + help="pile-up filter: keep only the signal interaction (EncodedEventId bx 0, event 0), dropping " + "all pile-up; orthogonal to the seed selection" ) +parser.add_argument("--bunch-crossings", dest='bunchCrossings', default=None, + help="pile-up filter: comma-separated bunch crossings to keep, e.g. '0' for in-time only " + "(default: keep all)" ) +parser.add_argument("--showAll", action='store_true', + help="do not hide zero-simhit subgraphs or large SIM source vertices in the logical DOT dump" ) +parser.add_argument("--layout", default="dot", + help="DOT layout for the logical-graph dump: 'dot' (default, hierarchical L->R ranks) " + "or a force-directed engine ('sfdp'/'fdp'/'neato') for node repulsion + spring edges" ) +args = parser.parse_args() + +def _parsePdgIds(text): + return [int(token) for token in text.replace(' ', '').split(',') if token] + +seedPdgIds = _parsePdgIds(args.seeds) if args.seeds is not None else [23, 15, -15, 25, 4, 5, 6] +decayPdgIdGroups = [_parsePdgIds(group) for group in args.groups.split(';')] if args.groups else [] +ignoredPdgIds = _parsePdgIds(args.ignore) if args.ignore else [] +seedHadronFlavors = _parsePdgIds(args.flavors) if args.flavors else [] +keepBunchCrossings = _parsePdgIds(args.bunchCrossings) if args.bunchCrossings else [] +if '/' not in args.inputFile and ':' not in args.inputFile: + args.inputFile = 'file:'+args.inputFile +if args.outdir and not os.path.exists(args.outdir): + os.makedirs(args.outdir, exist_ok=True) + +process = cms.Process("TRUTHGRAPH") + +process.load("FWCore.MessageService.MessageLogger_cfi") + +# Needed if TruthLogicalGraphHitIndexProducer does HGCal simId -> reco DetId relabelling. +# Keep this consistent with the geometry used to produce step3.root. +process.load("Configuration.Geometry.GeometryExtendedRun4D120Reco_cff") + +# Use the ideal tracker geometry so the tracker simhit table needs no alignment +# conditions (GlobalPositionRcd) when running standalone without a GlobalTag. +process.trackerGeometry.applyAlignment = cms.bool(False) + +process.maxEvents = cms.untracked.PSet( + input=cms.untracked.int32(args.maxevts) +) + +process.source = cms.Source( + "PoolSource", + fileNames=cms.untracked.vstring( + args.inputFile #"file:step3.root" + ) +) + +process.options = cms.untracked.PSet( + wantSummary=cms.untracked.bool(True) +) + +process.truthGraphProducer = cms.EDProducer( + "TruthGraphProducer", + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + addGenToSimEdges=cms.bool(True), +) + +process.truthGraphDumper = cms.EDAnalyzer( + "TruthGraphDumper", + src=cms.InputTag("truthGraphProducer"), + dotFile=cms.string(os.path.join(args.outdir,f"truthgraph{args.tag}.dot")), # output file + maxNodes=cms.uint32(20000), + maxEdgesPerNode=cms.uint32(50), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC=cms.InputTag("generatorSmeared"), + genEventHepMC3=cms.InputTag("generatorSmeared"), +) + +process.truthLogicalGraphProducer = cms.EDProducer( + "TruthLogicalGraphProducer", + src=cms.InputTag("truthGraphProducer"), + simTracks=cms.InputTag("g4SimHits"), + simVertices=cms.InputTag("g4SimHits"), + genEventHepMC3=cms.InputTag("generatorSmeared"), + genEventHepMC=cms.InputTag("generatorSmeared"), + + mergeGenSimVertices=cms.bool(args.mergeGenSim), + + postProcessing=cms.PSet( + collapseIntermediateGenParticles=cms.bool(args.collapse), + + # Empty means: keep the full logical graph. + # The most upstream particle of each matching chain becomes a root and + # its full downstream subgraph is kept; unselected upstream activity is + # collapsed into one artificial source vertex. + # The special value 0 disables the selection and keeps the full graph + # (debugging escape hatch). + seedPdgIds=cms.vint32(*seedPdgIds), + + # Seed on hadrons by heavy-flavor content (5=b, 4=c), OR-ed with + # seedPdgIds. E.g. -f 5 selects all B-hadron decay subgraphs. + seedHadronFlavors=cms.vint32(*seedHadronFlavors), + + # Ancestor generations kept above each root as context only: their + # other descendants are not pulled in. + seedParentDepth=cms.uint32(args.parentDepth), + + # Keep stable spectators (underlying event) on an artificial + # UnderlyingEvent vertex; --no-keepSpectators drops them for a focused + # subgraph (only the selection + its Upstream/ISR context). + keepStableSpectators=cms.bool(args.keepSpectators), + + # Root each selected seed directly (true graph roots) instead of + # attaching it to an artificial Upstream/UnderlyingEvent vertex. + # --no-attachSources gives one self-contained subgraph per seed. + attachSelectionSources=cms.bool(args.attachSources), + + # Also keep the seed's hard-scatter co-products (siblings at its + # production vertex and their subtrees), e.g. the VBF tagging quarks + # that become forward jets; --keepProductionSiblings to enable. + keepProductionSiblings=cms.bool(args.keepProductionSiblings), + + # Pile-up filter (orthogonal to the seed selection): --signal-only keeps + # only the signal interaction (EncodedEventId bx 0, event 0); a non-empty + # --bunch-crossings keeps only the listed bunch crossings (e.g. 0 = in-time). + signalOnly=cms.bool(args.signalOnly), + keepBunchCrossings=cms.vint32(*keepBunchCrossings), + + # Decay patterns of interest: unordered, charge-sensitive PDG id + # multisets, OR-ed. With seedPdgIds set, only roots whose effective + # decay products contain a group are kept, e.g. + # cms.PSet(pdgIds=cms.vint32(13, -13)) + # keeps Z -> mu mu but drops Z -> e e. Without seedPdgIds, or when the + # event contains no seed particle at all, vertices whose outgoing PDG + # ids contain a group are selected directly. + decayPdgIdGroups=cms.VPSet( + *[cms.PSet(pdgIds=cms.vint32(*group)) for group in decayPdgIdGroups] + ), + + # Remove particles by PDG id. + # Example: cms.vint32(22) removes all photons from the final logical graph. + ignoredPdgIds=cms.vint32(*ignoredPdgIds), + + # Remove particles by logical particle id. + # These ids refer to the graph after the previous postprocessing steps + # and before ignored-particle collapsing. + ignoredParticleIds=cms.vuint32(), + ), +) +process.detIdToRecHitMapProducer = cms.EDProducer( + "DetIdToRecHitMapProducer", + + hgcalRecHits=cms.VInputTag( + cms.InputTag("HGCalRecHit", "HGCEERecHits", "RECO"), + cms.InputTag("HGCalRecHit", "HGCHEFRecHits", "RECO"), + cms.InputTag("HGCalRecHit", "HGCHEBRecHits", "RECO"), + ), + + pfRecHits=cms.VInputTag( + cms.InputTag("particleFlowRecHitECAL", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHBHE", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHF", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHO", "Cleaned", "RECO"), + ), +) +process.truthLogicalGraphHitIndexProducer = cms.EDProducer( + "TruthLogicalGraphHitIndexProducer", + + src=cms.InputTag("truthLogicalGraphProducer"), + rawSrc=cms.InputTag("truthGraphProducer"), + + recHitMap=cms.InputTag("detIdToRecHitMapProducer"), + + simHitCollections=cms.VInputTag( + cms.InputTag("g4SimHits", "HGCHitsEE", "SIM"), + cms.InputTag("g4SimHits", "HGCHitsHEfront", "SIM"), + cms.InputTag("g4SimHits", "HGCHitsHEback", "SIM"), + cms.InputTag("g4SimHits", "EcalHitsEB", "SIM"), + cms.InputTag("g4SimHits", "HcalHits", "SIM"), + ), + + doHGCalRelabelling=cms.bool(False), +) + +process.truthLogicalGraphDumper = cms.EDAnalyzer( + "TruthLogicalGraphDumper", + src=cms.InputTag("truthLogicalGraphProducer"), + rawSrc=cms.InputTag("truthGraphProducer"), + hitIndex=cms.InputTag("truthLogicalGraphHitIndexProducer"), + + hgcalRecHits=cms.VInputTag( + cms.InputTag("HGCalRecHit", "HGCEERecHits", "RECO"), + cms.InputTag("HGCalRecHit", "HGCHEFRecHits", "RECO"), + cms.InputTag("HGCalRecHit", "HGCHEBRecHits", "RECO"), + ), + + pfRecHits=cms.VInputTag( + cms.InputTag("particleFlowRecHitECAL", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHBHE", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHF", "Cleaned", "RECO"), + cms.InputTag("particleFlowRecHitHO", "Cleaned", "RECO"), + ), + + dotFile=cms.string(os.path.join(args.outdir,f"truthlogicalgraph{args.tag}.dot")), # output file + + layout=cms.string(args.layout), + + maxParticles=cms.uint32(20000), + maxVertices=cms.uint32(20000), + # --showAll lifts the per-node edge cap: with large events the artificial + # source vertex legitimately has more than 300 outgoing spectators. + maxEdgesPerNode=cms.uint32(1000000 if args.showAll else 300), + + hideLargeSimSourceVertices=cms.bool(not args.showAll), + largeSimSourceVertexMinOutgoing=cms.uint32(50), + + hideZeroSimHitSubgraphs=cms.bool(not args.showAll), +) + + +process.load("PhysicsTools.TruthInfo.recHitTable_cfi") + +# Barrel/forward calorimeter PFRecHits as a separate NanoAOD collection. +# HGCal rechits stay in recHitTable above. NOTE: the offline (RECO) PFRecHit +# collections are empty in this sample, so the HLT-tier PFRecHits (which contain +# the hits) are used here. +process.pfRecHitTable = cms.EDProducer( + "PFRecHitFlatTableProducer", + objName=cms.string("pfrechits"), + label_rechits=cms.VInputTag( + cms.InputTag("hltParticleFlowRecHitECALUnseeded", "", "HLT"), + cms.InputTag("hltParticleFlowRecHitHBHE", "", "HLT"), + cms.InputTag("hltParticleFlowRecHitHF", "", "HLT"), + cms.InputTag("hltParticleFlowRecHitHO", "", "HLT"), + ), +) + +# Tracker PSimHits as a separate NanoAOD collection. +process.trackerSimHitTable = cms.EDProducer( + "TrackerSimHitFlatTableProducer", + objName=cms.string("trackersimhits"), + label_simhits=cms.VInputTag( + cms.InputTag("g4SimHits", "TrackerHitsPixelBarrelLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsPixelBarrelHighTof"), + cms.InputTag("g4SimHits", "TrackerHitsPixelEndcapLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsPixelEndcapHighTof"), + cms.InputTag("g4SimHits", "TrackerHitsTIBLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsTIBHighTof"), + cms.InputTag("g4SimHits", "TrackerHitsTIDLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsTIDHighTof"), + cms.InputTag("g4SimHits", "TrackerHitsTOBLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsTOBHighTof"), + cms.InputTag("g4SimHits", "TrackerHitsTECLowTof"), + cms.InputTag("g4SimHits", "TrackerHitsTECHighTof"), + ), +) +# process.load('Configuration.EventContent.EventContent_cff') +process.NANOAODSIMoutput = cms.OutputModule("NanoAODOutputModule", + compressionAlgorithm = cms.untracked.string('ZLIB'), + compressionLevel = cms.untracked.int32(9), + dataset = cms.untracked.PSet( + dataTier = cms.untracked.string('NANOAODSIM'), + filterName = cms.untracked.string('') + ), + fileName = cms.untracked.string(os.path.join(args.outdir,f"rechits_nano{args.tag}.root")), + outputCommands = cms.untracked.vstring( + 'drop *', + 'keep nanoaodFlatTable_*Table_*_*', + # 'keep edmTriggerResults_*_*_*', + 'keep String_*_genModel_*', + 'keep nanoaodMergeableCounterTable_*Table_*_*', + 'keep nanoaodUniqueString_nanoMetadata_*_*', + 'keep nanoaodFlatTable_*Table*_*_*' +) +) + +process.MessageLogger.cerr.threshold = "INFO" +process.MessageLogger.cerr.default = cms.untracked.PSet( + limit=cms.untracked.int32(0) +) +process.MessageLogger.cerr.TruthGraphProducer = cms.untracked.PSet( + limit=cms.untracked.int32(-1) +) +process.MessageLogger.cerr.TruthLogicalGraphProducer = cms.untracked.PSet( + limit=cms.untracked.int32(-1) +) +process.MessageLogger.cerr.TruthLogicalGraphPostProcessor = cms.untracked.PSet( + limit=cms.untracked.int32(-1) +) +process.MessageLogger.cerr.TruthLogicalGraphHitIndexProducer = cms.untracked.PSet( + limit=cms.untracked.int32(-1) +) +process.MessageLogger.cerr.DetIdToRecHitMapProducer = cms.untracked.PSet( + limit=cms.untracked.int32(-1) +) + +process.truthGraph_step = cms.Path( + process.truthGraphProducer + + process.truthGraphDumper + + process.truthLogicalGraphProducer + + process.detIdToRecHitMapProducer + + process.truthLogicalGraphHitIndexProducer + + process.truthLogicalGraphDumper + + process.recHitTable + + process.pfRecHitTable + + process.trackerSimHitTable +) + +process.nano_step = cms.EndPath(process.NANOAODSIMoutput) diff --git a/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py b/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py new file mode 100644 index 0000000000000..700f5b5c46b1f --- /dev/null +++ b/PhysicsTools/TruthInfo/test/harvestBranchDQM_cfg.py @@ -0,0 +1,36 @@ +# Original author: Felice Pantaleo (CERN) +# Part of the MC-truth-graph prototype - under heavy development, not yet open +# to external contributions (see PhysicsTools/TruthInfo/README.md). + +# Harvests a Branch-DQM file (output of validateBranchDQM_cfg.py) into efficiency +# plots via the truthGraphDQMHarvesting sequence (DQMGenericClient). + +import FWCore.ParameterSet.Config as cms +from argparse import ArgumentParser + +parser = ArgumentParser() +parser.add_argument("inputFile", nargs='?', default="branch_dqm.root", metavar='FILE') +parser.add_argument('-o', "--out", default="branch_dqm_harvested.root") +args = parser.parse_args() +if '/' not in args.inputFile and ':' not in args.inputFile: + args.inputFile = 'file:' + args.inputFile + +process = cms.Process("BRANCHHARVEST") +process.load("FWCore.MessageService.MessageLogger_cfi") +process.load("DQMServices.Core.DQMStore_cfi") +process.load("PhysicsTools.TruthInfo.truthGraphDQMHarvester_cff") + +process.load("DQMServices.Components.DQMEnvironment_cfi") + +process.maxEvents = cms.untracked.PSet(input=cms.untracked.int32(-1)) +process.source = cms.Source("DQMRootSource", fileNames=cms.untracked.vstring(args.inputFile)) +process.options = cms.untracked.PSet(wantSummary=cms.untracked.bool(False), numberOfThreads=cms.untracked.uint32(1)) + +# Standard harvesting output: a legacy DQM_V0001_R*.root (TDirectory/TH1) written +# to the current directory under the workflow path below. +process.dqmSaver.convention = "Offline" +process.dqmSaver.workflow = "/Branch/Validation/HARVEST" +process.dqmSaver.saveByRun = cms.untracked.int32(1) + +process.p = cms.Path(process.truthGraphDQMHarvesting) +process.e = cms.EndPath(process.dqmSaver) diff --git a/PhysicsTools/TruthInfo/test/makeBranchValidationPlots.sh b/PhysicsTools/TruthInfo/test/makeBranchValidationPlots.sh new file mode 100755 index 0000000000000..720c03bd15666 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/makeBranchValidationPlots.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Render the truth-Branch DQM validation plots for the enableTruth relval +# library, overlaying a few representative samples in one set of PNGs+index.html. +# It feeds the per-sample harvested legacy DQM file produced by step4 of each +# workflow (DQM_V0001_R*__Global__*__RECO.root) into makeTruthGraphValidationPlots.py, +# which derives the efficiency / fake-rate / self-match ratios and overlays the +# booked quality distributions (purity, completeness, response, n_sharing_branches, +# best-matched-Branch metrics). +# +# Requires cmsenv. Usage: +# cmsenv +# makeBranchValidationPlots.sh [LIBRARY_DIR] [OUTPUT_DIR] +# LIBRARY_DIR dir containing .88_*/DQM_V0001_*__RECO.root (default ./library) +# OUTPUT_DIR plots output dir (default ./branch_validation_plots) +# Env knobs: SAMPLES (override the "label:workflow" overlay list). +# +set -uo pipefail +: "${CMSSW_BASE:?run cmsenv first}" + +LIB="${1:-$PWD/library}" +OUT="${2:-$PWD/branch_validation_plots}" +PLOTTER="$CMSSW_BASE/src/PhysicsTools/TruthInfo/scripts/makeTruthGraphValidationPlots.py" + +# label : workflow-number. Diverse topologies: hadronic (TTbar), dense multi-tau +# gun (TenTau, where the calo n_sharing tail is richest) and clean dimuon (ZMM). +SAMPLES_DEFAULT=("TTbar:34034" "TenTau:34087" "ZMM:34050") +read -r -a SAMPLES <<< "${SAMPLES:-${SAMPLES_DEFAULT[*]}}" + +args=() +for s in "${SAMPLES[@]}"; do + IFS=: read -r lab num <<< "$s" + dqm=$(ls "$LIB"/${num}.88_*/DQM_V0001_R*__Global__*__RECO.root 2>/dev/null | head -1) + if [[ -z "$dqm" ]]; then echo "SKIP $lab ($num): no harvested DQM under $LIB"; continue; fi + args+=("${dqm}:${lab}") +done + +[[ ${#args[@]} -eq 0 ]] && { echo "No harvested DQM files found under $LIB"; exit 1; } + +rm -rf "$OUT"; mkdir -p "$OUT" +echo "Plotting [${args[*]##*:}] into $OUT" +python3 "$PLOTTER" "${args[@]}" -o "$OUT" +echo "Branch validation plots written to $OUT (open $OUT/index.html)" diff --git a/PhysicsTools/TruthInfo/test/makeTruthGallery.sh b/PhysicsTools/TruthInfo/test/makeTruthGallery.sh new file mode 100755 index 0000000000000..926b1d4aaab32 --- /dev/null +++ b/PhysicsTools/TruthInfo/test/makeTruthGallery.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# +# Build a DOT/SVG gallery of logical truth graphs for the enableTruth relval +# samples, one folder per physics process. For each sample it dumps: +#