Skip to content

Commit 82ecba9

Browse files
anthoak13claude
andauthored
Add Bethe Bloch energy loss model (ATTPC#259)
* Add CLAUDE.md file * Add AtELossBetheBloch analytic energy loss model Implements the Bethe-Bloch stopping power formula as a new AtELossModel subclass supporting heavy charged particles (PDG 2022, Eq. 34.1) and electrons (Leo 1994, Eq. 2.38). A tk::spline cache of dx/dE enables O(log n) range integrals. Newton's method is used for GetEnergy, matching the AtELossTable strategy. Straggling uses the Bohr approximation. Low-energy breakdown of the formula is handled by extrapolating dEdx ∝ sqrt(E) below the validity threshold. Includes 8 unit tests (proton/alpha/pion/electron in H2 and Ar, self-consistency, straggling sanity, Bloch approximation) and a ROOT macro to plot proton stopping power in Al for visual comparison against the PDG reference curve. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add more robust pion tests * Fix AtELossBetheBloch setters to rebuild spline automatically SetMaterial, SetI, and SetDensity all change physical parameters that the cached dx/dE spline depends on, but previously left it stale. SetMaterial now calls BuildSpline at the end (removing the redundant explicit call from the constructor). SetI is moved from an inline header one-liner to a proper definition that rebuilds the spline. SetDensity overrides the base class virtual and rebuilds the spline after updating fDensity. Adds SettersRebuildSpline test to verify each setter takes effect immediately without a manual BuildSpline call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add record of Claude log for Bethe Impl * Move level of debug statement * Fix clang-formatting --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7ff3909 commit 82ecba9

8 files changed

Lines changed: 1953 additions & 0 deletions

File tree

.claude/2026-03-03-131255-implement-beth-bloch.txt

Lines changed: 1257 additions & 0 deletions
Large diffs are not rendered by default.

.claude/CLAUDE.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## About
6+
7+
ATTPCROOT is a ROOT/FairRoot-based C++ framework for simulation and analysis of Active Target Time Projection Chamber (AT-TPC) detector data. It integrates with FairSoft (provides ROOT, Geant4, VMC) and FairRoot (provides the task/run framework).
8+
9+
## Environment Setup
10+
11+
Before building or running anything, the environment must be loaded. The VSCode terminal auto-sources this on startup:
12+
13+
```bash
14+
source build/config.sh
15+
```
16+
17+
This sets `LD_LIBRARY_PATH`, `ROOTSYS`, `VMCWORKDIR`, `ROOT_INCLUDE_PATH`, and Geant4 data paths. The key install paths on this machine are:
18+
- FairRoot: `~/fair_install/FairRootInstall`
19+
- FairSoft: `~/fair_install/FairSoftInstall`
20+
21+
For CMake configuration (not the build itself), the following env vars are needed:
22+
```bash
23+
export FAIRROOTPATH=~/fair_install/FairRootInstall
24+
export SIMPATH=~/fair_install/FairSoftInstall
25+
```
26+
27+
## Build Commands
28+
29+
```bash
30+
# Configure (from repo root, out-of-source into build/)
31+
cmake -S . -B build -DCMAKE_PREFIX_PATH=~/fair_install/hdf5
32+
33+
# Build (10 parallel jobs)
34+
cmake --build build -j10
35+
36+
# Build a specific target
37+
cmake --build build --target AtSimulationData -j10
38+
39+
# Run all unit tests
40+
cd build && ctest -V
41+
42+
# Run a specific test binary directly
43+
./build/tests/AtSimulationDataTests
44+
./build/tests/AtGeneratorsTests
45+
./build/tests/AtToolsTests
46+
```
47+
48+
Tests are built by default (`BUILD_TESTS=ON`). Test binaries are placed in `build/tests/`.
49+
50+
## Code Formatting
51+
52+
The project uses `clang-format-17` with the config in `.clang-format` (based on LLVM style, 3-space indent, 120-char column limit). Format on save is configured in VSCode. To format manually:
53+
54+
```bash
55+
clang-format-17 -i <file>
56+
# Or format all changed files:
57+
scripts/formatAll.sh
58+
```
59+
60+
Static analysis uses `clang-tidy` (config in `.clang-tidy`): modernize-* and cppcoreguidelines-* checks.
61+
62+
## Architecture
63+
64+
The framework follows FairRoot's task pipeline pattern: data flows through a chain of `FairTask` subclasses, persisted as ROOT TClonesArrays in a TTree.
65+
66+
### Module Overview
67+
68+
| Module | Purpose |
69+
|--------|---------|
70+
| `AtSimulationData` | MC truth data: `AtStack`, `AtMCTrack`, `AtMCPoint`, `AtVertexPropagator` |
71+
| `AtGenerators` | FairGenerator subclasses for beam/reaction/decay event generation |
72+
| `AtDetectors` | Geant4 sensitive detector implementations (AT-TPC, GADGET-II, SpecMAT, etc.) |
73+
| `AtDigitization` | Converts MC points → simulated pad signals (`AtClusterize`, `AtPulse`) |
74+
| `AtUnpack` | Unpacks raw experimental data (GET/GRAW, HDF5, ROOT formats) |
75+
| `AtData` | Core data classes: `AtRawEvent`, `AtEvent`, `AtHit`, `AtPad`, `AtTrack` |
76+
| `AtMap` | Pad mapping between electronics channels and detector geometry |
77+
| `AtParameter` | FairRuntimeDb parameter containers |
78+
| `AtReconstruction` | PSA, pattern recognition, fitting tasks |
79+
| `AtTools` | Utilities: energy loss (CATIMA), kinematics, space charge, hit sampling |
80+
| `AtAnalysis` | High-level analysis tasks and `AtRunAna` |
81+
| `AtS800` | S800 spectrograph data handling |
82+
| `AtEventDisplay` | ROOT Eve-based event display |
83+
84+
### Data Flow
85+
86+
**Simulation pipeline** (macros in `macro/Simulation/`):
87+
1. `FairPrimaryGenerator` with an `AtReactionGenerator` subclass → particle stack
88+
2. Geant4/VMC transport → `AtMCPoint` hits in sensitive volumes
89+
3. `AtClusterizeTask` → electron clusters from ionization
90+
4. `AtPulseTask` → simulated pad signals (`AtRawEvent`)
91+
92+
**Reconstruction pipeline** (macros in `macro/Unpack_*/` and `macro/Analysis/`):
93+
1. `AtUnpackTask``AtRawEvent` (raw pad traces)
94+
2. `AtFilterTask` → filtered `AtRawEvent`
95+
3. `AtPSAtask` (Pulse Shape Analysis) → `AtEvent` with `AtHit` objects
96+
4. `AtSampleConsensusTask` / `AtPRAtask``AtPatternEvent` with `AtTrack` objects
97+
5. `AtMCFitterTask` / `AtFitterTask` → fitted tracks with kinematics
98+
99+
### Key Design Patterns
100+
101+
**FairTask subclasses**: Each processing step is a `FairTask`. They retrieve input branches via `TClonesArray*` from `FairRootManager` in `Init()` and process them in `Exec()`.
102+
103+
**AtReactionGenerator**: Abstract base for all reaction generators. Subclasses implement `GenerateReaction()`. The `ReadEvent()` method is `final` and handles the beam/reaction event alternation via `AtVertexPropagator`. Generators can be chained for sequential decays using `SetSequentialDecay(true)`.
104+
105+
**AtVertexPropagator**: Singleton (`AtVertexPropagator::Instance()`) that communicates vertex, momentum, and kinematics between chained generators and downstream tasks. Alternates beam/reaction events via `EndEvent()`. Use `ResetForTesting()` in unit tests.
106+
107+
**PSA (Pulse Shape Analysis)**: `AtPSA` is the abstract base. Concrete implementations (`AtPSAMax`, `AtPSAFull`, `AtPSADeconv`, etc.) extract hits from pad traces. `AtPSAComposite` allows chaining PSA methods.
108+
109+
**Energy loss**: `AtELossModel` is the base; `AtELossCATIMA` wraps the CATIMA library (fetched automatically if not installed). Used via `AtELossManager`.
110+
111+
### Adding a New Module Library
112+
113+
Each module's `CMakeLists.txt` follows this pattern:
114+
```cmake
115+
set(LIBRARY_NAME MyModule)
116+
set(SRCS file1.cxx file2.cxx)
117+
set(DEPENDENCIES ATTPCROOT::AtData ROOT::Core ...)
118+
set(TEST_SRCS MyModuleTest.cxx)
119+
attpcroot_generate_tests(${LIBRARY_NAME}Tests SRCS ${TEST_SRCS} DEPS ${LIBRARY_NAME})
120+
generate_target_and_root_library(${LIBRARY_NAME} LINKDEF ${LIBRARY_NAME}LinkDef.h SRCS ${SRCS} DEPS_PUBLIC ${DEPENDENCIES})
121+
```
122+
123+
ROOT dictionary generation requires a `*LinkDef.h` file listing every class that needs ROOT reflection. Every class with `ClassDef` in its header must appear in the LinkDef.
124+
125+
**LinkDef streamer suffix** — the suffix after the class name controls whether ROOT generates a full I/O streamer:
126+
127+
- `ClassName +;` — generates a full streamer. Use this **only** for classes that are written to a ROOT file (i.e. stored as a branch in a `TClonesArray` or `TTree`). Examples: `AtHit`, `AtEvent`, `AtRawEvent`, `AtMCPoint`.
128+
- `ClassName -!;` — registers the class for reflection (usable in interpreted macros, usable as a pointer type) but **suppresses the streamer**. Use this for every class that is never written to disk: tasks, algorithms, models, samplers, etc. Examples: `AtELossModel`, `AtSpaceChargeModel`, `AtSample` subclasses.
129+
130+
Generating an unnecessary streamer bloats the dictionary and binary with unused I/O code, so the default should be `-!` unless disk persistence is actually required.
131+
132+
**C++ vs ROOT typedefs** — use plain C++ types (`bool`, `int`, `double`, `std::string`) in all classes that are not written to a ROOT file. Only use ROOT's portability typedefs (`Bool_t`, `Int_t`, `Double_t`, etc.) in classes that are persisted to disk, where ROOT's cross-platform I/O guarantees matter. Mixing ROOT types into purely algorithmic or task classes is unnecessary overhead.
133+
134+
### Unit Tests
135+
136+
Tests use Google Test, placed alongside source files (e.g., `AtVertexPropagatorTest.cxx` in `AtSimulationData/`). Register them in the module's `CMakeLists.txt` via `attpcroot_generate_tests()`. Tests must not access external files or network resources.
137+
138+
### Macros
139+
140+
ROOT macros (`.C` files) in `macro/` are the primary user interface for running simulations and analyses. They are not compiled into libraries—they run interpreted by ROOT. Integration tests live in `macro/tests/`.
141+
142+
## Contributing
143+
144+
- PRs must target the `develop` branch and apply cleanly (fast-forward, no merge commits).
145+
- Commit messages: present imperative mood, ≤72 characters summary.
146+
- All PRs are checked by `clang-format`, `clang-tidy`, and the unit test suite.

AtTools/AtELossBetheBloch.cxx

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
#include "AtELossBetheBloch.h"
2+
3+
#include <FairLogger.h>
4+
5+
#include <cmath>
6+
#include <vector>
7+
8+
namespace AtTools {
9+
10+
AtELossBetheBloch::AtELossBetheBloch(double part_q, double part_mass, int mat_Z, int mat_A, double density, double I_eV)
11+
: AtELossModel(density), fPart_q(part_q), fPart_mass(part_mass)
12+
{
13+
SetMaterial(mat_Z, mat_A, density, I_eV);
14+
}
15+
16+
void AtELossBetheBloch::SetMaterial(int mat_Z, int mat_A, double density, double I_eV)
17+
{
18+
fMat_Z = mat_Z;
19+
fMat_A = mat_A;
20+
fDensity = density;
21+
if (I_eV <= 0)
22+
fI_MeV = 13.5 * mat_Z * 1e-6; // Bloch approximation: I ≈ 13.5·Z eV
23+
else
24+
fI_MeV = I_eV * 1e-6;
25+
BuildSpline();
26+
}
27+
28+
void AtELossBetheBloch::SetI(double I_eV)
29+
{
30+
fI_MeV = I_eV * 1e-6;
31+
BuildSpline();
32+
}
33+
34+
void AtELossBetheBloch::SetDensity(double density)
35+
{
36+
fDensity = density;
37+
BuildSpline();
38+
}
39+
40+
void AtELossBetheBloch::BuildSpline(double E_min_MeV, double E_max_MeV, int nPoints)
41+
{
42+
std::vector<double> energies(nPoints);
43+
std::vector<double> dedxValues(nPoints);
44+
45+
double logMin = std::log(E_min_MeV);
46+
double logMax = std::log(E_max_MeV);
47+
48+
for (int i = 0; i < nPoints; ++i) {
49+
double t = static_cast<double>(i) / (nPoints - 1);
50+
energies[i] = std::exp(logMin + t * (logMax - logMin));
51+
dedxValues[i] = GetdEdx_formula(energies[i]);
52+
}
53+
54+
// The Bethe-Bloch formula breaks down at low energies where the log argument drops below 1.
55+
// Find the lowest-energy valid grid point, then extrapolate below it using dEdx ∝ sqrt(E),
56+
// consistent with the low-energy Lindhard stopping power behavior.
57+
int firstValid = -1;
58+
for (int i = 0; i < nPoints; ++i) {
59+
if (dedxValues[i] > 0) {
60+
firstValid = i;
61+
break;
62+
}
63+
}
64+
65+
if (firstValid < 0) {
66+
LOG(error) << "Bethe-Bloch formula returned zero for all energy grid points!";
67+
return;
68+
}
69+
if (firstValid > 0) {
70+
LOG(debug) << "Bethe-Bloch formula invalid below " << energies[firstValid]
71+
<< " MeV; extrapolating with dEdx ∝ sqrt(E).";
72+
for (int i = 0; i < firstValid; ++i)
73+
dedxValues[i] = dedxValues[firstValid] * std::sqrt(energies[i] / energies[firstValid]);
74+
}
75+
76+
std::vector<double> dXdE(nPoints);
77+
for (int i = 0; i < nPoints; ++i)
78+
dXdE[i] = (dedxValues[i] > 0) ? 1.0 / dedxValues[i] : 1e10;
79+
80+
fdXdE = tk::spline(energies, dXdE);
81+
}
82+
83+
double AtELossBetheBloch::GetdEdx_formula(double energy) const
84+
{
85+
if (IsElectron())
86+
return GetdEdx_electron(energy);
87+
return GetdEdx_heavy(energy);
88+
}
89+
90+
double AtELossBetheBloch::GetdEdx_heavy(double energy) const
91+
{
92+
if (energy <= 0)
93+
return 0;
94+
95+
double M = fPart_mass;
96+
double gamma = 1.0 + energy / M;
97+
double beta2 = 1.0 - 1.0 / (gamma * gamma);
98+
99+
if (beta2 <= 0)
100+
return 0;
101+
102+
// Maximum kinetic energy transferable in a single collision (PDG 2022, Eq. 34.2)
103+
double Tmax = 2.0 * kM_e * beta2 * gamma * gamma / (1.0 + 2.0 * gamma * kM_e / M + (kM_e / M) * (kM_e / M));
104+
105+
// Argument of logarithm: 2mₑc²β²γ²Tmax / I²
106+
double logArg = 2.0 * kM_e * beta2 * gamma * gamma * Tmax / (fI_MeV * fI_MeV);
107+
if (logArg <= 0)
108+
return 0;
109+
110+
double z = fPart_q;
111+
// Standard Bethe-Bloch in MeV/cm (PDG 2022, Eq. 34.1), density correction neglected
112+
double dedx_cm =
113+
kK * z * z * (static_cast<double>(fMat_Z) / fMat_A) * fDensity / beta2 * (0.5 * std::log(logArg) - beta2);
114+
115+
if (dedx_cm <= 0)
116+
return 0;
117+
118+
return dedx_cm / 10.0; // Convert MeV/cm → MeV/mm
119+
}
120+
121+
double AtELossBetheBloch::GetdEdx_electron(double energy) const
122+
{
123+
if (energy <= 0)
124+
return 0;
125+
126+
double tau = energy / kM_e;
127+
double beta2 = tau * (tau + 2.0) / ((tau + 1.0) * (tau + 1.0));
128+
129+
if (beta2 <= 0)
130+
return 0;
131+
132+
// Møller exchange correction (Leo 1994, Eq. 2.38)
133+
double Fminus = (1.0 + tau * tau / 8.0 - (2.0 * tau + 1.0) * std::log(2.0)) / ((tau + 1.0) * (tau + 1.0));
134+
135+
// Argument of logarithm: τ√(τ+2)·mₑc² / (√2·I)
136+
double logArg = tau * std::sqrt(tau + 2.0) * kM_e / (std::sqrt(2.0) * fI_MeV);
137+
if (logArg <= 0)
138+
return 0;
139+
140+
// Modified Bethe formula for electrons in MeV/cm
141+
double dedx_cm = kK * (static_cast<double>(fMat_Z) / fMat_A) * fDensity / beta2 * (std::log(logArg) + Fminus);
142+
143+
if (dedx_cm <= 0)
144+
return 0;
145+
146+
return dedx_cm / 10.0; // Convert MeV/cm → MeV/mm
147+
}
148+
149+
double AtELossBetheBloch::GetdEdx(double energy) const
150+
{
151+
return 1.0 / fdXdE(energy);
152+
}
153+
154+
double AtELossBetheBloch::GetRange(double energyIni, double energyFin) const
155+
{
156+
if (energyIni == energyFin)
157+
return 0;
158+
if (energyFin < fdXdE.get_x_min()) {
159+
LOG(debug) << "Attempting to integrate energy to " << energyFin << " when min energy in table is "
160+
<< fdXdE.get_x_min();
161+
energyFin = fdXdE.get_x_min();
162+
}
163+
return fdXdE.integrate(energyFin, energyIni);
164+
}
165+
166+
double AtELossBetheBloch::GetEnergyLoss(double energyIni, double distance) const
167+
{
168+
return energyIni - GetEnergy(energyIni, distance);
169+
}
170+
171+
double AtELossBetheBloch::GetEnergy(double energyIni, double distance) const
172+
{
173+
if (distance == 0)
174+
return energyIni;
175+
if (energyIni < 1e-6 || GetRange(energyIni) < distance)
176+
return 0.0;
177+
178+
const int maxIt = 100;
179+
const double distErr = 1e-4; // mm
180+
181+
double guessEnergy = energyIni - GetdEdx(energyIni) * distance;
182+
for (int i = 0; i < maxIt; ++i) {
183+
double range = GetRange(energyIni, guessEnergy);
184+
if (std::fabs(range - distance) < distErr) {
185+
LOG(debug) << "Energy converged in " << i + 1 << " iterations.";
186+
return guessEnergy;
187+
}
188+
guessEnergy += GetdEdx(guessEnergy) * (range - distance);
189+
if (guessEnergy < fdXdE.get_x_min() * 1.01) {
190+
guessEnergy = fdXdE.get_x_min();
191+
return guessEnergy;
192+
}
193+
}
194+
195+
LOG(error) << "Energy calculation (" << energyIni << " MeV through " << distance << " mm) failed to converge in "
196+
<< maxIt << " iterations!";
197+
return -1;
198+
}
199+
200+
double AtELossBetheBloch::GetElossStraggling(double energyIni, double energyFin) const
201+
{
202+
double dx_mm = GetRange(energyIni, energyFin);
203+
if (dx_mm <= 0)
204+
return 0;
205+
206+
// Bohr approximation: σ² = K·z²·(Z/A)·ρ·Δx[cm]·mₑc²
207+
double z = IsElectron() ? 1.0 : fPart_q;
208+
double dx_cm = dx_mm * 0.1;
209+
double sigma2 = kK * z * z * (static_cast<double>(fMat_Z) / fMat_A) * fDensity * dx_cm * kM_e;
210+
return std::sqrt(sigma2);
211+
}
212+
213+
double AtELossBetheBloch::GetdEdxStraggling(double energyIni, double energyFin) const
214+
{
215+
double dx_mm = GetRange(energyIni, energyFin);
216+
if (dx_mm <= 0)
217+
return 0;
218+
return GetElossStraggling(energyIni, energyFin) / dx_mm;
219+
}
220+
221+
} // namespace AtTools

0 commit comments

Comments
 (0)