Files
minicalosim/README.md
T
lars 0adc896bce add post-step momentum direction to Steps ntuple
Pre-step direction was already recorded; post_dx/dy/dz lets students
compare momentum direction before and after each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:40:05 +02:00

11 KiB
Raw Blame History

miniCaloSim

A Geant4-based calorimeter simulator for teaching and research. Supports arbitrary sampling or homogeneous calorimeter geometries, runs batches of particle showers, and writes ROOT output for analysis.


Contents


Overview

miniCaloSim wraps the standard Geant4 B4a example into a configurable geometry driven by a GeometryDescriptor object. A geometry is a stack of layers; each layer is a slab of named NIST material with an optional sensor grid that accumulates deposited energy. The primary particle type and energy are configurable at run time.

The default executable run_pbwo4 simulates electron showers in a 20 cm PbWO₄ crystal with a 10×10 sensor grid and writes a ROOT file.


Repository layout

run_pbwo4.cc          main executable entry point
src/                  Geant4 application sources
  ActionInitialization.cc
  DetectorConstruction.cc   reads GeometryDescriptor, builds Geant4 volumes
  EventAction.cc            fills the Hits ntuple at end of each event
  SteppingAction.cc         fills Steps and Spawning ntuples per step
  RunAction.cc              creates all three ROOT ntuples
  G4System.cc               owns the Geant4 run manager lifetime
  GeometryDescriptor.cc     geometry data model (layers + sensors)
  PrimaryGeneratorAction.cc
include/              corresponding headers
bind/                 Python bindings (pybind11 + pure-Python helpers)
  bindings.cpp        exposes Sensor, Layer, GeometryDescriptor, G4System
  G4Calo.py           high-level Python API (run_batch, display_event)
  G4Calo_exec.py      worker subprocess entry point
  minicalo_tools.py   TempFileManager for subprocess IPC via /dev/shm
  minipandas.py       MiniFrame: minimal NumPy-backed dataframe
lib/
  geant4/             Geant4 source submodule (fork with modifications)
  pybind11/           pybind11 submodule
superbuild/           CMake superbuild to compile Geant4 from source
docker/               Dockerfile-mini
docs/                 design documents

Build

Option A — system Geant4

Requires a Geant4 ≥ 11 installation visible to CMake (e.g. via Geant4_DIR or CMAKE_PREFIX_PATH), plus pybind11 (submodule) and Python 3 development headers.

cmake -B build -S .
cmake --build build --parallel $(nproc)
# executable: build/run_pbwo4

Option B — build Geant4 from submodule

The superbuild compiles Geant4 from lib/geant4 into build/geant4-install, then builds minicalosim against it. Three targets are available:

cmake -B build -S superbuild/            # configure once

cmake --build build --target geant4      # compile Geant4 (~3060 min)
cmake --build build --target run_pbwo4   # compile minicalosim
cmake --build build                      # both

After the build, the executable is at build/run_pbwo4 (symlink in the superbuild case).

Options:

CMake option Default Meaning
GEANT4_INSTALL_DATA ON Download Geant4 physics data tables (~2 GB)

The superbuild caps parallelism at min(nproc, 16) to avoid memory exhaustion.


Running

# default: 10 events, writes pbwo4_10events_hits.root
build/run_pbwo4

# custom number of events
build/run_pbwo4 500

Output file name: pbwo4_<nEvents>events_hits.root.

The geometry is hardcoded in run_pbwo4.cc: a single 20 cm PbWO₄ layer with a 10×10 sensor grid, bombarded with 1 GeV electrons.


ROOT output format

Each run produces a ROOT file with three TTrees.

Hits tree

One row per simulated event. Contains integrated quantities over the whole calorimeter.

Column Type Description
true_energy double Primary particle energy [MeV]
total_dep_energy double Total energy deposited in all sensors [MeV]
N_layers int Number of layers
N_active_layers int Number of active (sensitive) layers
N_sensors int Total number of sensors
sensor_energy[N] double[] Deposited energy per sensor [MeV]
sensor_x/y/z[N] double[] Sensor centre position [mm]
sensor_dx/dy/dz[N] double[] Sensor half-size [mm]
sensor_layer[N] int[] Layer index of each sensor
sensor_copy_number[N] int[] Copy number within its layer

Steps tree

One row per Geant4 step. Every step of every track of every event is recorded.

Column Type Description
event_id int Event index within the run
track_id int Track ID (1 = primary, ≥2 = secondaries)
step_no int Step number within this track (starts at 1)
pdg int PDG encoding of the particle
pre_x/y/z double Pre-step position [mm]
pre_E double Kinetic energy at step start [MeV]
post_x/y/z double Post-step position [mm]
post_E double Kinetic energy at step end [MeV]
edep double Energy deposited in medium at this step [MeV]
step_length double Geometric step length [mm]
process string Physics process that ended this step
layer_id int Layer index at the pre-step point (-1 = outside all layers)
material string Material name at the pre-step point
pre_dx/dy/dz double Pre-step momentum unit direction
post_dx/dy/dz double Post-step momentum unit direction
Bx/By/Bz double Magnetic field at pre-step position [T]
Ex/Ey/Ez double Electric field at pre-step position [V/m]
child_track_ids int[] Track IDs of all secondaries spawned during this step (empty if none)

Track IDs are assigned sequentially per event starting from 1. The primary particle is always track 1. Within an event, track_id uniquely identifies a particle. See also the Spawning tree.

Spawning tree

One row per secondary particle birth. Enables direct shower-tree reconstruction without position joins.

Column Type Description
event_id int Event index
parent_track_id int Track ID of the parent particle
parent_step_no int Step number of the parent at which this secondary was created
child_track_id int Track ID of the newly born secondary

Example join: to find all secondaries born at step 5 of track 3 in event 0:

spawning[(spawning.event_id == 0) &
         (spawning.parent_track_id == 3) &
         (spawning.parent_step_no == 5)]

The same information is available in the Steps tree via the child_track_ids array column on the parent's row.


Calorimeter materials

A few material choices available via NIST names:

Scintillators (active layers)

NIST name Density Notes
G4_POLYSTYRENE 1.06 g/cm³ cheap plastic scintillator
G4_PLASTIC_SC_VINYLTOLUENE 1.032 g/cm³ plastic scintillator
G4_BGO 7.13 g/cm³ inorganic crystal, ~0.8 CHF/cm³
G4_LSO 7.4 g/cm³ inorganic crystal, ~6 CHF/cm³
G4_LYSO 7.1 g/cm³ inorganic crystal, ~30 CHF/cm³

Absorbers (passive layers)

NIST name Density X₀ Notes
G4_Pb 11.35 g/cm³ 0.56 cm standard EM calorimeter absorber
G4_Fe 7.87 g/cm³ hadronic calorimeter absorber
G4_W 19.3 g/cm³ 0.35 cm compact high-resolution EM
G4_Cu 8.96 g/cm³ 1.43 cm EM calorimeter absorber
G4_BRASS 8.53 g/cm³ 1.46 cm 70% Cu / 30% Zn

Homogeneous calorimeters

NIST name Density X₀ Notes
G4_CESIUM_IODIDE 4.51 g/cm³ ~1.9 cm inorganic crystal
G4_PbWO4 8.28 g/cm³ 0.89 cm lead tungstate (CMS ECAL)

Docker

docker/Dockerfile-mini builds a minimal Ubuntu 22.04 image with:

  • Geant4 compiled from the lib/geant4 submodule (batch mode, no vis, no multithreading)
  • The minicalo Python package installed system-wide
  • Python dependencies: numpy, pandas, uproot, awkward, plotly

Build (from the repo root, passing COMMIT to pin the source):

docker build \
  --build-arg COMMIT=$(git rev-parse HEAD) \
  --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
  -f docker/Dockerfile-mini \
  -t minicalosim:latest \
  --build-context minicalosim=. \
  .

Geant4 modifications

The lib/geant4 submodule is a fork of Geant4 with three small changes to enable pre-assigning track IDs to secondary particles at the moment they are created, so that UserSteppingAction can record which child track IDs a step spawned (the child_track_ids column and the Spawning tree).

Background

In unmodified Geant4, track IDs are assigned in G4EventManager::StackTracks(), which is called only after the parent track has finished all its steps. At the time UserSteppingAction fires, the secondaries visible via G4Step::GetSecondaryInCurrentStep() have not yet received their IDs.

Changes

All changes are on the minicalosim-spawning branch of git@gitlab.etp.kit.edu:lbogner/geant4.git.

source/tracking/include/G4SteppingManager.hh

Adds a G4int* fTrackIDCounter member and a SetTrackIDCounter(G4int*) setter. The pointer is set by G4EventManager (via G4TrackingManager) at the start of each event and points directly into G4EventManager::trackIDCounter.

source/tracking/include/G4TrackingManager.hh

Adds a SetTrackIDCounter(G4int*) pass-through that forwards the pointer into G4SteppingManager.

source/tracking/src/G4SteppingManager.cc

In ProcessSecondariesFromParticleChange(), after SetParentID and SetCreatorProcess, immediately assigns a track ID to each secondary by incrementing through the pointer:

if (fTrackIDCounter) {
    tempSecondaryTrack->SetTrackID(++(*fTrackIDCounter));
}

source/event/src/G4EventManager.cc

  • Calls trackManager->SetTrackIDCounter(&trackIDCounter) once before the event's tracking loop.
  • StackTracks() is updated to skip counter increment and ID assignment for tracks that already have a non-zero ID (i.e. were pre-assigned above):
if (IDhasAlreadySet) {
    ++trackIDCounter;          // advance for primaries (ID set externally)
} else if (newTrack->GetTrackID() == 0) {
    ++trackIDCounter;          // normal secondary: assign as before
    newTrack->SetTrackID(trackIDCounter);
    ...
}
// else: pre-assigned secondary — counter already incremented, skip

Why not include G4EventManager.hh in G4SteppingManager.cc?

G4event already depends on G4tracking (G4EventManager uses G4TrackingManager), so including G4EventManager.hh in the tracking module would create a circular dependency. The pointer approach avoids any new include: G4SteppingManager only needs a G4int*, which requires no additional header.