Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 KiB
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
- Repository layout
- Build
- Running
- ROOT output format
- Calorimeter materials
- Docker
- Geant4 modifications
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 (~30–60 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 |
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/geant4submodule (batch mode, no vis, no multithreading) - The
minicaloPython 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.