diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 138117faa..65ab3554b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,24 +4,9 @@ ATTPCROOT is a ROOT/FairRoot-based C++ framework for simulation and analysis of ## Documentation -Full developer documentation lives in `docs/`. See [docs/index.md](../docs/index.md) for the full map. Quick topic links: - -| Topic | File | -|-------|------| -| First-time install | [tooling/installation.md](../docs/tooling/installation.md) | -| Daily use (build/test) | [tooling/daily-use.md](../docs/tooling/daily-use.md) | -| Testing patterns | [tooling/testing.md](../docs/tooling/testing.md) | -| Contributor guide | [contributing/guide.md](../docs/contributing/guide.md) | -| Adding a new module | [contributing/new-module.md](../docs/contributing/new-module.md) | -| Code style | [contributing/code-style.md](../docs/contributing/code-style.md) | -| Module overview | [reference/modules.md](../docs/reference/modules.md) | -| Data model | [reference/data-model.md](../docs/reference/data-model.md) | -| Branch I/O contracts | [reference/branch-io-contracts.md](../docs/reference/branch-io-contracts.md) | -| Simulation pipeline | [subsystems/simulation-pipeline.md](../docs/subsystems/simulation-pipeline.md) | -| Reconstruction pipeline | [subsystems/reconstruction-pipeline.md](../docs/subsystems/reconstruction-pipeline.md) | -| Event generators | [subsystems/generators.md](../docs/subsystems/generators.md) | -| Pulse shape analysis | [subsystems/psa.md](../docs/subsystems/psa.md) | -| Energy loss | [subsystems/energy-loss.md](../docs/subsystems/energy-loss.md) | +Full developer documentation lives in `docs/`. Before reading source files or writing code, `ls docs/` and read `docs/index.md` to orient yourself — it is the canonical map of all available docs. + +Macros in the `macros/` folder are useful for understanding how the code is used in practice, but are often stale and should not be treated as authoritative specification. ## Quick Reference: Build & Test @@ -29,6 +14,8 @@ Full developer documentation lives in `docs/`. See [docs/index.md](../docs/index source build/config.sh # load environment (do this first) cmake --build build -j10 # build everything cd build && ctest -V # run all unit tests +cd build && ctest -R TestName -V # run a single test by name +./build/tests/AtToolsTests # run a test binary directly ``` ## Code-Writing Rules @@ -53,8 +40,13 @@ Default to `-!` unless disk persistence is actually required. Unit tests must not access external files or network resources. Hardcode test data inline. +Register tests in CMakeLists.txt with: +```cmake +attpcroot_generate_tests(${LIBRARY_NAME}Tests SRCS test_foo.cxx DEPS SomeLib) +``` + ## Contributing -- PRs target the `develop` branch; fast-forward only (no merge commits). +- Feature branches off `develop`; PRs target `develop`; fast-forward only (no merge commits). - Commit messages: present imperative mood, ≤72 characters. -- All PRs must pass `clang-format`, `clang-tidy`, and unit tests. +- All PRs must pass `clang-format-17` (3-space indent, 120-char line limit), `clang-tidy`, and unit tests. diff --git a/.gitignore b/.gitignore index 081c538ef..96a8d423d 100755 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ data *#* event.dat pulser-files.txt +.codex +.cache/* # Executables *.exe diff --git a/AGENTS.md b/AGENTS.md index 3b59135d5..908c14329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,17 @@ ATTPCROOT is a ROOT/FairRoot-based C++ framework for simulation and analysis of Active Target Time Projection Chamber (AT-TPC) detector data. +Think critically about the physics of any change you make. When producing macros or tests, verify the physics looks like you expect not just that it runs without any errors. You are a scientific collaborator who explores ideas with a sense of independence. + + + ## Documentation -Full developer documentation lives in `docs/`. See [docs/index.md](docs/index.md) for the full map. Quick topic links: +Full developer documentation lives in `docs/`. See [docs/index.md](docs/index.md) for the full map. + +Before reading any files or writing any code `ls` the `docs` folder and read any relevant documentation, always reading the index. Use this to guide any necessary implementation choices. Critically, macros in the `macros` folder are likely to be out dated and not to be trusted as a source unless mentioned in the documentation. + +Quick topic links: | Topic | File | |-------|------| diff --git a/AtDetectors/AtTpc/AtTpc.cxx b/AtDetectors/AtTpc/AtTpc.cxx index f711a1a10..cad5005be 100644 --- a/AtDetectors/AtTpc/AtTpc.cxx +++ b/AtDetectors/AtTpc/AtTpc.cxx @@ -60,22 +60,17 @@ void AtTpc::Initialize() rtdb->getContainer("AtTpcGeoPar"); } -void AtTpc::trackEnteringVolume() +void AtTpc::trackEnteringVolume(const StepState &step) { - auto AZ = DecodePdG(gMC->TrackPid()); - fELoss = 0.; + // Reset the accumulator before capturing this step's energy loss, so the + // entering step contributes fELoss (not fELoss + whatever was left over). fELossAcc = 0.; - fTime = gMC->TrackTime() * 1.0e09; - fLength = gMC->TrackLength(); - gMC->TrackPosition(fPosIn); - gMC->TrackMomentum(fMomIn); - fTrackID = gMC->GetStack()->GetCurrentTrackNumber(); - - // Position of the first hit of the beam in the TPC volume ( For tracking purposes in the TPC) - if (fTrackID == 0 && (fVolName == "drift_volume" || fVolName == "cell")) + getTrackParametersFromStep(step); + + if (fTrackID == 0 && IsActiveGasVolume(fVolName)) InPos = fPosIn; - Int_t VolumeID = 0; + auto AZ = DecodePdG(step.pdg); if (fTrackID == 0) LOG(debug) << cGREEN << " AtTPC: Beam Event "; @@ -83,46 +78,44 @@ void AtTpc::trackEnteringVolume() LOG(debug) << cBLUE << " AtTPC: Reaction/Decay Event "; LOG(debug) << " AtTPC: First hit in Volume " << fVolName; - LOG(debug) << " Particle : " << gMC->ParticleName(gMC->TrackPid()); - LOG(debug) << " PID PdG : " << gMC->TrackPid(); + LOG(debug) << " PID PdG : " << step.pdg; LOG(debug) << " Atomic Mass : " << AZ.first; LOG(debug) << " Atomic Number : " << AZ.second; - LOG(debug) << " Volume ID " << gMC->CurrentVolID(VolumeID); + LOG(debug) << " Volume ID " << step.volumeID; LOG(debug) << " Track ID : " << fTrackID; LOG(debug) << " Position : " << fPosIn.X() << " " << fPosIn.Y() << " " << fPosIn.Z(); LOG(debug) << " Momentum : " << fMomIn.X() << " " << fMomIn.Y() << " " << fMomIn.Z(); - LOG(debug) << " Total relativistic energy " << gMC->Etot(); + LOG(debug) << " Total relativistic energy " << step.totalEnergy; LOG(debug) << " Mass of the Beam particle (gAVTP) : " << AtVertexPropagator::Instance()->GetBeamMass(); - LOG(debug) << " Mass of the Tracked particle (gMC) : " << gMC->TrackMass(); // NB: with electrons + LOG(debug) << " Mass of the Tracked particle (transport) : " << step.trackMass; // NB: with electrons LOG(debug) << " Initial energy of the beam particle in this volume : " - << ((gMC->Etot() - AtVertexPropagator::Instance()->GetBeamMass() * 0.93149401) * + << ((step.totalEnergy - AtVertexPropagator::Instance()->GetBeamMass() * 0.93149401) * 1000.); // Relativistic Mass LOG(debug) << " Total energy of the current track (gMC) : " - << ((gMC->Etot() - gMC->TrackMass()) * 1000.); // Relativistic Mass + << ((step.totalEnergy - step.trackMass) * 1000.); // Relativistic Mass LOG(debug) << " ==================================================== " << cNORMAL; } -void AtTpc::getTrackParametersFromMC() +void AtTpc::getTrackParametersFromStep(const StepState &step) { - fELoss = gMC->Edep(); + fELoss = step.energyLoss; fELossAcc += fELoss; - fTime = gMC->TrackTime() * 1.0e09; - fLength = gMC->TrackLength(); - gMC->TrackPosition(fPosIn); - gMC->TrackMomentum(fMomIn); - fTrackID = gMC->GetStack()->GetCurrentTrackNumber(); + fTime = step.timeNs; + fLength = step.trackLength; + fPosIn = step.pos; + fMomIn = step.mom; + fTrackID = step.trackID; } -void AtTpc::getTrackParametersWhileExiting() +void AtTpc::getTrackParametersWhileExiting(const StepState &step) { - fTrackID = gMC->GetStack()->GetCurrentTrackNumber(); - gMC->TrackPosition(fPosOut); - gMC->TrackMomentum(fMomOut); + fTrackID = step.trackID; + fPosOut = step.posOut; + fMomOut = step.momOut; - // Correct fPosOut - if (gMC->IsTrackExiting()) { + if (step.exiting) { correctPosOut(); - if ((fVolName.Contains("drift_volume") || fVolName.Contains("cell")) && fTrackID == 0) + if (IsActiveGasVolume(fVolName) && fTrackID == 0) resetVertex(); } } @@ -135,6 +128,9 @@ void AtTpc::resetVertex() void AtTpc::correctPosOut() { + if (gGeoManager == nullptr) + return; // No geometry (e.g. unit tests); caller's fPosOut is already final. + const Double_t *oldpos = nullptr; const Double_t *olddirection = nullptr; Double_t newpos[3]; @@ -166,51 +162,84 @@ bool AtTpc::reactionOccursHere() { bool atEnergyLoss = fELossAcc * 1000 > AtVertexPropagator::Instance()->GetRndELoss(); bool isPrimaryBeam = fTrackID == 0; - bool isInRightVolume = fVolName.Contains("drift_volume") || fVolName.Contains("cell"); + bool isInRightVolume = IsActiveGasVolume(fVolName); return atEnergyLoss && isPrimaryBeam && isInRightVolume; } + Bool_t AtTpc::ProcessHits(FairVolume *vol) { /** This method is called from the MC stepping */ auto *stack = dynamic_cast(gMC->GetStack()); - fVolName = gMC->CurrentVolName(); - fVolumeID = vol->getMCid(); - fDetCopyID = vol->getCopyNo(); - - if (gMC->IsTrackEntering()) - trackEnteringVolume(); - - getTrackParametersFromMC(); - - if (gMC->IsTrackExiting() || gMC->IsTrackStop() || gMC->IsTrackDisappeared()) - getTrackParametersWhileExiting(); - - addHit(); + StepState step; + step.trackID = gMC->GetStack()->GetCurrentTrackNumber(); + step.pdg = gMC->TrackPid(); + step.volumeName = gMC->CurrentVolName(); + step.volumeID = vol->getMCid(); + step.detCopyID = vol->getCopyNo(); + step.entering = gMC->IsTrackEntering(); + step.exiting = gMC->IsTrackExiting(); + step.stopping = gMC->IsTrackStop(); + step.disappeared = gMC->IsTrackDisappeared(); + step.energyLoss = gMC->Edep(); + step.timeNs = gMC->TrackTime() * 1.0e09; + step.trackLength = gMC->TrackLength(); + step.totalEnergy = gMC->Etot(); + step.trackMass = gMC->TrackMass(); + gMC->TrackPosition(step.pos); + gMC->TrackMomentum(step.mom); + + if (step.exiting || step.stopping || step.disappeared) { + gMC->TrackPosition(step.posOut); + gMC->TrackMomentum(step.momOut); + } - // Reaction Occurs here - if (reactionOccursHere()) - startReactionEvent(); + bool stopTrack = ProcessStep(step); + if (stopTrack) + gMC->StopTrack(); // Increment number of AtTpc det points in TParticle stack->AddPoint(kAtTpc); return kTRUE; } -void AtTpc::startReactionEvent() +bool AtTpc::ProcessStep(const StepState &step) { + fVolName = step.volumeName; + fVolumeID = step.volumeID; + fDetCopyID = step.detCopyID; + + if (step.entering) + trackEnteringVolume(step); + else + getTrackParametersFromStep(step); + + if (step.exiting || step.stopping || step.disappeared) + getTrackParametersWhileExiting(step); + + addHit(step); + + if (reactionOccursHere()) { + startReactionEvent(step); + return true; + } - gMC->StopTrack(); + return false; +} + +void AtTpc::startReactionEvent(const StepState &step) +{ AtVertexPropagator::Instance()->ResetVertex(); - TLorentzVector StopPos; - TLorentzVector StopMom; - gMC->TrackPosition(StopPos); - gMC->TrackMomentum(StopMom); - Double_t StopEnergy = ((gMC->Etot() - AtVertexPropagator::Instance()->GetBeamMass() * 0.93149401) * 1000.); + const TLorentzVector &StopPos = step.pos; + const TLorentzVector &StopMom = step.mom; + Double_t StopEnergy = ((step.totalEnergy - AtVertexPropagator::Instance()->GetBeamMass() * 0.93149401) * 1000.); + + LOG(info) << "AtTpc: triggering reaction handoff at z=" << StopPos.Z() << " cm with accumulated loss " + << fELossAcc * 1000. << " MeV and residual energy " << StopEnergy << " MeV"; LOG(debug) << cYELLOW << " Beam energy loss before reaction : " << fELossAcc * 1000; - LOG(debug) << " Mass of the Tracked particle : " << gMC->TrackMass(); + LOG(debug) << " Mass of the Tracked particle : " << step.trackMass; LOG(debug) << " Mass of the Beam particle (gAVTP) : " << AtVertexPropagator::Instance()->GetBeamMass(); LOG(debug) << " Total energy of the Beam particle before reaction : " << StopEnergy << cNORMAL; // Relativistic Mass @@ -218,9 +247,9 @@ void AtTpc::startReactionEvent() StopMom.Px(), StopMom.Py(), StopMom.Pz(), StopEnergy); } -void AtTpc::addHit() +void AtTpc::addHit(const StepState &step) { - auto AZ = DecodePdG(gMC->TrackPid()); + auto AZ = DecodePdG(step.pdg); Double_t EIni = 0; Double_t AIni = 0; @@ -239,6 +268,11 @@ void AtTpc::addHit() TVector3(fMomIn.Px(), fMomIn.Py(), fMomIn.Pz()), fTime, fLength, fELoss, EIni, AIni, AZ.first, AZ.second); } +bool AtTpc::IsActiveGasVolume(const TString &volumeName) const +{ + return volumeName.Contains("drift_volume") || volumeName.Contains("cell"); +} + void AtTpc::EndOfEvent() { @@ -286,21 +320,11 @@ void AtTpc::ConstructGeometry() Bool_t AtTpc::CheckIfSensitive(std::string name) { - - TString tsname = name; - if (tsname.Contains("drift_volume") || tsname.Contains("window") || tsname.Contains("cell")) { - LOG(info) << " AtTPC geometry: Sensitive volume found: " << tsname; - return kTRUE; - } - return kFALSE; -} - -AtMCPoint * -AtTpc::AddHit(Int_t trackID, Int_t detID, TVector3 pos, TVector3 mom, Double_t time, Double_t length, Double_t eLoss) -{ - TClonesArray &clref = *fAtTpcPointCollection; - Int_t size = clref.GetEntriesFast(); - return new (clref[size]) AtMCPoint(trackID, detID, pos, mom, time, length, eLoss); + const bool sensitive = name.find("drift_volume") != std::string::npos || name.find("window") != std::string::npos || + name.find("cell") != std::string::npos; + if (sensitive) + LOG(debug) << " AtTPC geometry: Sensitive volume found: " << name; + return sensitive ? kTRUE : kFALSE; } // ----- Private method AddHit -------------------------------------------- diff --git a/AtDetectors/AtTpc/AtTpc.h b/AtDetectors/AtTpc/AtTpc.h index 842c372a4..03698d861 100644 --- a/AtDetectors/AtTpc/AtTpc.h +++ b/AtDetectors/AtTpc/AtTpc.h @@ -27,6 +27,39 @@ class TList; class TMemberInspector; class AtTpc : public FairDetector { +public: + /** + * Transport-neutral snapshot of a single step through an AtTpc volume. + * + * Populated by ProcessHits() from gMC (Geant4 path) or by AtSimTransportTask + * from its callback-based transport engine (SimpleSim path). Consumed by + * ProcessStep() — the shared entering/exiting/accumulate/react pipeline. + * + * Units follow FairRoot conventions: cm for position/length, GeV for energy, + * ns for time. Beam identification is by trackID == 0 (unchanged from the + * original Geant4 convention). + */ + struct StepState { + int trackID = -1; + int pdg = 0; + TString volumeName; + int volumeID = -1; + int detCopyID = -1; + bool entering = false; + bool exiting = false; + bool stopping = false; + bool disappeared = false; + double energyLoss = 0.0; // GeV + double timeNs = 0.0; // ns + double trackLength = 0.0; // cm + double totalEnergy = 0.0; // GeV + double trackMass = 0.0; // GeV/c^2 + TLorentzVector pos; + TLorentzVector mom; + TLorentzVector posOut; + TLorentzVector momOut; + }; + private: /** Track information to be stored until the track leaves the active volume. @@ -81,23 +114,37 @@ class AtTpc : public FairDetector { virtual void ConstructGeometry() override; virtual Bool_t CheckIfSensitive(std::string name) override; - AtMCPoint * - AddHit(Int_t trackID, Int_t detID, TVector3 pos, TVector3 mom, Double_t time, Double_t length, Double_t eLoss); - AtMCPoint *AddHit(Int_t trackID, Int_t detID, TString VolName, Int_t detCopyID, TVector3 pos, TVector3 mom, Double_t time, Double_t length, Double_t eLoss, Double_t EIni, Double_t AIni, Int_t A, Int_t Z); + /** + * Process a detector step from a transport-neutral snapshot. + * + * Expected call sequence per volume traversal: + * 1. One step with entering=true — resets fELossAcc to 0 and captures entry position/momentum. + * 2. Zero or more steps with entering=false, exiting=false — accumulate energy loss in fELossAcc. + * 3. One step with exiting=true (or stopping/disappeared) — captures exit position/momentum + * and may trigger resetVertex() for beam tracks leaving an active gas volume. + * + * Two entering=true steps without an intervening exiting=true step will silently reset + * the accumulated energy loss, discarding data from the first volume traversal. + * + * Returns true when the transport should stop at this step (reaction fired). + */ + bool ProcessStep(const StepState &step); + private: std::pair DecodePdG(Int_t PdG_Code); - void trackEnteringVolume(); - void getTrackParametersFromMC(); - void getTrackParametersWhileExiting(); + void trackEnteringVolume(const StepState &step); + void getTrackParametersFromStep(const StepState &step); + void getTrackParametersWhileExiting(const StepState &step); void correctPosOut(); void resetVertex(); - void addHit(); + void addHit(const StepState &step); bool reactionOccursHere(); - void startReactionEvent(); + void startReactionEvent(const StepState &step); + bool IsActiveGasVolume(const TString &volumeName) const; AtTpc(const AtTpc &); AtTpc &operator=(const AtTpc &); diff --git a/AtDetectors/AtTpc/AtTpcTest.cxx b/AtDetectors/AtTpc/AtTpcTest.cxx new file mode 100644 index 000000000..7269861bd --- /dev/null +++ b/AtDetectors/AtTpc/AtTpcTest.cxx @@ -0,0 +1,132 @@ +#include "AtTpc.h" + +#include "AtMCPoint.h" +#include "AtVertexPropagator.h" + +#include + +#include + +namespace { +AtTpc::StepState +MakeStep(int trackID, int pdg, const char *volumeName, double eLossGeV, double totalEnergyGeV, double zCm) +{ + AtTpc::StepState step; + step.trackID = trackID; + step.pdg = pdg; + step.volumeName = volumeName; + step.volumeID = 1; + step.detCopyID = 0; + step.energyLoss = eLossGeV; + step.trackLength = zCm; + step.totalEnergy = totalEnergyGeV; + step.trackMass = 0.938272; + step.pos.SetXYZT(0.0, 0.0, zCm, 0.0); + step.mom.SetXYZT(0.0, 0.0, 0.04, totalEnergyGeV); + step.posOut = step.pos; + step.momOut = step.mom; + return step; +} +} // namespace + +class AtTpcTest : public ::testing::Test { +protected: + AtTpc detector; + + void SetUp() override + { + AtVertexPropagator::Instance()->ResetForTesting(); + AtVertexPropagator::Instance()->SetBeamMass(1.007276); + AtVertexPropagator::Instance()->ResetVertex(); + detector.Reset(); + } + + void TearDown() override { AtVertexPropagator::Instance()->ResetForTesting(); } +}; + +TEST_F(AtTpcTest, ReactionTriggerPopulatesVertexPropagator) +{ + AtVertexPropagator::Instance()->SetRndELoss(0.5); + + auto step = MakeStep(0, 2212, "drift_volume", 0.0006, 0.98, 12.0); + step.entering = true; + + const bool stopTransport = detector.ProcessStep(step); + + EXPECT_TRUE(stopTransport); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVx(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVy(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 12.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetInVz(), 12.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 0.04); + + const double expectedEnergy = (0.98 - 1.007276 * 0.93149401) * 1000.0; + EXPECT_NEAR(AtVertexPropagator::Instance()->GetEnergy(), expectedEnergy, 1e-6); +} + +TEST_F(AtTpcTest, BeamExitResetsVertexState) +{ + AtVertexPropagator::Instance()->SetVertex(1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 100.0); + + auto step = MakeStep(0, 2212, "drift_volume", 0.0, 0.95, 20.0); + step.exiting = true; + step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); + + detector.ProcessStep(step); + + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 0.0); +} + +TEST_F(AtTpcTest, ReactionEventTrackZeroDoesNotTriggerBeamHandoffWhenNotInBeamEvent) +{ + AtVertexPropagator::Instance()->SetIsBeamEvent(false); + AtVertexPropagator::Instance()->SetRndELoss(0.5); + + // A secondary track (trackID != 0) crossing the active gas should not trigger the beam handoff. + auto step = MakeStep(1, 1000060160, "drift_volume", 0.0006, 15.9, 12.0); + step.entering = true; + + const bool stopTransport = detector.ProcessStep(step); + + EXPECT_FALSE(stopTransport); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 0.0); +} + +TEST_F(AtTpcTest, SecondaryExitDoesNotResetVertexState) +{ + AtVertexPropagator::Instance()->SetVertex(1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 100.0); + + // Non-beam track (trackID != 0) exiting the active gas must not disturb the vertex state. + auto step = MakeStep(1, 1000060160, "drift_volume", 0.0, 15.9, 20.0); + step.exiting = true; + step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); + + detector.ProcessStep(step); + + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 3.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 100.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 1.0); +} + +TEST_F(AtTpcTest, NonBeamTracksUseStoredMetadata) +{ + AtVertexPropagator::Instance()->SetTrackEnergy(1, 7.5); + AtVertexPropagator::Instance()->SetTrackAngle(1, 32.0); + + auto step = MakeStep(1, 1000020040, "drift_volume", 0.0001, 3.8, 8.0); + detector.ProcessStep(step); + + auto *points = detector.GetCollection(0); + ASSERT_NE(points, nullptr); + ASSERT_EQ(points->GetEntriesFast(), 1); + + auto *point = dynamic_cast(points->At(0)); + ASSERT_NE(point, nullptr); + EXPECT_DOUBLE_EQ(point->GetEIni(), 7.5); + EXPECT_DOUBLE_EQ(point->GetAIni(), 32.0); + EXPECT_EQ(point->GetMassNum(), 4); + EXPECT_EQ(point->GetAtomicNum(), 2); +} diff --git a/AtDetectors/CMakeLists.txt b/AtDetectors/CMakeLists.txt index f4f18c832..42a31dee1 100644 --- a/AtDetectors/CMakeLists.txt +++ b/AtDetectors/CMakeLists.txt @@ -61,6 +61,15 @@ Set(DEPENDENCIES ATTPCROOT::AtSimulationData ) +set(TEST_SRCS + AtTpc/AtTpcTest.cxx + ) + +attpcroot_generate_tests(${LIBRARY_NAME}Tests + SRCS ${TEST_SRCS} + DEPS ${LIBRARY_NAME} + ) + generate_target_and_root_library(${LIBRARY_NAME} LINKDEF ${LINKDEF} SRCS ${SRCS} diff --git a/AtDigitization/AtDigiLinkDef.h b/AtDigitization/AtDigiLinkDef.h index ec1c7b136..15a1de747 100644 --- a/AtDigitization/AtDigiLinkDef.h +++ b/AtDigitization/AtDigiLinkDef.h @@ -4,13 +4,13 @@ #pragma link off all classes; #pragma link off all functions; -#pragma link C++ class AtClusterize - !; -#pragma link C++ class AtClusterizeLine - !; +#pragma link C++ class AtClusterize -!; +#pragma link C++ class AtClusterizeLine -!; #pragma link C++ class AtClusterizeTask + ; #pragma link C++ class AtClusterizeLineTask + ; -#pragma link C++ class AtPulse - !; -#pragma link C++ class AtPulseLine - !; +#pragma link C++ class AtPulse -!; +#pragma link C++ class AtPulseLine -!; #pragma link C++ class AtPulseTask + ; #pragma link C++ class AtPulseTaskGADGET + ; #pragma link C++ class AtPulseLineTask + ; @@ -20,7 +20,10 @@ #pragma link C++ class AtTrigger + ; #pragma link C++ class AtTriggerTask + ; -#pragma link C++ class AtVectorResponse - ; -#pragma link C++ class AtTestSimulation + ; -#pragma link C++ class AtSimpleSimulation - !; +#pragma link C++ class AtVectorResponse -; +#pragma link C++ class AtSimTransportTask +; +#pragma link C++ class AtSimTransportGeneratorTask +; +#pragma link C++ class AtSimTransportReplayTask +; +#pragma link C++ class AtSimTransport -!; +#pragma link C++ class AtSimpleSimulation -!; #endif diff --git a/AtDigitization/AtSimParticleCollector.cxx b/AtDigitization/AtSimParticleCollector.cxx new file mode 100644 index 000000000..fd06b364e --- /dev/null +++ b/AtDigitization/AtSimParticleCollector.cxx @@ -0,0 +1,62 @@ +#include "AtSimParticleCollector.h" + +#include + +#include + +AtSimParticleCollector::~AtSimParticleCollector() +{ + Reset(); +} + +void AtSimParticleCollector::Reset() +{ + for (auto *particle : fParticles) + delete particle; + fParticles.clear(); + fCurrentTrack = -1; +} + +void AtSimParticleCollector::PushTrack(Int_t toBeDone, Int_t parentID, Int_t pdgCode, Double_t px, Double_t py, + Double_t pz, Double_t e, Double_t vx, Double_t vy, Double_t vz, Double_t time, + Double_t polx, Double_t poly, Double_t polz, TMCProcess /*proc*/, Int_t &ntr, + Double_t weight, Int_t is, Int_t secondparentID) +{ + ntr = static_cast(fParticles.size()); + if (!toBeDone) + return; + + auto *particle = new TParticle(pdgCode, is, parentID, secondparentID, /*daughter1=*/-1, /*daughter2=*/-1, px, py, pz, + e, vx, vy, vz, time); + particle->SetPolarisation(polx, poly, polz); + particle->SetWeight(weight); + fParticles.push_back(particle); +} + +Int_t AtSimParticleCollector::GetCurrentParentTrackNumber() const +{ + if (fCurrentTrack < 0 || static_cast(fCurrentTrack) >= fParticles.size()) + return -1; + return fParticles[fCurrentTrack]->GetFirstMother(); +} + +TParticle *AtSimParticleCollector::PopNextTrack(Int_t & /*itrack*/) +{ + LOG(fatal) << "AtSimParticleCollector::PopNextTrack() is not implemented. " + << "This stub stack only supports PushTrack()."; + return nullptr; +} + +TParticle *AtSimParticleCollector::PopPrimaryForTracking(Int_t /*iPrim*/) +{ + LOG(fatal) << "AtSimParticleCollector::PopPrimaryForTracking() is not implemented. " + << "This stub stack only supports PushTrack()."; + return nullptr; +} + +TParticle *AtSimParticleCollector::GetCurrentTrack() const +{ + LOG(fatal) << "AtSimParticleCollector::GetCurrentTrack() is not implemented. " + << "This stub stack only supports PushTrack()."; + return nullptr; +} diff --git a/AtDigitization/AtSimParticleCollector.h b/AtDigitization/AtSimParticleCollector.h new file mode 100644 index 000000000..44e82f8e4 --- /dev/null +++ b/AtDigitization/AtSimParticleCollector.h @@ -0,0 +1,72 @@ +#ifndef ATSIMPARTICLECOLLECTOR_H +#define ATSIMPARTICLECOLLECTOR_H + +#include + +#include // for Int_t, Double_t, TMCProcess, etc. + +#include + +class TRefArray; +class TParticle; + +/** + * @brief Minimal FairGenericStack stub that captures PushTrack() calls as TParticles. + * + * Pass an instance of this class to FairPrimaryGenerator::GenerateEvent() instead of the + * real VMC stack. All generator logic (vertex offsets, beam-angle rotations, PDG lookups, + * energy calculation) runs unchanged; the resulting particles land here rather than in Geant4. + * Storing TParticle directly preserves parentage, polarization, weight, and vertex time — + * none of which the previous bespoke struct captured. + * + * Only primary particles marked for tracking (toBeDone == 1) are stored. + * + * @note GetCurrentTrack() / PopNextTrack() / PopPrimaryForTracking() exist only to satisfy + * TVirtualMCStack's pure-virtual interface. They LOG(fatal) if called and must never + * be reached in the SimpleSim pipeline, which only uses PushTrack() and iterates + * GetParticles() afterwards. + */ +class AtSimParticleCollector : public FairGenericStack { +public: + AtSimParticleCollector() = default; + ~AtSimParticleCollector() override; + + /// Full 19-argument PushTrack: stores a TParticle carrying parent, polarization, weight, time. + void PushTrack(Int_t toBeDone, Int_t parentID, Int_t pdgCode, Double_t px, Double_t py, Double_t pz, Double_t e, + Double_t vx, Double_t vy, Double_t vz, Double_t time, Double_t polx, Double_t poly, Double_t polz, + TMCProcess proc, Int_t &ntr, Double_t weight, Int_t is, Int_t secondparentID) override; + + /// 18-argument overload (legacy VMC signature). Delegates with secondparentID = -1. + void PushTrack(Int_t toBeDone, Int_t parentID, Int_t pdgCode, Double_t px, Double_t py, Double_t pz, Double_t e, + Double_t vx, Double_t vy, Double_t vz, Double_t time, Double_t polx, Double_t poly, Double_t polz, + TMCProcess proc, Int_t &ntr, Double_t weight, Int_t is) override + { + PushTrack(toBeDone, parentID, pdgCode, px, py, pz, e, vx, vy, vz, time, polx, poly, polz, proc, ntr, weight, is, + -1); + } + + /// Collected particles. Ownership stays with the collector; vector is cleared on Reset(). + const std::vector &GetParticles() const { return fParticles; } + + // ---- TVirtualMCStack pure-virtual stubs. See class note. ---- + TParticle *PopNextTrack(Int_t &itrack) override; + TParticle *PopPrimaryForTracking(Int_t i) override; + TParticle *GetCurrentTrack() const override; + + void SetCurrentTrack(Int_t itrack) override { fCurrentTrack = itrack; } + Int_t GetNtrack() const override { return static_cast(fParticles.size()); } + Int_t GetNprimary() const override { return static_cast(fParticles.size()); } + Int_t GetCurrentTrackNumber() const override { return fCurrentTrack; } + Int_t GetCurrentParentTrackNumber() const override; + + // ---- FairGenericStack overrides (non-pure; trivial for a collector) ---- + void FillTrackArray() override {} + void UpdateTrackIndex(TRefArray *) override {} + void Reset() override; + +private: + std::vector fParticles; ///< Owned TParticle pointers. + Int_t fCurrentTrack{-1}; +}; + +#endif // ATSIMPARTICLECOLLECTOR_H diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx new file mode 100644 index 000000000..1d90f64cd --- /dev/null +++ b/AtDigitization/AtSimTest.cxx @@ -0,0 +1,364 @@ +/** + * Unit tests for AtSimTransport. + * + * Two physics tests: + * 1. ZeroFieldStraightLine — zero E/B fields produce a collinear track along the + * initial momentum direction. Total energy loss matches the analytic integral + * of a constant-dEdx model. + * + * 2. MagneticFieldLarmorRadius — a non-zero B field along Z curves a transverse + * proton into a circle whose radius matches the relativistic Larmor formula + * r = p⊥ / (q B). + * + * No external files are used; geometry and energy-loss model are built in memory. + */ + +#include "AtELossManager.h" +#include "AtELossModel.h" +#include "AtMCPoint.h" +#include "AtMCTrack.h" +#include "AtSimTransport.h" +#include "AtSimTransportGeneratorTask.h" +#include "AtSimpleSimulation.h" +#include "AtVertexPropagator.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "AtTpc/AtTpc.h" + +// --------------------------------------------------------------------------- +// Minimal energy-loss model with constant dEdx = fRate [MeV/mm]. +// --------------------------------------------------------------------------- +class ConstELoss : public AtTools::AtELossModel { +public: + double fRate; + explicit ConstELoss(double rate = 1.0) : AtTools::AtELossModel(0), fRate(rate) {} + + double GetdEdx(double /*KE*/) const override { return fRate; } + double GetRange(double ei, double ef = 0) const override { return (fRate > 0) ? (ei - ef) / fRate : 1e9; } + double GetEnergyLoss(double /*KE*/, double dist) const override { return fRate * dist; } + double GetEnergy(double ei, double dist) const override { return std::max(0.0, ei - fRate * dist); } + double GetElossStraggling(double, double) const override { return 0; } + double GetdEdxStraggling(double, double) const override { return 0; } + double GetRangeVariance(double) const override { return 0; } +}; + +// --------------------------------------------------------------------------- +// Fixture: builds an in-memory TGeoManager with a 100×100×100 cm "cave" +// containing a 50×50×50 cm "drift_volume" box. AtSimTransport calls +// gGeoManager->FindNode() to determine whether a position is inside the +// active volume, so we need this geometry even in unit tests. +// --------------------------------------------------------------------------- +class AtSimTest : public ::testing::Test { +protected: + void SetUp() override + { + if (gGeoManager != nullptr) + delete gGeoManager; + + new TGeoManager("test_geo", "test geometry"); + auto *mat = new TGeoMaterial("Vacuum", 0, 0, 0); + auto *med = new TGeoMedium("Vacuum", 1, mat); + auto *top = gGeoManager->MakeBox("cave", med, 100, 100, 100); // 100 cm half-widths + gGeoManager->SetTopVolume(top); + auto *drift = gGeoManager->MakeBox("drift_volume", med, 50, 50, 50); // 50 cm = 500 mm + top->AddNode(drift, 1); + gGeoManager->CloseGeometry(); + } +}; + +// --------------------------------------------------------------------------- +// Test helper: exposes protected members for testing without #define hacks. +// --------------------------------------------------------------------------- +class TestableSimTask : public AtSimTransportGeneratorTask { +public: + using AtSimTransportGeneratorTask::AtSimTransportGeneratorTask; + using AtSimTransportTask::fCollector; + using AtSimTransportTask::fDetector; + using AtSimTransportTask::FillMCTracks; + using AtSimTransportTask::fMCTrackArray; + using AtSimTransportTask::SubmitDetectorStep; + EventState LoadEvent() override { return {}; } +}; + +// --------------------------------------------------------------------------- +// Test 1 — ZeroFieldStraightLine +// +// Fire a proton along +Z (no E or B field). With a constant 1 MeV/mm model +// and KE₀ = 50 MeV, the particle stops after 50 mm. +// --------------------------------------------------------------------------- +TEST_F(AtSimTest, ZeroFieldStraightLine) +{ + auto manager = std::make_shared(); + manager->AddModel(1, 1, std::make_shared(1.0 /*MeV/mm*/)); + auto engine = std::make_unique(manager); + AtSimpleSimulation sim(std::move(engine)); + + // Proton: KE = 50 MeV → p_z ≈ 310.5 MeV/c, E ≈ 988.3 MeV + const double mass_p = 1.007276 * 931.494; // MeV/c² (matching model mass) + const double KE0 = 50.0; // MeV + const double E0 = mass_p + KE0; + const double p0 = std::sqrt(E0 * E0 - mass_p * mass_p); + + ROOT::Math::XYZPoint pos(0, 0, 0); // mm + ROOT::Math::PxPyPzEVector mom(0.0, 0.0, p0, E0); // MeV + + sim.NewEvent(); + sim.SimulateParticle(1, 1, pos, mom); + + int nPts = sim.GetNumPoints(); + ASSERT_GT(nPts, 0) << "No MCPoints were produced"; + + // --- Check each point lies on the Z axis --- + double totalELoss = 0; + double prevZ = -1e9; + for (int i = 0; i < nPts; ++i) { + auto *pt = dynamic_cast(sim.GetPointsArray().At(i)); + ASSERT_NE(pt, nullptr); + + // X and Y must be (almost) zero — positions stored in cm + EXPECT_NEAR(pt->GetX(), 0.0, 1e-9) << "Point " << i << " X ≠ 0"; + EXPECT_NEAR(pt->GetY(), 0.0, 1e-9) << "Point " << i << " Y ≠ 0"; + + // Z must be monotonically increasing + double z = pt->GetZ(); + EXPECT_GT(z, prevZ) << "Z not monotonically increasing at point " << i; + prevZ = z; + + totalELoss += pt->GetEnergyLoss() * 1000.; // GeV → MeV + } + + // --- Last point must be near the expected stopping position (50 mm = 5 cm) --- + auto *last = dynamic_cast(sim.GetPointsArray().At(nPts - 1)); + EXPECT_NEAR(last->GetZ(), 5.0 /*cm*/, 0.2) << "Particle didn't stop near expected range"; + + // --- Total energy loss must equal KE₀ within 5% --- + EXPECT_NEAR(totalELoss, KE0, KE0 * 0.05) << "Total energy loss differs from initial KE"; +} + +// --------------------------------------------------------------------------- +// Test 2 — MagneticFieldLarmorRadius +// +// Fire a proton with purely transverse momentum p_x in a B = (0,0,2 T) field. +// The proton should trace a circle in the XY plane with the relativistic +// Larmor radius r = p⊥ / (q B). +// +// Expected radius (SI calculation): +// p⊥ = 100 MeV/c = 100 × 1.60218e-13 / 299792458 kg·m/s ≈ 5.344e-20 kg·m/s +// q = 1.60218e-19 C +// B = 2 T +// r = p⊥ / (q B) ≈ 0.1669 m ≈ 166.9 mm +// --------------------------------------------------------------------------- +TEST_F(AtSimTest, MagneticFieldLarmorRadius) +{ + auto manager = std::make_shared(); + manager->AddModel(1, 1, std::make_shared(0.0 /*MeV/mm — no drag*/)); + auto engine = std::make_unique(manager); + engine->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); // 2 T along Z + AtSimpleSimulation sim(std::move(engine)); + + const double mass_p = 938.272; // MeV/c² + const double px0 = 100.0; // MeV/c (purely transverse) + const double E0 = std::sqrt(px0 * px0 + mass_p * mass_p); + + ROOT::Math::XYZPoint pos(0, 0, 0); + ROOT::Math::PxPyPzEVector mom(px0, 0.0, 0.0, E0); // MeV + + // Stop after 200 steps — enough to trace ≈ one full Larmor circle + int stepCount = 0; + const int maxSteps = 200; + auto stopFunc = [&stepCount, maxSteps](ROOT::Math::XYZPoint, ROOT::Math::PxPyPzEVector) -> bool { + return ++stepCount < maxSteps; + }; + + sim.NewEvent(); + sim.SimulateParticle(1, 1, pos, mom, stopFunc); + + int nPts = sim.GetNumPoints(); + ASSERT_GT(nPts, 5) << "Too few MCPoints for Larmor test (got " << nPts << ")"; + + // Sanity check: if the curved path is active, the proton must curve in -Y. + // If all Y-coordinates are ~0, the simulation used the straight-line path (B field inactive). + { + auto *pt0 = dynamic_cast(sim.GetPointsArray().At(nPts / 2)); + ASSERT_NE(pt0, nullptr); + EXPECT_NE(pt0->GetY(), 0.0) << "Y=0 at midpoint: B field not activating curved path"; + } + + // Analytic Larmor radius in mm + // r [m] = p[kg·m/s] / (q[C] * B[T]) + // p [MeV/c] → [kg·m/s] via 1 MeV/c = 1.60218e-13 J / 299792458 m/s + const double MeV_per_c_to_SI = 1.60218e-13 / 299792458.0; + const double q = 1.60218e-19; // C + const double B = 2.0; // T + const double p_SI = px0 * MeV_per_c_to_SI; + const double larmor_mm = p_SI / (q * B) * 1000.; // m → mm + + // Initial momentum is in +X; B is in +Z. + // Force F = q(v × B): v = vx*x̂, B = Bz*ẑ → v×B = vx*Bz*(x̂×ẑ) = -vx*Bz*ŷ + // The proton curves in the -Y direction; circle center is at (0, -r, 0). + const double cx = 0.0; + const double cy = -larmor_mm; + + // Check that every MCPoint lies on the expected circle (within 5%) + double sumErr = 0; + for (int i = 0; i < nPts; ++i) { + auto *pt = dynamic_cast(sim.GetPointsArray().At(i)); + ASSERT_NE(pt, nullptr); + + double x_mm = pt->GetX() * 10.; // cm → mm + double y_mm = pt->GetY() * 10.; + double dx = x_mm - cx; + double dy = y_mm - cy; + double r = std::sqrt(dx * dx + dy * dy); + sumErr += std::abs(r - larmor_mm); + } + double meanErr = sumErr / nPts; + EXPECT_LT(meanErr, larmor_mm * 0.05) << "Mean Larmor radius error " << meanErr << " mm exceeds 5% of " << larmor_mm + << " mm"; +} + +TEST_F(AtSimTest, LegacySimulateParticleStillRejectsStartsOutsideDriftVolume) +{ + auto manager = std::make_shared(); + manager->AddModel(1, 1, std::make_shared(0.1)); + auto engine = std::make_unique(manager); + AtSimpleSimulation sim(std::move(engine)); + + const double mass_p = 938.272; + const double E0 = mass_p + 10.0; + const double p0 = std::sqrt(E0 * E0 - mass_p * mass_p); + + ROOT::Math::XYZPoint pos(0, 0, 600.0); // Outside drift_volume but still in cave + ROOT::Math::PxPyPzEVector mom(0.0, 0.0, -p0, E0); + + sim.NewEvent(); + EXPECT_THROW(sim.SimulateParticle(1, 1, pos, mom), std::invalid_argument); +} + +TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) +{ + auto manager = std::make_shared(); + manager->AddModel(1, 1, std::make_shared(0.0)); + AtSimTransport sim(manager); + sim.SetMaxStep(10.0); + + const double mass_p = 938.272; + const double E0 = mass_p + 10.0; + const double p0 = std::sqrt(E0 * E0 - mass_p * mass_p); + + ROOT::Math::XYZPoint pos(0, 0, -600.0); // In cave, upstream of drift_volume + ROOT::Math::PxPyPzEVector mom(0.0, 0.0, p0, E0); + + bool sawCaveToDrift = false; + int callbackCount = 0; + + sim.TransportParticle(1, 1, pos, mom, [&](const AtSimTransport::TransportStep &step) { + ++callbackCount; + if (step.preVolumeName == "cave" && step.postVolumeName == "drift_volume") + sawCaveToDrift = true; + return callbackCount < 30; + }); + + EXPECT_GT(callbackCount, 0); + EXPECT_TRUE(sawCaveToDrift); +} + +TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) +{ + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + TestableSimTask task(std::move(sim)); + task.fMCTrackArray = new TClonesArray("AtMCTrack"); + + Int_t ntr = -1; + task.fCollector.PushTrack(1, -1, 2212, 0.1, 0.0, 0.2, 0.95, 0.0, 0.0, 14.0, 0.0, 0.0, 0.0, 0.0, kPPrimary, ntr, 1.0, + 0, -1); + task.fCollector.PushTrack(1, -1, 1000020040, 0.0, 0.0, 0.3, 3.8, 0.0, 0.0, 14.0, 0.0, 0.0, 0.0, 0.0, kPPrimary, ntr, + 1.0, 0, -1); + + task.FillMCTracks(); + + ASSERT_EQ(task.fMCTrackArray->GetEntriesFast(), 2); + + auto *proton = dynamic_cast(task.fMCTrackArray->At(0)); + auto *alpha = dynamic_cast(task.fMCTrackArray->At(1)); + + ASSERT_NE(proton, nullptr); + ASSERT_NE(alpha, nullptr); + + EXPECT_EQ(proton->GetPdgCode(), 2212); + EXPECT_EQ(proton->GetMotherId(), -1); + EXPECT_EQ(alpha->GetPdgCode(), 1000020040); + EXPECT_EQ(alpha->GetMotherId(), -1); +} + +TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) +{ + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + TestableSimTask task(std::move(sim)); + task.fMCTrackArray = new TClonesArray("AtMCTrack"); + + Int_t ntr = -1; + task.fCollector.PushTrack(1, -1, 2212, 0.0, 0.0, 0.043, 0.94, 0.0, 0.0, -10.0, 0.0, 0.0, 0.0, 0.0, kPPrimary, ntr, + 1.0, 0, -1); + + task.FillMCTracks(); + + ASSERT_EQ(task.fMCTrackArray->GetEntriesFast(), 1); + auto *beam = dynamic_cast(task.fMCTrackArray->At(0)); + ASSERT_NE(beam, nullptr); + EXPECT_EQ(beam->GetPdgCode(), 2212); + EXPECT_EQ(beam->GetMotherId(), -1); +} + +TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) +{ + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + TestableSimTask task(std::move(sim)); + AtTpc detector; + task.fDetector = &detector; + + AtVertexPropagator::Instance()->ResetForTesting(); + AtVertexPropagator::Instance()->SetBeamMass(16.014701); + AtVertexPropagator::Instance()->ResetVertex(); + + const double mass = 16.014701 * 931.494; + ROOT::Math::XYZPoint pos(0.0, 0.0, 1.0); + ROOT::Math::PxPyPzEVector mom(0.0, 0.0, 2297.0, std::sqrt(2297.0 * 2297.0 + mass * mass)); + + // Build a synthetic initial step (zero energy loss, entering) + AtSimTransport::TransportStep initialStep; + initialStep.pdg = 1000060160; + initialStep.trackMass = mom.M(); + initialStep.prePosition = pos; + initialStep.postPosition = pos; + initialStep.preMomentum = mom; + initialStep.postMomentum = mom; + const bool keepTransporting = task.SubmitDetectorStep(initialStep, 0, true, true, false); + + EXPECT_TRUE(keepTransporting); + auto *points = detector.GetCollection(0); + ASSERT_NE(points, nullptr); + ASSERT_EQ(points->GetEntriesFast(), 1); + + auto *point = dynamic_cast(points->At(0)); + ASSERT_NE(point, nullptr); + EXPECT_EQ(point->GetTrackID(), 0); + EXPECT_NEAR(point->GetZ() * 10., 1.0, 1e-9); + EXPECT_NEAR(point->GetLength() * 10., 0.0, 1e-9); + EXPECT_NEAR(point->GetEnergyLoss() * 1e6, 0.0, 1e-9); +} diff --git a/AtDigitization/AtSimTransport.cxx b/AtDigitization/AtSimTransport.cxx new file mode 100644 index 000000000..292f04ebf --- /dev/null +++ b/AtDigitization/AtSimTransport.cxx @@ -0,0 +1,370 @@ +#include "AtSimTransport.h" + +#include "AtELossManager.h" +#include "AtELossModel.h" +#include "AtKinematics.h" +#include "AtPropagator.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using ModelPtr = std::shared_ptr; +using XYZPoint = ROOT::Math::XYZPoint; +using XYZVector = ROOT::Math::XYZVector; +using PxPyPzEVector = ROOT::Math::PxPyPzEVector; + +namespace { +constexpr double kAmuToMeV = 931.494; // MeV/c² per amu +constexpr double kEcharge = 1.602176634e-19; // Coulombs + +int GetPDGFromZA(int Z, int A) +{ + if (A == 1 && Z == 1) + return 2212; + if (A == 1 && Z == 0) + return 2112; + return 1000000000 + Z * 10000 + A * 10; +} + +double GetMassAmuFromPDG(int Z, int A) +{ + int pdg = GetPDGFromZA(Z, A); + TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdg); + if (particle != nullptr) { + return particle->Mass() / 0.931494; // GeV/c² -> amu + } + return static_cast(A); +} +} // namespace + +AtSimTransport::AtSimTransport() : fManager(std::make_shared()) +{ + // Defer geometry check until first use; gGeoManager may not be populated yet. +} + +AtSimTransport::AtSimTransport(std::shared_ptr manager) : fManager(std::move(manager)) +{ + if (!fManager) + fManager = std::make_shared(); +} + +AtSimTransport::AtSimTransport(std::string geoFile) + : AtSimTransport(std::move(geoFile), std::make_shared()) +{ +} + +AtSimTransport::AtSimTransport(std::string geoFile, std::shared_ptr manager) + : fManager(std::move(manager)) +{ + if (!fManager) + fManager = std::make_shared(); + TGeoManager *geo = TGeoManager::Import(geoFile.c_str()); + if (gGeoManager == nullptr) + LOG(fatal) << "Failed to load geometry file " << geoFile << " " << geo; +} + +void AtSimTransport::AddModel(int Z, int A, ModelPtr model) +{ + if (!fManager) + LOG(fatal) << "AtSimTransport::AddModel: no AtELossManager attached"; + fManager->AddModel(Z, A, std::move(model)); +} + +void AtSimTransport::AddModel(int Z, int A, const std::string &materialName, ModelPtr model) +{ + if (!fManager) + LOG(fatal) << "AtSimTransport::AddModel: no AtELossManager attached"; + fManager->AddModel(Z, A, materialName, std::move(model)); +} + +TGeoVolume *AtSimTransport::GetVolume(const XYZPoint &pos) +{ + auto pointCm = pos / 10.; // mm → cm (TGeo) + + std::lock_guard lock(fGeoMutex); + + if (gGeoManager == nullptr) + return nullptr; + + // Re-sync with gGeoManager if it changed (e.g. FairRunSim::Init loaded geometry). + // A private navigator is required because AtMCFitter runs many simulations that share + // gGeoManager's state, and its default navigator is not safe to share. + if (fGeoManager != gGeoManager || fNavigator == nullptr) { + fNavigator = nullptr; // old navigator is owned by the old manager; abandon it. + fGeoManager = gGeoManager; + fNavigator = fGeoManager->AddNavigator(); + } + + TGeoNode *node = fNavigator->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); + if (node == nullptr) + return nullptr; + return node->GetVolume(); +} + +std::string AtSimTransport::GetVolumeName(const XYZPoint &point) +{ + TGeoVolume *volume = GetVolume(point); + return volume != nullptr ? volume->GetName() : std::string{}; +} + +TGeoMaterial *AtSimTransport::GetMaterial(const XYZPoint &pos) +{ + TGeoVolume *vol = GetVolume(pos); + if (vol == nullptr) + return nullptr; + TGeoMedium *medium = vol->GetMedium(); + return medium != nullptr ? medium->GetMaterial() : nullptr; +} + +AtSimTransport::ParticleInfo AtSimTransport::LookupParticleInfo(int Z, int A, const XYZPoint &pos) +{ + ParticleInfo info; + if (!fManager) + return info; + + TGeoMaterial *material = GetMaterial(pos); + double massAmu = GetMassAmuFromPDG(Z, A); + info.model = fManager->GetModel(Z, A, massAmu, material); + info.charge = Z * kEcharge; + info.mass = massAmu * kAmuToMeV; + return info; +} + +std::pair AtSimTransport::TransportParticle(int Z, int A, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, + StepCallback callback) +{ + if (GetVolume(iniPos) == nullptr) + throw std::invalid_argument("Position of particle is outside the loaded geometry"); + + ParticleInfo info = LookupParticleInfo(Z, A, iniPos); + if (!info.model) { + throw std::invalid_argument("No energy-loss model available for Z=" + std::to_string(Z) + + " A=" + std::to_string(A) + " in material at start position"); + } + + return PropagateParticle(Z, A, GetPDGFromZA(Z, A), iniPos, iniMom, callback); +} + +std::pair AtSimTransport::PropagateParticle(int Z, int A, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, + const StepCallback &callback) +{ + if (fBField.Mag2() != 0 || fFieldFunc != nullptr) + return PropagateCurved(Z, A, pdg, iniPos, iniMom, callback); + return PropagateStraightLine(Z, A, pdg, iniPos, iniMom, callback); +} + +AtSimTransport::TransportStep +AtSimTransport::BuildStep(int pdg, std::string preVolumeName, std::string postVolumeName, std::string materialName, + const XYZPoint &posBefore, const XYZPoint &posAfter, const PxPyPzEVector &momBefore, + const PxPyPzEVector &momAfter, double eLoss, double length, double mass) +{ + TransportStep step; + step.pdg = pdg; + step.preVolumeName = std::move(preVolumeName); + step.postVolumeName = std::move(postVolumeName); + step.materialName = std::move(materialName); + step.energyLoss = eLoss; + step.length = length; + step.trackMass = mass; + step.prePosition = posBefore; + step.postPosition = posAfter; + step.preMomentum = momBefore; + step.postMomentum = momAfter; + return step; +} + +std::pair AtSimTransport::PropagateCurved(int Z, int A, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, + const StepCallback &callback) +{ + ParticleInfo info = LookupParticleInfo(Z, A, iniPos); + if (!info.model) { + LOG(error) << "PropagateCurved: no model for Z=" << Z << " A=" << A << " at start position"; + return {iniPos, iniMom}; + } + + AtTools::AtPropagator prop(info.charge, info.mass, info.model.get()); + + if (fFieldFunc) { + auto [eField, bField] = fFieldFunc(iniPos); + prop.SetEField(eField); + prop.SetBField(bField); + } else { + prop.SetEField(fEField); + prop.SetBField(fBField); + } + prop.SetState(iniPos, iniMom.Vect()); + + AtTools::AtRK4AdaptiveStepper stepper; + stepper.fInitialStep = fMaxStep; + stepper.fMaxStep = fMaxStep; + const double minAcceptedStepMm = stepper.fMinStep * 1e3 * fMinStepGuardScale; + + double length = 0; + int numSteps = 0; + int minStepSteps = 0; + + TGeoVolume *curVol = nullptr; + std::string curMaterialName; + while ((curVol = GetVolume(prop.GetPosition())) != nullptr) { + if (++numSteps > fMaxTransportSteps) { + LOG(warning) << "Aborting curved SimpleSim track after " << numSteps << " steps without leaving the geometry"; + break; + } + + // Re-query the manager on volume change so each medium gets its own model. + TGeoMedium *medium = curVol->GetMedium(); + TGeoMaterial *material = medium != nullptr ? medium->GetMaterial() : nullptr; + const std::string newMaterialName = material != nullptr ? material->GetName() : std::string{}; + if (newMaterialName != curMaterialName) { + auto newModel = fManager ? fManager->GetModel(Z, A, info.mass / kAmuToMeV, material) : ModelPtr{}; + if (!newModel) { + LOG(warning) << "PropagateCurved: no model for material '" << newMaterialName << "'; stopping track"; + break; + } + info.model = newModel; + prop.SetELossModel(info.model.get()); + curMaterialName = newMaterialName; + } + + double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); + if (KE <= fStopTol) + break; + + if (fFieldFunc) { + auto [eField, bField] = fFieldFunc(prop.GetPosition()); + prop.SetEField(eField); + prop.SetBField(bField); + } + + auto momBefore = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); + auto posBefore = prop.GetPosition(); + std::string preVolumeName = curVol->GetName(); + + if (std::isnan(posBefore.X()) || std::isnan(prop.GetMomentum().X())) { + LOG(error) << "Failed to propagate a point with nan!"; + return {{0, 0, 0}, {0, 0, 0, 0}}; + } + + prop.PropagateOneStep(stepper); + + auto &state = prop.GetState(); + if (state.status != AtTools::AtPropagator::StepStateStatus::kSuccess) + break; + + auto posAfter = prop.GetPosition(); + auto momAfter = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); + double KE_after = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); + double eLoss = KE - KE_after; + if (eLoss < 0) + eLoss = 0; // magnetic field does no work + + double stepDist = (posAfter - state.fLastPos).R(); // mm + if (stepDist <= minAcceptedStepMm || state.hUsed <= stepper.fMinStep * fMinStepGuardScale) { + if (++minStepSteps > fMaxMinStepStreak) { + LOG(warning) << "Aborting curved SimpleSim track after " << minStepSteps + << " minimum-size steps at position " << posAfter << " with KE " << KE_after << " MeV for PDG " + << pdg; + break; + } + } else { + minStepSteps = 0; + } + length += stepDist; + + if (callback) { + auto step = BuildStep(pdg, preVolumeName, GetVolumeName(posAfter), curMaterialName, posBefore, posAfter, + momBefore, momAfter, eLoss, length, info.mass); + if (!callback(step)) + break; + } + } + + return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; +} + +std::pair +AtSimTransport::PropagateStraightLine(int Z, int A, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + const StepCallback &callback) +{ + ParticleInfo info = LookupParticleInfo(Z, A, iniPos); + if (!info.model) { + LOG(error) << "PropagateStraightLine: no model for Z=" << Z << " A=" << A << " at start position"; + return {iniPos, iniMom}; + } + + auto pos = iniPos; + auto mom = iniMom; + double length = 0; + int numSteps = 0; + + TGeoVolume *curVol = nullptr; + std::string curMaterialName; + while ((curVol = GetVolume(pos)) != nullptr) { + if (++numSteps > fMaxTransportSteps) { + LOG(warning) << "Aborting straight-line SimpleSim track after " << numSteps + << " steps without leaving the geometry"; + break; + } + + // Re-query the manager on volume change. + TGeoMedium *medium = curVol->GetMedium(); + TGeoMaterial *material = medium != nullptr ? medium->GetMaterial() : nullptr; + const std::string newMaterialName = material != nullptr ? material->GetName() : std::string{}; + if (newMaterialName != curMaterialName) { + auto newModel = fManager ? fManager->GetModel(Z, A, info.mass / kAmuToMeV, material) : ModelPtr{}; + if (!newModel) { + LOG(warning) << "PropagateStraightLine: no model for material '" << newMaterialName << "'; stopping track"; + break; + } + info.model = newModel; + curMaterialName = newMaterialName; + } + + double KE = AtTools::Kinematics::KE(mom.Vect(), info.mass); + if (KE <= fStopTol) + break; + + if (std::isnan(pos.X()) || std::isnan(mom.X())) { + LOG(error) << "Failed to propagate a point with nan!"; + return {{0, 0, 0}, {0, 0, 0, 0}}; + } + + auto posBefore = pos; + auto momBefore = mom; + std::string preVolumeName = curVol->GetName(); + auto dir = mom.Vect().Unit(); + double eLoss = info.model->GetEnergyLoss(KE, fMaxStep); + double newKE = KE - eLoss; + if (newKE <= 0) + break; + double E = newKE + info.mass; + double p = std::sqrt(E * E - info.mass * info.mass); + mom.SetPxPyPzE(dir.X() * p, dir.Y() * p, dir.Z() * p, E); + pos += dir * fMaxStep; + length += fMaxStep; + + if (callback) { + auto step = BuildStep(pdg, preVolumeName, GetVolumeName(pos), curMaterialName, posBefore, pos, momBefore, mom, + eLoss, length, info.mass); + if (!callback(step)) + break; + } + } + + return {pos, mom}; +} diff --git a/AtDigitization/AtSimTransport.h b/AtDigitization/AtSimTransport.h new file mode 100644 index 000000000..6eb5596bb --- /dev/null +++ b/AtDigitization/AtSimTransport.h @@ -0,0 +1,184 @@ +#ifndef AT_SIM_TRANSPORT_H +#define AT_SIM_TRANSPORT_H + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace AtTools { +class AtELossModel; +class AtELossManager; +} // namespace AtTools + +class TGeoVolume; +class TGeoManager; +class TGeoNavigator; +class TGeoMaterial; + +/** + * Transport engine for propagating particles through a ROOT geometry using energy-loss + * models supplied by an AtELossManager. + * + * Internal units are mm (distance), MeV (energy), MeV/c (momentum). + * + * When a B field is set (non-zero) or a field function is provided, the simulation uses + * AtPropagator (Lorentz force + RK4 adaptive stepping) to produce curved tracks. When + * both fields are zero the existing straight-line fast path is used. + * + * This class handles only transport. For standalone hit recording see AtSimpleSimulation; + * for FairRoot pipeline integration see AtSimTransportTask. + */ +class AtSimTransport { +public: + using ModelPtr = std::shared_ptr; + using XYZPoint = ROOT::Math::XYZPoint; + using XYZVector = ROOT::Math::XYZVector; + using PxPyPzEVector = ROOT::Math::PxPyPzEVector; + + /** + * Per-step snapshot delivered to the transport callback. + * + * Units: mm (position/length), MeV (energy), MeV/c (momentum). Pre/post pair semantics + * (not entering/exiting flags) — detector-level flag state lives in AtTpc::StepState + * and is assembled by the adapter in AtSimTransportTask::SubmitDetectorStep. + * + * Track identity is owned by the caller (attach it via the callback closure); the + * engine does not carry a trackID. Material name lets callbacks observe medium + * transitions without querying geometry themselves. + */ + struct TransportStep { + int pdg = 0; + std::string preVolumeName; + std::string postVolumeName; + std::string materialName; + double energyLoss = 0.0; ///< MeV lost in this step + double length = 0.0; ///< mm accumulated since transport start + double trackMass = 0.0; ///< MeV/c² (projectile mass used by the energy-loss model) + XYZPoint prePosition; + XYZPoint postPosition; + PxPyPzEVector preMomentum; + PxPyPzEVector postMomentum; + }; + + /** + * Callback invoked by the transport loop once per step. Return true to continue, false + * to stop the particle immediately. Invoked after the step's energy loss has been + * applied; post* fields reflect the state at the end of the step. + */ + using StepCallback = std::function; + + /// Position-dependent field function. Position in mm; returns (E-field in V/m, B-field in T). + using FieldFunc = std::function(const XYZPoint &)>; + + // ---- Construction ---- + + /// Construct with a default (accept-only) AtELossManager. Callers who want auto-generation + /// should pass an AtELossManagerBetheBloch / CATIMA subclass explicitly. + AtSimTransport(); + explicit AtSimTransport(std::shared_ptr manager); + explicit AtSimTransport(std::string geoFile); + AtSimTransport(std::string geoFile, std::shared_ptr manager); + AtSimTransport(const AtSimTransport &other) = delete; // std::mutex + ~AtSimTransport() = default; + + // ---- Manager access ---- + + AtTools::AtELossManager *GetManager() { return fManager.get(); } + void SetManager(std::shared_ptr manager) { fManager = std::move(manager); } + + /// Register a pre-built model (material-agnostic); delegates to the manager. + /// Preserved for drop-in compatibility with existing MCFission / AtMCFitter calls. + void AddModel(int Z, int A, ModelPtr model); + + /// Register a pre-built model for a specific material name. + void AddModel(int Z, int A, const std::string &materialName, ModelPtr model); + + // ---- Field and step configuration ---- + + /// Maximum distance per transport step, in mm. Applies to both curved and straight-line + /// paths; only one is active at a time (selected by field settings). + void SetMaxStep(double stepMm) { fMaxStep = stepMm; } + /// Legacy alias for SetMaxStep; kept so pre-manager macros keep compiling. Prefer SetMaxStep. + void SetDistanceStep(double stepMm) { SetMaxStep(stepMm); } + void SetElectricField(XYZVector eField) { fEField = eField; } ///< V/m + void SetMagneticField(XYZVector bField) { fBField = bField; } ///< T + void SetStopTolerance(double stopTolMeV) { fStopTol = stopTolMeV; } + void SetMaxTransportSteps(int maxSteps) { fMaxTransportSteps = maxSteps; } + /// Maximum consecutive minimum-size RK4 steps before the curved path gives up. + void SetMaxMinStepStreak(int steps) { fMaxMinStepStreak = steps; } + /// Scale factor above stepper's fMinStep used to classify a step as "minimum-size." + void SetMinStepGuardScale(double scale) { fMinStepGuardScale = scale; } + void SetFieldFunction(FieldFunc func) { fFieldFunc = std::move(func); } + + // ---- Transport API ---- + + /** + * Transport a particle through the loaded geometry. Hit recording is the caller's + * responsibility (attach hooks via the callback). + * + * Throws std::invalid_argument if no model can be obtained from the manager for this + * particle + start material, or if the start position is outside the geometry. + */ + std::pair + TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, StepCallback callback); + + // ---- Geometry queries ---- + + bool IsInsideGeometry(const XYZPoint &point) { return GetVolume(point) != nullptr; } + std::string GetVolumeNameAt(const XYZPoint &point) { return GetVolumeName(point); } + +private: + std::shared_ptr fManager; + double fMaxStep{1.}; ///< mm (straight-line step distance; also used as max step for adaptive RK4) + std::mutex fGeoMutex; + TGeoManager *fGeoManager{nullptr}; + TGeoNavigator *fNavigator{nullptr}; + + XYZVector fEField{0, 0, 0}; ///< V/m + XYZVector fBField{0, 0, 0}; ///< T + double fStopTol{1e-3}; ///< MeV — KE threshold below which the particle is considered stopped + int fMaxTransportSteps{200000}; + int fMaxMinStepStreak{4096}; ///< Consecutive min-size RK4 steps before aborting the curved path. + double fMinStepGuardScale{1.01}; + FieldFunc fFieldFunc{nullptr}; + + struct ParticleInfo { + ModelPtr model; + double charge{0.}; ///< Coulombs + double mass{0.}; ///< MeV/c² + }; + + /// Look up (or build) model + particle-kinematic info for (Z, A) in the current material. + /// Returns info with model == nullptr if no model is available. + ParticleInfo LookupParticleInfo(int Z, int A, const XYZPoint &pos); + + std::string GetVolumeName(const XYZPoint &point); + TGeoVolume *GetVolume(const XYZPoint &pos); + TGeoMaterial *GetMaterial(const XYZPoint &pos); + + /// Core transport loop, branches on field settings (curved RK4 vs straight-line). + std::pair PropagateParticle(int Z, int A, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, const StepCallback &callback); + + std::pair PropagateCurved(int Z, int A, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, const StepCallback &callback); + + std::pair PropagateStraightLine(int Z, int A, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, const StepCallback &callback); + + static TransportStep BuildStep(int pdg, std::string preVolumeName, std::string postVolumeName, + std::string materialName, const XYZPoint &posBefore, const XYZPoint &posAfter, + const PxPyPzEVector &momBefore, const PxPyPzEVector &momAfter, double eLoss, + double length, double mass); +}; + +#endif // AT_SIM_TRANSPORT_H diff --git a/AtDigitization/AtSimTransportGeneratorTask.cxx b/AtDigitization/AtSimTransportGeneratorTask.cxx new file mode 100644 index 000000000..a3a7c40cd --- /dev/null +++ b/AtDigitization/AtSimTransportGeneratorTask.cxx @@ -0,0 +1,41 @@ +#include "AtSimTransportGeneratorTask.h" + +#include "AtVertexPropagator.h" + +#include +#include + +AtSimTransportGeneratorTask::AtSimTransportGeneratorTask(std::unique_ptr sim) + : AtSimTransportTask(std::move(sim)) +{ +} + +InitStatus AtSimTransportGeneratorTask::InitEventSource() +{ + if (fPrimGen == nullptr) + return kSUCCESS; + + fMCHeader = std::make_unique(); + fPrimGen->SetEvent(fMCHeader.get()); + fPrimGen->Init(); + return kSUCCESS; +} + +AtSimTransportTask::EventState AtSimTransportGeneratorTask::LoadEvent() +{ + fCollector.Clear(); + if (fPrimGen == nullptr) + return {}; + + // Capture the beam flag before GenerateEvent(), because AtReactionGenerator::ReadEvent() + // calls EndEvent() which toggles the flag before returning. + bool wasBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); + fPrimGen->GenerateEvent(&fCollector); + EventState state; + state.hasEvent = true; + state.beamEvent = wasBeamEvent; + state.transportPrimaries = true; + return state; +} + +ClassImp(AtSimTransportGeneratorTask); diff --git a/AtDigitization/AtSimTransportGeneratorTask.h b/AtDigitization/AtSimTransportGeneratorTask.h new file mode 100644 index 000000000..d5b5e7250 --- /dev/null +++ b/AtDigitization/AtSimTransportGeneratorTask.h @@ -0,0 +1,34 @@ +#ifndef AtSimTransportGeneratorTask_h +#define AtSimTransportGeneratorTask_h + +#include "AtSimTransportTask.h" + +#include +#include + +#include + +class FairPrimaryGenerator; +class TBuffer; +class TClass; +class TMemberInspector; + +class AtSimTransportGeneratorTask : public AtSimTransportTask { +public: + explicit AtSimTransportGeneratorTask(std::unique_ptr sim); + ~AtSimTransportGeneratorTask() override = default; + + void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } + void SetEventGenerator(FairPrimaryGenerator *primGen) { SetPrimaryGenerator(primGen); } + +protected: + FairPrimaryGenerator *fPrimGen{nullptr}; //! + std::unique_ptr fMCHeader{nullptr}; //! + + InitStatus InitEventSource() override; + EventState LoadEvent() override; + + ClassDefOverride(AtSimTransportGeneratorTask, 1); +}; + +#endif diff --git a/AtDigitization/AtSimTransportReplayTask.cxx b/AtDigitization/AtSimTransportReplayTask.cxx new file mode 100644 index 000000000..da36d4d99 --- /dev/null +++ b/AtDigitization/AtSimTransportReplayTask.cxx @@ -0,0 +1,93 @@ +#include "AtSimTransportReplayTask.h" + +#include "AtMCTrack.h" + +#include +#include +#include +#include +#include + +AtSimTransportReplayTask::AtSimTransportReplayTask(std::unique_ptr sim) + : AtSimTransportTask(std::move(sim)) +{ +} + +InitStatus AtSimTransportReplayTask::InitEventSource() +{ + if (fPrimaryTrackSourceFile.empty()) + return kSUCCESS; + + fPrimaryTrackFile = TFile::Open(fPrimaryTrackSourceFile.c_str(), "READ"); + if (fPrimaryTrackFile == nullptr || fPrimaryTrackFile->IsZombie()) { + LOG(fatal) << "AtSimTransportReplayTask: cannot open primary track source " << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree = dynamic_cast(fPrimaryTrackFile->Get("cbmsim")); + if (fPrimaryTrackTree == nullptr) { + LOG(fatal) << "AtSimTransportReplayTask: missing cbmsim tree in primary track source " + << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree->SetBranchAddress("MCTrack", &fPrimaryTrackInput); + LOG(info) << "AtSimTransportReplayTask: replaying primary MC tracks from " << fPrimaryTrackSourceFile; + return kSUCCESS; +} + +AtSimTransportTask::EventState AtSimTransportReplayTask::LoadEvent() +{ + if (fPrimaryTrackTree == nullptr) + return {}; + + if (!LoadPrimaryTracksFromSource()) + return {}; + + // The replay task transports all primaries from every source event. There is no + // beam/reaction state machine because the source file already contains the correct + // primaries — no generators are running and AtVertexPropagator state is not needed. + EventState state; + state.hasEvent = true; + state.beamEvent = false; + state.transportPrimaries = true; + return state; +} + +void AtSimTransportReplayTask::FinishEventSource() +{ + if (fPrimaryTrackFile == nullptr) + return; + + fPrimaryTrackFile->Close(); + delete fPrimaryTrackFile; + fPrimaryTrackFile = nullptr; + fPrimaryTrackTree = nullptr; + fPrimaryTrackInput = nullptr; +} + +bool AtSimTransportReplayTask::LoadPrimaryTracksFromSource() +{ + if (fPrimaryTrackTree == nullptr || fSourceEventIndex >= fPrimaryTrackTree->GetEntries()) + return false; + + fCollector.Clear(); + fPrimaryTrackTree->GetEntry(fSourceEventIndex++); + if (fPrimaryTrackInput == nullptr) + return true; + + for (int i = 0; i < fPrimaryTrackInput->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(fPrimaryTrackInput->At(i)); + if (track == nullptr || track->GetMotherId() != -1) + continue; + + Int_t ntr = 0; + fCollector.PushTrack(1, -1, track->GetPdgCode(), track->GetPx(), track->GetPy(), track->GetPz(), track->GetEnergy(), + track->GetStartX(), track->GetStartY(), track->GetStartZ(), track->GetStartT(), 0., 0., 0., + kPPrimary, ntr, 0., 0, -1); + } + + return true; +} + +ClassImp(AtSimTransportReplayTask); diff --git a/AtDigitization/AtSimTransportReplayTask.h b/AtDigitization/AtSimTransportReplayTask.h new file mode 100644 index 000000000..b0ff51246 --- /dev/null +++ b/AtDigitization/AtSimTransportReplayTask.h @@ -0,0 +1,40 @@ +#ifndef AtSimTransportReplayTask_h +#define AtSimTransportReplayTask_h + +#include "AtSimTransportTask.h" + +#include + +#include + +class TBuffer; +class TClass; +class TFile; +class TMemberInspector; +class TTree; + +class AtSimTransportReplayTask : public AtSimTransportTask { +public: + explicit AtSimTransportReplayTask(std::unique_ptr sim); + ~AtSimTransportReplayTask() override = default; + + void SetPrimaryTrackSource(const std::string &fileName) { fPrimaryTrackSourceFile = fileName; } + +protected: + std::string fPrimaryTrackSourceFile; //! + TFile *fPrimaryTrackFile{nullptr}; //! + TTree *fPrimaryTrackTree{nullptr}; //! + TClonesArray *fPrimaryTrackInput{nullptr};//! + Long64_t fSourceEventIndex{0}; //! + + InitStatus InitEventSource() override; + EventState LoadEvent() override; + void FinishEventSource() override; + +private: + bool LoadPrimaryTracksFromSource(); + + ClassDefOverride(AtSimTransportReplayTask, 1); +}; + +#endif diff --git a/AtDigitization/AtSimTransportTask.cxx b/AtDigitization/AtSimTransportTask.cxx new file mode 100644 index 000000000..f33382240 --- /dev/null +++ b/AtDigitization/AtSimTransportTask.cxx @@ -0,0 +1,413 @@ +#include "AtSimTransportTask.h" + +#include "AtDetectorList.h" +#include "AtMCTrack.h" +#include "AtSimTransport.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "AtTpc/AtTpc.h" + +using namespace ROOT::Math; + +namespace { +// SimpleSim uses mm/MeV; FairRoot/AtTpc uses cm/GeV. +constexpr double kCmToMm = 10.; +constexpr double kMmToCm = 0.1; +constexpr double kGeVToMeV = 1000.; +constexpr double kMeVToGeV = 0.001; + +std::pair GetZAFromPDG(int pdg) +{ + if (pdg > 1000000000) { + int A = (pdg / 10) % 1000; + int Z = (pdg / 10000) % 1000; + return {Z, A}; + } + + TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdg); + if (particle != nullptr) { + int Z = static_cast(std::round(particle->Charge() / 3.0)); + int A = static_cast(std::round(particle->Mass() / 0.9315)); + return {Z, std::max(A, 1)}; + } + + return {0, 0}; +} +} // namespace + +AtSimTransportTask::AtSimTransportTask(std::unique_ptr sim) : fSimulation(std::move(sim)) {} + +InitStatus AtSimTransportTask::Init() +{ + // Auto-discover detector from FairRunSim if not set manually + if (fDetector == nullptr) { + auto *runSim = FairRunSim::Instance(); + if (runSim != nullptr) { + auto *modules = runSim->GetListOfModules(); + if (modules != nullptr) { + for (int i = 0; i < modules->GetEntries(); ++i) { + auto *det = dynamic_cast(modules->At(i)); + if (det != nullptr) { + fDetector = det; + LOG(info) << "AtSimTransportTask: auto-discovered AtTpc detector '" << det->GetName() + << "' from FairRunSim"; + break; + } + } + } + } + } + if (fDetector == nullptr) { + LOG(fatal) << "AtSimTransportTask requires a sensitive detector. " + << "Call SetDetector(tpc) before Init(), or register an AtTpc with FairRunSim."; + return kFATAL; + } + LOG(info) << "AtSimTransportTask: using detector-coupled transport adapter"; + + if (fAutoConfigureField) + ConfigureFieldFromFairRun(); + + auto sourceStatus = InitEventSource(); + if (sourceStatus != kSUCCESS) + return sourceStatus; + + RegisterMCTrackBranch(); + return kSUCCESS; +} + +void AtSimTransportTask::Exec(Option_t *) +{ + auto eventState = LoadEvent(); + if (!eventState.hasEvent) + return; + + FillMCTracks(); + if (!eventState.transportPrimaries) + return; + + TransportCurrentEvent(eventState.beamEvent); +} + +void AtSimTransportTask::Finish() +{ + FinishEventSource(); +} + +InitStatus AtSimTransportTask::InitEventSource() +{ + return kSUCCESS; +} + +void AtSimTransportTask::FinishEventSource() {} + +void AtSimTransportTask::ConfigureFieldFromFairRun() +{ + using XYZVector = ROOT::Math::XYZVector; + constexpr double kKGtoTesla = 0.1; + + // Skip if the simulation already has a manually configured non-zero field + // (check by seeing if B is non-zero -- user set it before Init) + // We can't access the private fBField directly, so we rely on the convention + // that auto-config runs first and manual overrides come before Init(). + + auto *run = FairRun::Instance(); + if (run == nullptr) { + LOG(info) << "AtSimTransportTask: no FairRun instance; skipping field auto-config"; + return; + } + + auto *field = run->GetField(); + if (field == nullptr) { + LOG(info) << "AtSimTransportTask: no field set on FairRun; SimpleSim fields remain at zero"; + return; + } + + // Find drift volume center for field sampling + double cx = 0, cy = 0, cz = 0; + TGeoVolume *driftVol = nullptr; + if (gGeoManager != nullptr) + driftVol = gGeoManager->FindVolumeFast("drift_volume"); + + if (driftVol != nullptr) { + auto *shape = dynamic_cast(driftVol->GetShape()); + if (shape != nullptr) { + const double *origin = shape->GetOrigin(); + cx = origin[0]; + cy = origin[1]; + cz = origin[2]; + } + } + + // Sample field at drift volume center (FairField returns kG, position in cm) + double bx_kG = field->GetBx(cx, cy, cz); + double by_kG = field->GetBy(cx, cy, cz); + double bz_kG = field->GetBz(cx, cy, cz); + + double bx_T = bx_kG * kKGtoTesla; + double by_T = by_kG * kKGtoTesla; + double bz_T = bz_kG * kKGtoTesla; + + fSimulation->SetMagneticField(XYZVector(bx_T, by_T, bz_T)); + LOG(info) << "AtSimTransportTask: auto-configured B field from FairRun: (" << bx_T << ", " << by_T << ", " + << bz_T << ") T (sampled at drift volume center)"; + + // FairField::GetType() == 0 means constant field. For non-constant fields (maps, etc.), + // set up a per-step field query so the propagator sees the correct field at each position. + // The lambda captures the FairField pointer (owned by FairRun, outlives the simulation). + if (field->GetType() != 0) { + LOG(info) << "AtSimTransportTask: non-constant field (type " << field->GetType() + << "); enabling per-step field queries"; + fSimulation->SetFieldFunction( + [field](const ROOT::Math::XYZPoint &pos_mm) -> std::pair { + constexpr double kMmToCm = 0.1; + constexpr double kKGtoT = 0.1; + double x_cm = pos_mm.X() * kMmToCm; + double y_cm = pos_mm.Y() * kMmToCm; + double z_cm = pos_mm.Z() * kMmToCm; + ROOT::Math::XYZVector B(field->GetBx(x_cm, y_cm, z_cm) * kKGtoT, field->GetBy(x_cm, y_cm, z_cm) * kKGtoT, + field->GetBz(x_cm, y_cm, z_cm) * kKGtoT); + return {ROOT::Math::XYZVector(0, 0, 0), B}; + }); + } + + // For constant fields, check if drift volume extends beyond field region + if (field->GetType() == 0 && driftVol != nullptr) { + auto *shape = dynamic_cast(driftVol->GetShape()); + if (shape != nullptr) { + const double *origin = shape->GetOrigin(); + double dx = shape->GetDX(); + double dy = shape->GetDY(); + double dz = shape->GetDZ(); + + // Check corners of drift volume bounding box + double corners[8][3] = { + {origin[0] - dx, origin[1] - dy, origin[2] - dz}, {origin[0] + dx, origin[1] - dy, origin[2] - dz}, + {origin[0] - dx, origin[1] + dy, origin[2] - dz}, {origin[0] + dx, origin[1] + dy, origin[2] - dz}, + {origin[0] - dx, origin[1] - dy, origin[2] + dz}, {origin[0] + dx, origin[1] - dy, origin[2] + dz}, + {origin[0] - dx, origin[1] + dy, origin[2] + dz}, {origin[0] + dx, origin[1] + dy, origin[2] + dz}}; + + for (const auto &corner : corners) { + double bz_corner = field->GetBz(corner[0], corner[1], corner[2]); + if (std::abs(bz_corner - bz_kG) > 1e-6) { + LOG(warning) << "AtSimTransportTask: drift volume extends beyond the constant field region. " + << "Field at corner (" << corner[0] << ", " << corner[1] << ", " << corner[2] + << ") cm differs from center value."; + break; + } + } + } + } +} + +void AtSimTransportTask::RegisterMCTrackBranch() +{ + auto *ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before AtSimTransportTask::Init()."; + return; + } + + auto *existing = dynamic_cast(ioMan->GetObject("MCTrack")); + if (existing != nullptr) { + fMCTrackArray = existing; + return; + } + + if (fMCTrackArray == nullptr) + fMCTrackArray = new TClonesArray("AtMCTrack"); + + ioMan->Register("MCTrack", "Stack", fMCTrackArray, kTRUE); +} + +void AtSimTransportTask::FillMCTracks() +{ + if (fMCTrackArray == nullptr) + return; + + fMCTrackArray->Clear("C"); + int idx = 0; + for (auto *particle : fCollector.GetParticles()) { + new ((*fMCTrackArray)[idx]) AtMCTrack(particle); + ++idx; + } +} + +void AtSimTransportTask::TransportCurrentEvent(bool beamEvent) +{ + int trackID = 0; + for (auto *particle : fCollector.GetParticles()) { + TransportParticle(*particle, trackID, beamEvent); + ++trackID; + } +} + +void AtSimTransportTask::TransportParticle(const TParticle &particle, int trackID, bool beamEvent) +{ + const int pdgCode = particle.GetPdgCode(); + auto [Z, A] = GetZAFromPDG(pdgCode); + if (Z == 0 && A == 0) + return; + + XYZPoint pos(particle.Vx() * kCmToMm, particle.Vy() * kCmToMm, particle.Vz() * kCmToMm); + PxPyPzEVector mom(particle.Px() * kGeVToMeV, particle.Py() * kGeVToMeV, particle.Pz() * kGeVToMeV, + particle.Energy() * kGeVToMeV); + + try { + if (!IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos))) + pos = FindSensitiveEntry(pos, mom); + + LOG(info) << "Simulating particle Z=" << Z << " A=" << A << " with initial pos=" << pos << " mm and mom=" << mom + << " MeV/c"; + + // In the generator pipeline, trackID 0 is always the beam particle. Only mark it as a + // beam track during beam events so the detector can accumulate energy toward a reaction threshold. + const bool beamTrack = beamEvent && trackID == 0; + + // Submit an initial entering step if starting inside a sensitive volume. + // Build a synthetic TransportStep with zero energy loss at the start position. + auto startVolName = fSimulation->GetVolumeNameAt(pos); + if (IsSensitiveVolume(startVolName)) { + AtSimTransport::TransportStep initialStep; + initialStep.pdg = pdgCode; + initialStep.preVolumeName = startVolName; + initialStep.postVolumeName = startVolName; + initialStep.trackMass = mom.M(); // MeV/c^2 + initialStep.prePosition = pos; + initialStep.postPosition = pos; + initialStep.preMomentum = mom; + initialStep.postMomentum = mom; + if (!SubmitDetectorStep(initialStep, trackID, beamTrack, true, false)) + return; + } + + fSimulation->TransportParticle( + Z, A, pos, mom, [this, trackID, beamEvent](const AtSimTransport::TransportStep &step) { + const bool preSensitive = IsSensitiveVolume(step.preVolumeName); + const bool postSensitive = IsSensitiveVolume(step.postVolumeName); + + if (!preSensitive && !postSensitive) + return true; // skip non-sensitive regions + + // Detect volume boundary crossings, not just sensitive/non-sensitive transitions. + // In Geant4, IsTrackEntering() fires at every volume boundary. We replicate this + // by also detecting sensitive-to-sensitive transitions (e.g., window -> drift_volume). + const bool volumeChanged = step.preVolumeName != step.postVolumeName; + const bool entering = postSensitive && (!preSensitive || volumeChanged); + const bool exiting = preSensitive && (!postSensitive || volumeChanged); + const bool currentBeamTrack = beamEvent && trackID == 0; + + // When crossing between two sensitive volumes, split into exit + entry so the + // detector resets per-volume state (e.g., fELossAcc) at boundaries. + if (exiting && entering) { + SubmitDetectorStep(step, trackID, currentBeamTrack, false, true); + SubmitDetectorStep(step, trackID, currentBeamTrack, true, false); + return true; + } + + const bool keepTransporting = SubmitDetectorStep(step, trackID, currentBeamTrack, entering, exiting); + if (exiting && !postSensitive) + return false; + return keepTransporting; + }); + } catch (const std::invalid_argument &ex) { + LOG(fatal) << "AtSimTransportTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); + } +} + +bool AtSimTransportTask::SubmitDetectorStep(const AtSimTransport::TransportStep &step, int trackID, + bool beamTrack, bool entering, bool exiting) +{ + // When exiting, reference the pre-step state (where the particle was in the volume). + // Otherwise (entering or inside), reference the post-step state. + const auto &refPos = exiting ? step.prePosition : step.postPosition; + const auto &refMom = exiting ? step.preMomentum : step.postMomentum; + const auto &refVol = exiting ? step.preVolumeName : step.postVolumeName; + + AtTpc::StepState detectorStep; + detectorStep.trackID = trackID; + detectorStep.pdg = step.pdg; + detectorStep.volumeName = refVol.c_str(); + detectorStep.volumeID = kAtTpc; + detectorStep.detCopyID = 0; + detectorStep.entering = entering; + detectorStep.exiting = exiting; + detectorStep.stopping = !exiting && (step.postMomentum.E() - step.trackMass <= 1e-3); + detectorStep.disappeared = false; + detectorStep.energyLoss = step.energyLoss * kMeVToGeV; + detectorStep.timeNs = 0.; + detectorStep.trackLength = step.length * kMmToCm; + detectorStep.totalEnergy = refMom.E() * kMeVToGeV; + detectorStep.trackMass = step.trackMass * kMeVToGeV; + detectorStep.pos.SetXYZT(refPos.X() * kMmToCm, refPos.Y() * kMmToCm, refPos.Z() * kMmToCm, 0.); + detectorStep.mom.SetXYZT(refMom.Px() * kMeVToGeV, refMom.Py() * kMeVToGeV, refMom.Pz() * kMeVToGeV, + refMom.E() * kMeVToGeV); + detectorStep.posOut.SetXYZT(step.postPosition.X() * kMmToCm, step.postPosition.Y() * kMmToCm, + step.postPosition.Z() * kMmToCm, 0.); + detectorStep.momOut.SetXYZT(step.postMomentum.Px() * kMeVToGeV, step.postMomentum.Py() * kMeVToGeV, + step.postMomentum.Pz() * kMeVToGeV, step.postMomentum.E() * kMeVToGeV); + + const bool stopTransport = fDetector->ProcessStep(detectorStep); + return !stopTransport; +} + +XYZPoint AtSimTransportTask::FindSensitiveEntry(const XYZPoint &pos, const PxPyPzEVector &mom) const +{ + const auto dir = mom.Vect().Unit(); + if (dir.R() == 0.0) + throw std::invalid_argument("Particle momentum is zero; cannot search for detector entry"); + + if (gGeoManager == nullptr) + throw std::invalid_argument("No TGeoManager available for ray-trace"); + + // TGeoManager works in cm; SimpleSim uses mm + double point_cm[3] = {pos.X() * kMmToCm, pos.Y() * kMmToCm, pos.Z() * kMmToCm}; + double dir_unit[3] = {dir.X(), dir.Y(), dir.Z()}; + + // Use TGeo ray-tracing to find the exact boundary crossing into the first sensitive volume. + // This is faster and more precise than stepping in fixed increments. + gGeoManager->InitTrack(point_cm, dir_unit); + constexpr int maxBoundaries = 100; + for (int i = 0; i < maxBoundaries; ++i) { + auto *node = gGeoManager->FindNextBoundaryAndStep(); + if (node == nullptr) + break; + auto *vol = node->GetVolume(); + if (vol != nullptr && IsSensitiveVolume(vol->GetName())) { + const double *current = gGeoManager->GetCurrentPoint(); + return XYZPoint(current[0] * kCmToMm, current[1] * kCmToMm, current[2] * kCmToMm); + } + } + + throw std::invalid_argument("Particle does not intersect a sensitive detector volume"); +} + +bool AtSimTransportTask::IsSensitiveVolume(const std::string &volumeName) const +{ + return fDetector != nullptr && fDetector->CheckIfSensitive(volumeName); +} + +ClassImp(AtSimTransportTask); diff --git a/AtDigitization/AtSimTransportTask.h b/AtDigitization/AtSimTransportTask.h new file mode 100644 index 000000000..3eae4d2f5 --- /dev/null +++ b/AtDigitization/AtSimTransportTask.h @@ -0,0 +1,72 @@ +#ifndef AtSimTransportTask_h +#define AtSimTransportTask_h + +#include "AtSimParticleCollector.h" +#include "AtSimTransport.h" + +#include +#include + +#include + +#include +#include + +class AtTpc; +class TBuffer; +class TClass; +class TClonesArray; +class TMemberInspector; +class TParticle; + +class AtSimTransportTask : public FairTask { +public: + struct EventState { + bool hasEvent{false}; + bool beamEvent{false}; + bool transportPrimaries{true}; + }; + + explicit AtSimTransportTask(std::unique_ptr sim); + ~AtSimTransportTask() override = default; + + void SetSensitiveDetector(AtTpc *detector) { fDetector = detector; } + void SetDetector(AtTpc *detector) { SetSensitiveDetector(detector); } + + InitStatus Init() override; + void Exec(Option_t *option) override; + void Finish() override; + + /// Enable/disable automatic field extraction from FairRun. On by default for drop-in behavior. + void SetAutoConfigureField(bool enable) { fAutoConfigureField = enable; } + + AtSimTransport *GetSimulation() { return fSimulation.get(); } + +protected: + std::unique_ptr fSimulation{nullptr}; //! + AtTpc *fDetector{nullptr}; //! + bool fAutoConfigureField{true}; //! + AtSimParticleCollector fCollector; //! + TClonesArray *fMCTrackArray{nullptr}; //! + + virtual InitStatus InitEventSource(); + virtual EventState LoadEvent() = 0; + virtual void FinishEventSource(); + void ConfigureFieldFromFairRun(); + + void RegisterMCTrackBranch(); + void FillMCTracks(); + void TransportCurrentEvent(bool beamEvent); + void TransportParticle(const TParticle &particle, int trackID, bool beamEvent); + /// Build an AtTpc::StepState from a TransportStep and submit it to the detector. + /// Returns true if transport should continue (false if detector requested stop). + /// When exiting, position/momentum reference the pre-step state; otherwise post-step. + bool SubmitDetectorStep(const AtSimTransport::TransportStep &step, int trackID, bool beamTrack, bool entering, + bool exiting); + ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom) const; + bool IsSensitiveVolume(const std::string &volumeName) const; + + ClassDefOverride(AtSimTransportTask, 1); +}; + +#endif diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index 73c4fc218..7ce0af1f1 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -1,164 +1,92 @@ - #include "AtSimpleSimulation.h" -#include "AtELossModel.h" #include "AtMCPoint.h" -#include "AtSpaceChargeModel.h" // for AtSpaceChargeModel +#include "AtSimTransport.h" +#include "AtSpaceChargeModel.h" #include #include -#include // for TClonesArray -#include -#include -#include -#include // for TObject +#include -#include // for sqrt -#include // for invalid_argument -#include // for pair +#include thread_local TClonesArray AtSimpleSimulation::fMCPoints("AtMCPoint"); thread_local int AtSimpleSimulation::fTrackID = 0; -using SpaceChargeModel = std::shared_ptr; -using ModelPtr = std::shared_ptr; using XYZPoint = ROOT::Math::XYZPoint; -using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; -AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) -{ - TGeoManager *geo = TGeoManager::Import(geoFile.c_str()); +AtSimpleSimulation::AtSimpleSimulation() : fEngine(std::make_unique()) {} - if (gGeoManager == nullptr) - LOG(fatal) << "Failed to load geometry file " << geoFile << " " << geo; -} -AtSimpleSimulation::AtSimpleSimulation() +AtSimpleSimulation::AtSimpleSimulation(std::unique_ptr engine) : fEngine(std::move(engine)) { - if (gGeoManager == nullptr) - LOG(fatal) << "No geometry file loaded!"; + if (!fEngine) + fEngine = std::make_unique(); } -bool AtSimpleSimulation::ParticleID::operator<(const ParticleID &other) const +AtSimpleSimulation::AtSimpleSimulation(const std::string &geoFile) : fEngine(std::make_unique(geoFile)) { - if (A < other.A) { - return true; - } else if (A > other.A) { - return false; - } else { - return Z < other.Z; - } } -/// Takes position in mm -TGeoVolume *AtSimpleSimulation::GetVolume(const XYZPoint &point) +AtSimpleSimulation::AtSimpleSimulation(const std::string &geoFile, std::shared_ptr manager) + : fEngine(std::make_unique(geoFile, std::move(manager))) { - auto pointCm = point / 10.; - { - std::lock_guard lock(fGeoMutex); - TGeoNode *node = gGeoManager->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); - if (node == nullptr) { - return nullptr; - } - return node->GetVolume(); - } } -bool AtSimpleSimulation::IsInVolume(const std::string &volName, const XYZPoint &point) +AtSimpleSimulation::AtSimpleSimulation(std::shared_ptr manager) + : fEngine(std::make_unique(std::move(manager))) { - - TGeoVolume *volume = GetVolume(point); - if (volume == nullptr || volName != std::string(volume->GetName())) { - return false; - } - - return true; } -std::string AtSimpleSimulation::GetVolumeName(const XYZPoint &point) +void AtSimpleSimulation::NewEvent() { - TGeoVolume *volume = GetVolume(point); - if (volume == nullptr) { - return ""; - } - return volume->GetName(); + fMCPoints.Clear(); + fTrackID = 0; } -void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model) +void AtSimpleSimulation::RegisterBranch(std::string branchName, bool pers) { - ParticleID id = { - .A = A, - .Z = Z, - }; + auto ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before attempting to simulate an event."; + return; + } - fModels[id] = model; + ioMan->Register(branchName.c_str(), "AtTPC", &fMCPoints, pers); } std::pair AtSimpleSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func) { - auto modelIt = fModels.find({A, Z}); - if (modelIt == fModels.end()) - throw std::invalid_argument("Missing energy loss model for Z:" + std::to_string(Z) + " A:" + std::to_string(A)); - if (!IsInVolume("drift_volume", iniPos)) - throw std::invalid_argument("Position of particle is not in active volume but is in " + GetVolumeName(iniPos)); - - return SimulateParticle(modelIt->second, iniPos, iniMom, func); -} - -std::pair -AtSimpleSimulation::SimulateParticle(ModelPtr model, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func) -{ - // This is a new track - fTrackID++; - - auto pos = iniPos; - auto mom = iniMom; - double length = 0; - - // Go until we exit the volume or the KE is less than 1keV - while (IsInVolume("drift_volume", pos) && mom.E() - mom.M() > 1e-3 && func(pos, mom)) { - - if (isnan(pos.X()) || isnan(mom.X())) { - LOG(error) << "Failed to simulate a point with nan!"; - return {{0, 0, 0}, {0, 0, 0, 0}}; - } - // Direction particle is traveling - auto dir = mom.Vect().Unit(); - - // Get the energy loss from the model - double KE = mom.E() - mom.M(); - double eLoss = model->GetEnergyLoss(KE, fDistStep); - - // Update the momentum from the energy loss model. Assume the energy loss does not change - // the direction of the particle. - // newMom (x/y/z) = - auto E = mom.E() - eLoss; - double p = sqrt(E * E - mom.M2()); - mom.SetPxPyPzE(dir.X() * p, dir.Y() * p, dir.Z() * p, E); - - LOG(debug) << mom << " " << mom.M() << " " << iniMom.M(); - - pos += dir * fDistStep; - length += fDistStep; - AddHit(eLoss, pos, mom, length); - } - - return {pos, mom}; + if (fEngine->GetVolumeNameAt(iniPos) != fVolumeName) + throw std::invalid_argument("Position of particle is not in active volume but is in " + + fEngine->GetVolumeNameAt(iniPos)); + + ++fTrackID; + const auto &volName = fVolumeName; + + // The callback records hits while inside the volume, forwards to the user's callback, + // and stops transport when the particle exits. All checks use postPosition so hits + // are recorded at the step endpoint (matching the Geant4 convention). + return fEngine->TransportParticle(Z, A, iniPos, iniMom, + [this, &func, &volName](const AtSimTransport::TransportStep &step) { + if (fEngine->GetVolumeNameAt(step.postPosition) == volName) + AddHit(step.energyLoss, step.postPosition, step.postMomentum, step.length); + + // Call user's callback + if (!func(step.postPosition, step.postMomentum)) + return false; + + // Stop transport if the particle has exited the configured volume + if (fEngine->GetVolumeNameAt(step.postPosition) != volName) + return false; + + return true; + }); } -void AtSimpleSimulation::NewEvent() -{ - fMCPoints.Clear(); - fTrackID = 0; -} - -/** - * Units are mm, Mev, and Mev/c. - */ void AtSimpleSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length) { LOG(debug) << "Adding a hit at element " << fMCPoints.GetEntriesFast() << " in TClonesArray."; @@ -168,7 +96,7 @@ void AtSimpleSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzE mcPoint->SetTrackID(fTrackID); mcPoint->SetLength(length / 10.); // Convert to cm mcPoint->SetEnergyLoss(ELoss / 1000.); // Convert to GeV - mcPoint->SetVolName("drift_volume"); + mcPoint->SetVolName(fVolumeName.c_str()); if (fSCModel) { // In the simulation z = 0 is the window and z=1000 is the pad plane. @@ -181,16 +109,4 @@ void AtSimpleSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzE } else mcPoint->SetPosition(pos / 10.); // Convert to cm mcPoint->SetMomentum(mom.Vect() / 1000.); // Convert to GeV/c - // mcPoint->Print(nullptr); -} - -void AtSimpleSimulation::RegisterBranch(std::string branchName, bool perc) -{ - auto ioMan = FairRootManager::Instance(); - if (ioMan == nullptr) { - LOG(fatal) << "The IO manager was not instatiated before attempting to simulate an event."; - return; - } - - ioMan->Register(branchName.c_str(), "AtTPC", &fMCPoints, perc); } diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index 1d429affa..9907fd3fc 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -2,100 +2,108 @@ #define AT_SIMPLE_SIMULATION_H #include "AtMCPoint.h" +#include "AtSimTransport.h" -#include -#include // for XYZPoint -#include -#include // for XYZVector -#include -#include // for PxPyPzEVector +#include +#include +#include #include -#include -#include // for function -#include +#include #include -#include -#include // for string -#include // for pair +#include +#include + namespace AtTools { +class AtELossManager; class AtELossModel; -} -class TGeoVolume; +} // namespace AtTools class AtSpaceChargeModel; /** - * Class for simulating simple events using AtELossModels. - * Units in this class are MeV (energy), mm (distance) MeV/c (momentum). + * Standalone user-facing simulation wrapper: owns an AtSimTransport transport engine + * and records detector hits into a thread-local TClonesArray. + * + * Used by AtMCFitter, AtMCFission, and standalone analysis macros that manage their own + * event loop rather than running inside the FairRoot simulation pipeline. For pipeline + * integration (FairRoot event loop + AtTpc detector coupling), use AtSimTransportTask. + * + * Most configuration calls (AddModel, SetMagneticField, SetMaxStep, ...) are thin + * forwarders to the embedded AtSimTransport so historical single-class macros work + * without reaching through GetEngine(). */ class AtSimpleSimulation { -protected: - struct ParticleID { - int A; - int Z; - - bool operator<(const ParticleID &other) const; - }; - using SpaceChargeModel = std::shared_ptr; +public: using ModelPtr = std::shared_ptr; + using SpaceChargeModel = std::shared_ptr; using XYZPoint = ROOT::Math::XYZPoint; using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; - std::map fModels; - SpaceChargeModel fSCModel{nullptr}; - double fDistStep{1.}; // Distance step in mm for particles - std::mutex fGeoMutex; + // ---- Construction ---- - // Variables to across an entire event - static thread_local int fTrackID; - static thread_local TClonesArray fMCPoints; - -public: - /** - * Assumes that the IO manager has been initialized (it will attempt to construct the branch needed here). - */ - AtSimpleSimulation(std::string geoFile); AtSimpleSimulation(); - AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicity deleted because of std::mutex + explicit AtSimpleSimulation(std::unique_ptr engine); + explicit AtSimpleSimulation(const std::string &geoFile); + AtSimpleSimulation(const std::string &geoFile, std::shared_ptr manager); + explicit AtSimpleSimulation(std::shared_ptr manager); ~AtSimpleSimulation() = default; - void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); - void AddModel(int Z, int A, ModelPtr model); - void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = model; } - void SetDistanceStep(double step) { fDistStep = step; } //AddModel(Z, A, std::move(model)); } + void AddModel(int Z, int A, const std::string &materialName, ModelPtr model) + { + fEngine->AddModel(Z, A, materialName, std::move(model)); + } + /// Legacy signature from the develop-era API; `massAmu` is ignored (mass is now taken + /// from the TParticlePDG database / model itself). + void AddModel(int Z, int A, ModelPtr model, double /*massAmu*/) { AddModel(Z, A, std::move(model)); } + void SetManager(std::shared_ptr manager) { fEngine->SetManager(std::move(manager)); } + AtTools::AtELossManager *GetManager() { return fEngine->GetManager(); } + + void SetMagneticField(const XYZVector &bField) { fEngine->SetMagneticField(bField); } + void SetElectricField(const XYZVector &eField) { fEngine->SetElectricField(eField); } + void SetMaxStep(double stepMm) { fEngine->SetMaxStep(stepMm); } + void SetDistanceStep(double stepMm) { fEngine->SetMaxStep(stepMm); } + void SetStopTolerance(double stopTolMeV) { fEngine->SetStopTolerance(stopTolMeV); } + void SetMaxTransportSteps(int maxSteps) { fEngine->SetMaxTransportSteps(maxSteps); } + + // ---- Hit-recording configuration ---- + + void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = std::move(model); } + SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } + void SetStandaloneVolumeName(const std::string &name) { fVolumeName = name; } + + // ---- Event lifecycle ---- + + void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); void NewEvent(); /** - * Simulates a particle over a given distance and returns the position and momentum of the particle at the stoping - * point. Uses Z and A to provide a model to the protected version of SimulateParticle (see below for more - * information on the simulation). + * Simulate a particle within the configured standalone volume. + * Hits are recorded to the thread-local TClonesArray. */ std::pair SimulateParticle( int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); + std::function func = [](XYZPoint, PxPyPzEVector) { return true; }); AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } int GetNumPoints() { return fMCPoints.GetEntries(); } TClonesArray &GetPointsArray() { return fMCPoints; } - SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } -protected: - bool IsInVolume(const std::string &volName, const XYZPoint &point); - std::string GetVolumeName(const XYZPoint &point); +private: + std::unique_ptr fEngine; + SpaceChargeModel fSCModel{nullptr}; + std::string fVolumeName{"drift_volume"}; + + static thread_local int fTrackID; + static thread_local TClonesArray fMCPoints; - /** - * Simulates a particle over a given distance and returns the position and momentum of the particle at the stoping - * point. By default the particle will stop when it reaches the end of the TPC. A user defined function can test the - * position and momentum of the particle for each time step and stop it when a given condition is met (such as a - * depth in the TPC or an energy to stop at). - */ - std::pair SimulateParticle( - ModelPtr model, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); - TGeoVolume *GetVolume(const XYZPoint &pos); }; #endif // AT_SIMPLE_SIMULATION_H diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx deleted file mode 100644 index 9ab7b44e6..000000000 --- a/AtDigitization/AtTestSimulation.cxx +++ /dev/null @@ -1,40 +0,0 @@ -#include "AtTestSimulation.h" - -#include "AtSimpleSimulation.h" - -#include // for InitStatus, kSUCCESS - -#include -#include // for Math, XYZPoint -#include -#include // for XYZVector -#include // for LorentzVector -#include // for PxPyPzEVector - -#include -using namespace ROOT::Math; - -InitStatus AtTestSimulation::Init() -{ - fSimulation->RegisterBranch(); - - return kSUCCESS; -} - -void AtTestSimulation::Exec(Option_t *) -{ - fSimulation->NewEvent(); - - XYZPoint pos(0, 0, 500); - XYZVector momDir = XYZVector(0, 0, 1).Unit(); - // Assume Pb208 (mass is 207.93 amu) - double m = 207.93 * 931.4936; // Mev - // Assume initial KE is 35 MeV/u - double E = m + 35 * 207.93; - // Grab the momentum - double p = sqrt(E * E - m * m); - PxPyPzEVector mom(momDir.X() * p, momDir.Y() * p, momDir.Z() * p, E); - fSimulation->SimulateParticle(82, 208, pos, mom); -} - -ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h deleted file mode 100644 index bb8efa9f7..000000000 --- a/AtDigitization/AtTestSimulation.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifndef AtTestSimulation_h -#define AtTestSimulation_h - -#include "AtSimpleSimulation.h" // for AtSimpleSimulation - -#include // for THashConsistencyHolder, ClassDefOver... - -#include "FairTask.h" - -#include // for unique_ptr -#include // for move -class TBuffer; -class TClass; -class TMemberInspector; - -class AtTestSimulation : public FairTask { -protected: - std::unique_ptr fSimulation{nullptr}; //! - -public: - AtTestSimulation(std::unique_ptr sim) : fSimulation(std::move(sim)) {} - virtual ~AtTestSimulation() = default; - - virtual InitStatus Init() override; - virtual void Exec(Option_t *option) override; - virtual void Finish() override {} - AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } - - ClassDefOverride(AtTestSimulation, 1); -}; - -#endif /* AtTestSimulation_h */ diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index 670ff9bb9..ecaaefdd0 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -12,6 +12,7 @@ Set(DEPENDENCIES ATTPCROOT::AtSimulationData ATTPCROOT::AtParameter ATTPCROOT::AtData + ATTPCROOT::AtDetectors ATTPCROOT::AtMap ATTPCROOT::AtTools ) @@ -35,8 +36,12 @@ AtTrigger.cxx AtTriggerTask.cxx AtVectorResponse.cxx +AtSimTransport.cxx AtSimpleSimulation.cxx -AtTestSimulation.cxx +AtSimTransportTask.cxx +AtSimTransportGeneratorTask.cxx +AtSimTransportReplayTask.cxx +AtSimParticleCollector.cxx ) generate_target_and_root_library(${LIBRARY_NAME} @@ -44,3 +49,12 @@ generate_target_and_root_library(${LIBRARY_NAME} SRCS ${SRCS} DEPS_PUBLIC ${DEPENDENCIES} ) + +set(TEST_SRCS + AtSimTest.cxx +) + +attpcroot_generate_tests(${LIBRARY_NAME}Tests + SRCS ${TEST_SRCS} + DEPS ${LIBRARY_NAME} +) diff --git a/AtReconstruction/AtFitter/AtMCFitter.cxx b/AtReconstruction/AtFitter/AtMCFitter.cxx index bef820f99..9381aa642 100644 --- a/AtReconstruction/AtFitter/AtMCFitter.cxx +++ b/AtReconstruction/AtFitter/AtMCFitter.cxx @@ -10,7 +10,7 @@ #include "AtPatternEvent.h" // for AtPatternEvent #include "AtPulse.h" // for AtPulse #include "AtRawEvent.h" // for AtRawEvent -#include "AtSimpleSimulation.h" // for AtSimpleSimulation +#include "AtSimpleSimulation.h" #include "AtSimulatedPoint.h" // IWYU pragma: keep #include "AtSpaceChargeModel.h" diff --git a/AtReconstruction/AtFitter/AtMCFitter.h b/AtReconstruction/AtFitter/AtMCFitter.h index 69c19652d..233a7e1ca 100644 --- a/AtReconstruction/AtFitter/AtMCFitter.h +++ b/AtReconstruction/AtFitter/AtMCFitter.h @@ -21,7 +21,7 @@ class AtClusterize; // lines 15-15 class AtMap; // lines 18-18 class AtPatternEvent; // lines 12-12 class AtPulse; // lines 16-16 -class AtSimpleSimulation; // lines 14-14 +class AtSimpleSimulation; class AtDigiPar; class AtPSA; diff --git a/AtSimulationData/AtMCPoint.h b/AtSimulationData/AtMCPoint.h index 9cb89a3e8..7660c01a4 100644 --- a/AtSimulationData/AtMCPoint.h +++ b/AtSimulationData/AtMCPoint.h @@ -21,7 +21,7 @@ class TBuffer; class TClass; class TMemberInspector; -class AtSimpleSimulation; +class AtSimTransport; class AtMCPoint : public FairMCPoint { @@ -83,7 +83,7 @@ class AtMCPoint : public FairMCPoint { /** Output to screen **/ virtual void Print(const Option_t *opt) const override; - friend AtSimpleSimulation; + friend AtSimTransport; ClassDefOverride(AtMCPoint, 2) }; diff --git a/AtSimulationData/AtVertexPropagatorTest.cxx b/AtSimulationData/AtVertexPropagatorTest.cxx index 7d9ecb874..f85e7d36f 100644 --- a/AtSimulationData/AtVertexPropagatorTest.cxx +++ b/AtSimulationData/AtVertexPropagatorTest.cxx @@ -31,4 +31,41 @@ TEST(AtVertexPropagator, EndEvent) AtVertexPropagator::Instance()->EndEvent(); EXPECT_TRUE(AtVertexPropagator::Instance()->IsBeamEvent()); EXPECT_FALSE(AtVertexPropagator::Instance()->IsReactionEvent()); -} \ No newline at end of file +} + +TEST(AtVertexPropagator, SetVertexStoresBeamState) +{ + AtVertexPropagator::Instance()->ResetForTesting(); + AtVertexPropagator::Instance()->SetVertex(1.0, 2.0, 3.0, -1.0, -2.0, -3.0, 0.1, 0.2, 0.3, 12.5); + + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVx(), 1.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVy(), 2.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 3.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetInVx(), -1.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetInVy(), -2.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetInVz(), -3.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPx(), 0.1); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPy(), 0.2); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 0.3); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 12.5); +} + +TEST(AtVertexPropagator, ResetVertexClearsVertexAndTrackMetadata) +{ + AtVertexPropagator::Instance()->ResetForTesting(); + AtVertexPropagator::Instance()->SetVertex(1.0, 2.0, 3.0, -1.0, -2.0, -3.0, 0.1, 0.2, 0.3, 12.5); + AtVertexPropagator::Instance()->SetTrackEnergy(1, 5.0); + AtVertexPropagator::Instance()->SetTrackAngle(1, 45.0); + + AtVertexPropagator::Instance()->ResetVertex(); + + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVx(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVy(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetVz(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPx(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPy(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetTrackEnergy(1), 0.0); + EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetTrackAngle(1), 0.0); +} diff --git a/AtTools/AtELossManager.cxx b/AtTools/AtELossManager.cxx index 44a767964..3760c7549 100644 --- a/AtTools/AtELossManager.cxx +++ b/AtTools/AtELossManager.cxx @@ -1,550 +1,165 @@ #include "AtELossManager.h" -#include -#include +#include + +#include #include #include -#include // IWYU pragma: keep -#include - -ClassImp(AtTools::AtELossManager); +#include -AtTools::AtELossManager::AtELossManager() -{ - EvD = std::make_shared(); -} +namespace AtTools { -AtTools::AtELossManager::AtELossManager(std::string Eloss_file, Double_t Mass) +void AtELossManager::AddModel(int Z, int A, ModelPtr model) { - Double_t _IonEnergy = 0; - Double_t _dEdx_e = 0, _dEdx_n = 0; - Double_t _Range = 0; - Double_t _Stragg_lon = 0, _Stragg_lat = 0; - std::string aux; - - std::ifstream Read(Eloss_file.c_str()); - - // cout << " Opening " << Eloss_file <> aux >> aux >> aux >> aux >> aux >> aux; // The first line has 6 strings (columns' description). - points = 0; - - do { - Read >> _IonEnergy >> _dEdx_e >> _dEdx_n >> _Range >> _Stragg_lon >> _Stragg_lat; - points++; - - } while (!Read.eof()); - - Read.close(); - - // Go to the begining of the file and read it again to now save the info in the newly created arrays. - Read.open(Eloss_file.c_str()); - Read >> aux >> aux >> aux >> aux; - - for (int p = 0; p < points; p++) { - Read >> _IonEnergy >> _dEdx_e >> _dEdx_n >> _Range; - - IonEnergy.push_back(_IonEnergy); - dEdx_e.push_back(_dEdx_e); - dEdx_n.push_back(_dEdx_n); - Range.push_back(_Range); - } - - Energy_in_range = true; - IonMass = Mass; // In MeV/c^2 - c = 29.9792458; // Speed of light in cm/ns. - EvD = std::make_shared(); - } + fRegistered[{Z, A, std::string{}}] = std::move(model); } -AtTools::AtELossManager::~AtELossManager() = default; - -Double_t AtTools::AtELossManager::GetEnergyLossLinear(Double_t energy, Double_t distance) +void AtELossManager::AddModel(int Z, int A, const std::string &materialName, ModelPtr model) { - - int i = -1; - if (energy >= 0.01) { - // Look for two points for which the initial energy lays in between. This for-loop should find the points - // unless there was a big jump from the energy used in the last point and the energy used now. - - for (int p = 0; p < points - 1; p++) { - - if (energy >= IonEnergy[p] && energy < IonEnergy[p + 1]) { - - i = p + 1; - last_point = p; - - break; - } - } - - // If after this two loop i is still -1 it means the energy was out of range. - if (i == -1) { - // cout << "*** EnergyLoss Error: energy not within range: " << energy << endl; - - Energy_in_range = false; - return 0; - } - - // If the initial energy is within the range of the function, - // get the stopping power for the initial energy. - - Double_t E1 = IonEnergy[i - 1]; - Double_t E2 = IonEnergy[i]; - - Double_t dEdx_e1 = dEdx_e[i - 1]; - Double_t dEdx_e2 = dEdx_e[i]; - - Double_t dEdx_n1 = dEdx_n[i - 1]; - Double_t dEdx_n2 = dEdx_n[i]; - - // Interpolating the electric stopping power (from point 1 to 'e'). - Double_t dEdx_e1e = dEdx_e1 + (energy - E1) * (dEdx_e2 - dEdx_e1) / (E2 - E1); - - // Interpolating the nuclear stopping power (usually negligable). - Double_t dEdx_n1e = dEdx_n1 + (energy - E1) * (dEdx_n2 - dEdx_n1) / (E2 - E1); - - // The stopping power units are in MeV/mm so we multiply by 10 to convert to MeV/cm. - return ((dEdx_e1e + dEdx_n1e) * 10 * distance); - } // end of if(energy > 0.1){ - else { - return 0; - } -} - -/////////////////////////////////// SPLINE INTERPOLATION //////////////////////////////////////////// -double AtTools::AtELossManager::GetEnergyLoss(double energy /*MeV*/, double distance /*cm*/) -{ - Float_t a11 = 0.0, a12 = 0.0, a21 = 0.0, a22 = 0.0, a23 = 0.0, a32 = 0.0, a33 = 0.0; - Float_t b11 = 0.0, b22 = 0.0, b33 = 0.0; - Float_t a1 = 0.0, a2 = 0.0, b1 = 0.0, b2 = 0.0; - Float_t K0 = 0.0, K1 = 0.0, K2 = 0.0; - Float_t N1 = 0.0, N2 = 0.0, N3 = 0.0; - Float_t T1 = 0.0, T2 = 0.0, q1 = 0.0, q2 = 0.0; - - int i = -1; - if (energy < 0.01) - return (0); - - // if(energy < 0.01){ - // break; - // } - - // Look for two points for which the initial energy lies in between. - // This for-loop should find the points - // unless there was a big jump from the energy used in the - // last point and the energy used now. - - for (int p = 0; p < points - 1; p++) { - - if (energy >= IonEnergy[p] && energy < IonEnergy[p + 1]) { - - i = p + 1; - last_point = p; - - break; - } - } - // If after this two loop i is still -1 it means the energy was out of range. - - if (i == -1) { - - std::cout << "*** EnergyLoss Error: energy not within range: " << energy << "\n"; - - Energy_in_range = false; - return 0; - } - - // Ion Energy - Float_t x0 = IonEnergy[i - 1]; - Float_t x1 = IonEnergy[i]; - Float_t x2 = IonEnergy[i + 1]; - - // Total Energy Loss (electric + nuclear) for one step - Float_t y0 = dEdx_e[i - 1] + dEdx_n[i - 1]; - Float_t y1 = dEdx_e[i] + dEdx_n[i]; - Float_t y2 = dEdx_e[i + 1] + dEdx_n[i + 1]; - - a11 = 2 / (x1 - x0); - a12 = 1 / (x1 - x0); - a21 = 1 / (x1 - x0); - a22 = 2 * ((1 / (x1 - x0)) + (1 / (x2 - x1))); - a23 = 1 / (x2 - x1); - a32 = 1 / (x2 - x1); - a33 = 2 / (x2 - x1); - - b11 = 3 * ((y1 - y0) / ((x1 - x0) * (x1 - x0))); - b22 = 3 * (((y1 - y0) / ((x1 - x0) * (x1 - x0))) + ((y2 - y1) / ((x2 - x1) * (x2 - x1)))); - b33 = 3 * ((y2 - y1) / ((x2 - x1) * (x2 - x1))); - - // mathematical terms to calculate curvatures. - N1 = (a21 * a33 * a12 - a11 * (a22 * a33 - a23 * a32)) / (a33 * a12); - N2 = (b22 * a33 - a23 * b33) / a33; - N3 = b11 * (a22 * a33 - a23 * a32) / (a33 * a12); - // cout<<"N1="< FinalE) { - dist += StepSize; - Elast = E; - E = E - GetEnergyLoss(E, StepSize); - } - - return ((dist - StepSize) - (StepSize * (Elast - FinalE) / (E - Elast))); + fCache.clear(); } -//////////////////////////////////////////////////////////////////////////////////////// -// Calulates the ion's path length in cm. -//////////////////////////////////////////////////////////////////////////////////////// -Double_t AtTools::AtELossManager::GetPathLength(Float_t InitialEnergy /*MeV*/, Float_t FinalEnergy /*MeV*/, - Float_t DeltaT /*ns*/) +std::vector> AtELossManager::ExtractComposition(const TGeoMaterial *material) { - Double_t L = 0, DeltaX = 0; - Double_t Kn = InitialEnergy; - Double_t Kn1 = InitialEnergy; - Int_t n = 0; - - if (IonMass == 0) - std::cout << "*** EnergyLoss Error: Path length cannot be calculated for IonMass = 0." - << "\n"; - else { - - // The path length (L) is proportional to sqrt(Kn). - // After the sum, L will be multiplied by the proportionality factor. - - while (Kn > FinalEnergy && Kn1 > FinalEnergy && n < (int)pow(10.0, 6)) { - - // L += sqrt(Kn); // 2016-06-15 changed - L += sqrt((Kn + Kn1) / 2) * sqrt(2 / IonMass) * DeltaT * c; // DeltaL going from point n to n+1. - // cout<<"1 = "<= (int)pow(10.0, 6)) { - std::cout << "*** EnergyLoss Warning: Full path length wasn't reached after 10^6 iterations." - << "\n"; - L = 0; - } else - // L *= sqrt(2/IonMass)*DeltaT*c; - L *= 1.0; + if (material == nullptr) { + LOG(error) << "AtELossManager::ExtractComposition: null material"; + return {}; } - return L; -} -/////////////////////////////////////////////////////////////////////////////////////// -Double_t AtTools::AtELossManager::LoadRange(Float_t energy1) -{ - Int_t i = -1; - Double_t Range1 = 0; - - if (energy1 >= 0.01) { // greater than= 10 keV - - for (Int_t p = last_point1 - 1; p < points1 - 1; p++) { - if (last_point1 >= 0) - if (energy1 >= IonEnergy[p] && energy1 < IonEnergy[p + 1]) { - i = p + 1; - last_point1 = p; - break; - } - } - - if (i == -1) { - for (Int_t p = 0; p < last_point1 - 1; p++) { - if (energy1 >= IonEnergy[p] && energy1 < IonEnergy[p + 1]) { - i = p + 1; - last_point1 = p; - break; - } - } - } - - if (i == -1) { - std::cout << "*** EnergyLoss Error: energy not within range: " << energy1 << "\n"; - Energy_in_range = false; - return 0; - } - Range1 = Range[i]; - return Range1; + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + int Z = static_cast(std::round(material->GetZ())); + int A = static_cast(std::round(material->GetA())); + return {{A, Z, 1}}; } - return (0); -} -////////////////////////////////////////////////////////////////////////////////////////////////////////////// -// Calulates the ion's time of flight in ns. -////////////////////////////////////////////////////////////////////////////////////////////////////////////// + int nElem = mixture->GetNelements(); + if (nElem <= 0) + return {}; -Double_t AtTools::AtELossManager::GetTimeOfFlight(Double_t InitialEnergy, Double_t PathLength, Double_t StepSize) -{ - Double_t TOF = 0; - Double_t Kn = InitialEnergy; - int Steps = (int)(PathLength / StepSize); - - if (IonMass == 0) { - std::cout << "Error: Time of flight cannot be calculated because mass is zero." - << "\n"; + std::vector weights(nElem); + std::vector masses(nElem); + for (int i = 0; i < nElem; ++i) { + weights[i] = mixture->GetWmixt()[i]; + masses[i] = mixture->GetAmixt()[i]; } - else { - - for (int n = 0; n < Steps; n++) { + auto stoich = WeightFractionsToStoichiometry(weights, masses); - TOF += sqrt(IonMass / (2 * Kn)) * StepSize / c; // DeltaT going from point n to n+1. - Kn -= GetEnergyLoss(Kn, StepSize); // After the TOF is added the K.E. at point n+1 is calc.} - } - return TOF; + std::vector> result; + result.reserve(nElem); + for (int i = 0; i < nElem; ++i) { + int A = static_cast(std::round(mixture->GetAmixt()[i])); + int Z = static_cast(std::round(mixture->GetZmixt()[i])); + result.emplace_back(A, Z, stoich[i]); } - return (0); + return result; } -////////////////////////////////////////////////////////////////////////////////////////////////////////////// -void AtTools::AtELossManager::SetIonMass(Double_t Mass) +std::vector AtELossManager::WeightFractionsToStoichiometry(const std::vector &weights, + const std::vector &atomicMasses) { - IonMass = Mass; -} -///////////////////////////////////////////////////////////////////////////////////////////////////////////// -// Lookup Table Extension -void AtTools::AtELossManager::InitializeLookupTables(Double_t MaximumEnergy, Double_t MaximumDistance, Double_t DeltaE, - Double_t DeltaD) -{ - - int noE = (int)ceil(MaximumEnergy / DeltaE); - int noD = (int)ceil(MaximumDistance / DeltaD); - - fMaximumEnergy = MaximumEnergy; - fMaximumDistance = MaximumDistance; - fDeltaD = DeltaD; - fDeltaE = DeltaE; - - EtoDtab.resize(noE); - DtoEtab.resize(noD); - - // Double_t D; - int i = 0; - //----------------------------------------------------------- - DtoEtab[0] = MaximumEnergy; - std::cout << " Number of distance entries " << noD << "\n"; - - for (i = 1; i < noD; i++) { - // DtoEtab[i] = GetFinalEnergy(InitialEnergy,D,50000); - - // DtoEtab[i] = GetFinalEnergy(DtoEtab[i-1],DeltaD,0.05*DeltaE); - DtoEtab[i] = GetFinalEnergy(DtoEtab[i - 1], DeltaD, 0.05 * DeltaD); - - // cout<<"2: "< molar(weights.size()); + for (size_t i = 0; i < weights.size(); ++i) { + if (atomicMasses[i] <= 0) { + LOG(error) << "AtELossManager::WeightFractionsToStoichiometry: non-positive atomic mass"; + return {}; } + molar[i] = weights[i] / atomicMasses[i]; } - // Double_t E; - int j = 0; - - std::cout << " Number of Energy entries " << noE << "\n"; + double minMolar = *std::min_element(molar.begin(), molar.end(), [](double a, double b) { + if (a <= 0) + return false; + if (b <= 0) + return true; + return a < b; + }); - EtoDtab[0] = 0.; - for (j = 1; j < noE; j++) { - // EtoDtab[j] = GetDistance(MaximumEnergy,E,50000); - // EtoDtab[j] = GetDistance(MaximumEnergy,MaximumEnergy-j*DeltaE,0.1,5500); - EtoDtab[j] = EtoDtab[j - 1] + - GetDistance((MaximumEnergy - (j - 1) * DeltaE), (MaximumEnergy - (j)*DeltaE), (0.05 * DeltaD)); - if (j % 1000 == 0) { - // cout << " Passed " << j << endl; - } - } - // cout << " Passed " << j << endl; - //----------------------------------------------------------- - - /* - for (i=0; i stoich(weights.size()); + for (size_t i = 0; i < molar.size(); ++i) { + stoich[i] = std::max(1, static_cast(std::round(molar[i] / minMolar))); } + return stoich; } -Double_t AtTools::AtELossManager::GetLookupEnergy(Double_t InitialEnergy, Double_t distance) +double AtELossManager::EffectiveMeanIonization(const TGeoMaterial *material) { + if (material == nullptr) + return 0; - Double_t D1, D2, D; - Double_t E1, E2, E; - int index; - int imhere; - - if (InitialEnergy < 0 || InitialEnergy > fMaximumEnergy) { - return (-1.); + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + int Z = static_cast(std::round(material->GetZ())); + return 13.5 * Z; // eV, Bloch approximation } - // Find the distance for which the initial energy is matched, interpolating - index = (int)floor((fMaximumEnergy - InitialEnergy) / fDeltaE); + int nElem = mixture->GetNelements(); + double numerator = 0; + double denominator = 0; - D1 = EtoDtab[index]; - D2 = EtoDtab[index + 1]; + for (int i = 0; i < nElem; ++i) { + double w = mixture->GetWmixt()[i]; + double Z = mixture->GetZmixt()[i]; + double A = mixture->GetAmixt()[i]; + if (A <= 0 || Z <= 0) + continue; - E1 = fMaximumEnergy - index * fDeltaE; - E2 = fMaximumEnergy - (index + 1) * fDeltaE; - - D = (InitialEnergy - E1) / (E2 - E1) * (D2 - D1) + D1; - // cout << " 1: "<< index << ' ' < fMaximumDistance)) { - // if (((D+distance)> MaximumDistance)){ - // if (D+distance <=0){ - std::cout << "i m here" - << "\n"; - // imhere++; - return (0.); + double I_i = 13.5 * Z; // eV, Bloch approximation per element + double frac = w * Z / A; + numerator += frac * std::log(I_i); + denominator += frac; } - // Lookup what energy is reached for (D + distance) and interpolate - index = (int)floor((D + distance) / fDeltaD); - - E1 = DtoEtab[index]; - E2 = DtoEtab[index + 1]; + if (denominator <= 0) + return 13.5; - D1 = index * fDeltaD; - D2 = (index + 1) * fDeltaD; - - E = ((D + distance) - D1) / (D2 - D1) * (E2 - E1) + E1; - // cout <<" 2: "<< E1 << ' ' << E2 << ' ' << D1 << ' ' << D2 << ' ' << E << endl; - - // cout<<"E == "< -#include +#include "AtELossModel.h" +#include #include #include +#include #include -class TBuffer; -class TClass; -class TGraph; -class TMemberInspector; +class TGeoMaterial; namespace AtTools { -class AtELossManager : public TObject { - +/** + * Energy-loss model manager: accepts pre-built models and serves them to transport code. + * + * The base class is "accept-only": users register models with AddModel(); GetModel() serves + * the registered ones and returns nullptr otherwise. Subclasses override GenerateModel() to + * synthesize a model on cache-miss (see AtELossManagerBetheBloch, AtELossManagerCATIMA). + * + * Models can be registered material-agnostically (AddModel(Z, A, model)) or material- + * specifically (AddModel(Z, A, materialName, model)). Lookup prefers material-specific + * over material-agnostic, so MCFission / AtMCFitter callers that pass a single (Z, A) + * table without material context continue to work, while validation macros that want + * different models per material can be precise. + * + * Static helpers (ExtractComposition, WeightFractionsToStoichiometry, + * EffectiveMeanIonization) translate TGeoMaterial/TGeoMixture into the forms needed + * by concrete generators and are used by the BetheBloch and CATIMA subclasses. + */ +class AtELossManager { public: - AtELossManager(); - AtELossManager(std::string Eloss_file, Double_t Mass); - ~AtELossManager(); - - Double_t GetEnergyLossLinear(Double_t energy, Double_t distance); - Double_t GetEnergyLoss(Double_t energy, Double_t distance); - Double_t GetInitialEnergy(Double_t FinalEnergy, Double_t PathLength, Double_t StepSize); - Double_t GetFinalEnergy(Double_t InitialEnergy, Double_t PathLength, Double_t StepSize); - Double_t GetDistance(Double_t InitialE, Double_t FinalE, Double_t StepSize); - Double_t GetPathLength(Float_t InitialEnergy, Float_t FinalEnergy, Float_t DeltaT); - Double_t LoadRange(Float_t energy1); - Double_t GetTimeOfFlight(Double_t InitialEnergy, Double_t PathLength, Double_t StepSize); - void SetIonMass(Double_t IonMass); - void InitializeLookupTables(Double_t MaximumEnergy, Double_t MaximumDistance, Double_t DeltaE, Double_t DeltaD); - void PrintLookupTables(); - Double_t GetLookupEnergy(Double_t InitialEnergy, Double_t distance); + using ModelPtr = std::shared_ptr; -private: - std::shared_ptr EvD; + virtual ~AtELossManager() = default; + + /// Register a pre-built model for (Z, A), served regardless of material. + /// Use this for SRIM/LISE tables or legacy single-model workflows (MCFission, AtMCFitter). + void AddModel(int Z, int A, ModelPtr model); + + /// Register a pre-built model for (Z, A) in a specific material. + /// Takes priority over material-agnostic registrations and over GenerateModel(). + void AddModel(int Z, int A, const std::string &materialName, ModelPtr model); - Double_t c{29.9792458}; - Double_t IonMass{0}; + /// Look up a model for (Z, A) in the given material. + /// Order: material-specific registration → material-agnostic registration → + /// auto-generated cache → GenerateModel() (cached on first call) → nullptr. + /// @param material may be nullptr only if a material-agnostic registration exists. + ModelPtr GetModel(int Z, int A, double massAmu, const TGeoMaterial *material); - std::vector IonEnergy; - std::vector dEdx_e; - std::vector dEdx_n; - std::vector Range; + /// Drop models produced by GenerateModel(); manually registered models stay. + void ClearCache(); - Double_t fMaximumEnergy{}; - Double_t fMaximumDistance{}; - Double_t fDeltaD{}; - Double_t fDeltaE{}; + // ---- Shared utility functions for extracting material info from TGeo ---- - std::vector EtoDtab; - std::vector DtoEtab; + /// Extract elemental composition as (A, Z, stoichiometry) tuples. + static std::vector> ExtractComposition(const TGeoMaterial *material); - Int_t points{0}; - Int_t last_point{0}; - Int_t points1{0}; - Int_t last_point1{0}; - Bool_t Energy_in_range{true}; - Bool_t GoodELossFile{false}; + /// Convert weight fractions and atomic masses to approximate integer stoichiometry. + static std::vector + WeightFractionsToStoichiometry(const std::vector &weights, const std::vector &atomicMasses); - ClassDef(AtELossManager, 1) + /// Compute effective mean ionization energy (eV) using Bragg's additivity rule. + static double EffectiveMeanIonization(const TGeoMaterial *material); + +protected: + /// Override to synthesize a model from physics parameters. Default returns nullptr, + /// i.e. the base class is accept-only. Subclasses (BetheBloch, CATIMA) override. + virtual ModelPtr GenerateModel(int /*Z*/, int /*A*/, double /*massAmu*/, const TGeoMaterial * /*material*/) + { + return nullptr; + } + +private: + // (Z, A, materialName) — materialName == "" for material-agnostic entries. + using Key = std::tuple; + + std::map fRegistered; ///< From AddModel(...) + std::map fCache; ///< From GenerateModel(...) }; + } // namespace AtTools -#endif +#endif // ATELOSSMANAGER_H diff --git a/AtTools/AtELossManagerBetheBloch.cxx b/AtTools/AtELossManagerBetheBloch.cxx new file mode 100644 index 000000000..165dce7a3 --- /dev/null +++ b/AtTools/AtELossManagerBetheBloch.cxx @@ -0,0 +1,71 @@ +#include "AtELossManagerBetheBloch.h" + +#include "AtELossBetheBloch.h" + +#include + +#include + +#include +#include + +namespace AtTools { + +namespace { +constexpr double kAmuToMeV = 931.494; // MeV/c² per amu +} // namespace + +AtELossManager::ModelPtr +AtELossManagerBetheBloch::GenerateModel(int Z, int A, double massAmu, const TGeoMaterial *material) +{ + if (material == nullptr) { + LOG(error) << "AtELossManagerBetheBloch::GenerateModel: null material"; + return nullptr; + } + + double density = material->GetDensity(); // g/cm³ + double projMass = massAmu * kAmuToMeV; + + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + int matZ = static_cast(std::round(material->GetZ())); + int matA = static_cast(std::round(material->GetA())); + double I_eV = 13.5 * matZ; + + auto model = std::make_shared(Z, projMass, matZ, matA, density, I_eV); + LOG(info) << "AtELossManagerBetheBloch: generated model for Z=" << Z << " A=" << A << " in " + << material->GetName() << " (pure Z=" << matZ << ", density=" << density << " g/cm³)"; + return model; + } + + int nElem = mixture->GetNelements(); + double sumWZoverA = 0; + double sumWoverA = 0; + + for (int i = 0; i < nElem; ++i) { + double w = mixture->GetWmixt()[i]; + double z = mixture->GetZmixt()[i]; + double a = mixture->GetAmixt()[i]; + if (a <= 0) + continue; + sumWZoverA += w * z / a; + sumWoverA += w / a; + } + + if (sumWoverA <= 0) { + LOG(error) << "AtELossManagerBetheBloch::GenerateModel: invalid mixture composition"; + return nullptr; + } + + int effZ = std::max(1, static_cast(std::round(sumWZoverA / sumWoverA))); + int effA = std::max(1, static_cast(std::round(1.0 / sumWoverA))); + double I_eV = EffectiveMeanIonization(material); + + auto model = std::make_shared(Z, projMass, effZ, effA, density, I_eV); + LOG(info) << "AtELossManagerBetheBloch: generated model for Z=" << Z << " A=" << A << " in " << material->GetName() + << " (mixture, eff Z=" << effZ << " A=" << effA << " I=" << I_eV << " eV, density=" << density + << " g/cm³)"; + return model; +} + +} // namespace AtTools diff --git a/AtTools/AtELossManagerBetheBloch.h b/AtTools/AtELossManagerBetheBloch.h new file mode 100644 index 000000000..280dbd87f --- /dev/null +++ b/AtTools/AtELossManagerBetheBloch.h @@ -0,0 +1,24 @@ +#ifndef ATELOSSMANAGERBETHEBLOCH_H +#define ATELOSSMANAGERBETHEBLOCH_H + +#include "AtELossManager.h" + +namespace AtTools { + +/** + * Energy-loss manager that synthesizes AtELossBetheBloch models on cache-miss. + * + * Accepted models (via AddModel) are served first; when a particle/material pair has no + * registration, GenerateModel() builds a Bethe-Bloch model from density, Z/A, and + * effective mean ionization. + * + * Mixtures: effective Z/A via electron-density weighting, I via Bragg's additivity. + */ +class AtELossManagerBetheBloch : public AtELossManager { +protected: + ModelPtr GenerateModel(int Z, int A, double massAmu, const TGeoMaterial *material) override; +}; + +} // namespace AtTools + +#endif // ATELOSSMANAGERBETHEBLOCH_H diff --git a/AtTools/AtELossManagerCATIMA.cxx b/AtTools/AtELossManagerCATIMA.cxx new file mode 100644 index 000000000..8d69bf017 --- /dev/null +++ b/AtTools/AtELossManagerCATIMA.cxx @@ -0,0 +1,35 @@ +#include "AtELossManagerCATIMA.h" + +#include "AtELossCATIMA.h" + +#include + +#include + +namespace AtTools { + +AtELossManager::ModelPtr AtELossManagerCATIMA::GenerateModel(int Z, int A, double massAmu, const TGeoMaterial *material) +{ + if (material == nullptr) { + LOG(error) << "AtELossManagerCATIMA::GenerateModel: null material"; + return nullptr; + } + + double density = material->GetDensity(); // g/cm³ + auto composition = ExtractComposition(material); + + if (composition.empty()) { + LOG(error) << "AtELossManagerCATIMA::GenerateModel: could not extract composition from " << material->GetName(); + return nullptr; + } + + auto model = std::make_shared(density, composition); + model->SetProjectile(A, Z, massAmu); + model->SetConfig(fConfig); + + LOG(info) << "AtELossManagerCATIMA: generated model for Z=" << Z << " A=" << A << " in " << material->GetName() + << " (density=" << density << " g/cm³, " << composition.size() << " elements)"; + return model; +} + +} // namespace AtTools diff --git a/AtTools/AtELossManagerCATIMA.h b/AtTools/AtELossManagerCATIMA.h new file mode 100644 index 000000000..3e66441f3 --- /dev/null +++ b/AtTools/AtELossManagerCATIMA.h @@ -0,0 +1,30 @@ +#ifndef ATELOSSMANAGERCATIMA_H +#define ATELOSSMANAGERCATIMA_H + +#include "AtELossManager.h" + +#include + +namespace AtTools { + +/** + * Energy-loss manager that synthesizes AtELossCATIMA models on cache-miss. + * + * Applies a user-configured catima::Config to every generated model so options like + * z_effective choice or calculation method are consistent across the simulation. + */ +class AtELossManagerCATIMA : public AtELossManager { +public: + void SetConfig(catima::Config cfg) { fConfig = cfg; } + catima::Config GetConfig() const { return fConfig; } + +protected: + ModelPtr GenerateModel(int Z, int A, double massAmu, const TGeoMaterial *material) override; + +private: + catima::Config fConfig{catima::default_config}; +}; + +} // namespace AtTools + +#endif // ATELOSSMANAGERCATIMA_H diff --git a/AtTools/AtELossManagerTest.cxx b/AtTools/AtELossManagerTest.cxx new file mode 100644 index 000000000..59f38e401 --- /dev/null +++ b/AtTools/AtELossManagerTest.cxx @@ -0,0 +1,264 @@ +#include "AtELossManager.h" + +#include "AtELossBetheBloch.h" +#include "AtELossManagerBetheBloch.h" +#include "AtELossModel.h" + +#include +#include + +#include +#include +#include +#include +#include + +using namespace AtTools; + +// TGeoMaterial/TGeoMixture constructors require gGeoManager to exist. +// Materials are heap-allocated so TGeoManager can own and clean them up. +class GeoFixture : public ::testing::Test { +protected: + static void SetUpTestSuite() + { + if (gGeoManager == nullptr) + new TGeoManager("test", "test geometry"); + } +}; + +// ---- WeightFractionsToStoichiometry tests ---- + +TEST(AtELossManagerUtils, WaterStoichiometry) +{ + std::vector weights = {0.111898, 0.888102}; + std::vector masses = {1.008, 15.999}; + auto stoich = AtELossManager::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 2u); + EXPECT_EQ(stoich[0], 2); // H + EXPECT_EQ(stoich[1], 1); // O +} + +TEST(AtELossManagerUtils, CO2Stoichiometry) +{ + std::vector weights = {0.272916, 0.727084}; + std::vector masses = {12.011, 15.999}; + auto stoich = AtELossManager::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 2u); + EXPECT_EQ(stoich[0], 1); // C + EXPECT_EQ(stoich[1], 2); // O +} + +TEST(AtELossManagerUtils, PureElementStoichiometry) +{ + std::vector weights = {1.0}; + std::vector masses = {4.003}; + auto stoich = AtELossManager::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 1u); + EXPECT_EQ(stoich[0], 1); +} + +TEST(AtELossManagerUtils, EmptyInput) +{ + auto stoich = AtELossManager::WeightFractionsToStoichiometry({}, {}); + EXPECT_TRUE(stoich.empty()); +} + +// ---- ExtractComposition tests ---- + +TEST_F(GeoFixture, ExtractPureMaterial) +{ + auto *mat = new TGeoMaterial("He_extract", 4.003, 2, 1.664e-4); + auto comp = AtELossManager::ExtractComposition(mat); + + ASSERT_EQ(comp.size(), 1u); + auto [A, Z, s] = comp[0]; + EXPECT_EQ(A, 4); + EXPECT_EQ(Z, 2); + EXPECT_EQ(s, 1); +} + +TEST_F(GeoFixture, ExtractMixture) +{ + auto *mix = new TGeoMixture("HeCO2_extract", 3, 1.0e-3); + mix->AddElement(4, 2, 0.90); + mix->AddElement(12, 6, 0.03); + mix->AddElement(16, 8, 0.07); + + auto comp = AtELossManager::ExtractComposition(mix); + ASSERT_EQ(comp.size(), 3u); + + EXPECT_EQ(std::get<1>(comp[0]), 2); // He Z + EXPECT_EQ(std::get<1>(comp[1]), 6); // C Z + EXPECT_EQ(std::get<1>(comp[2]), 8); // O Z + + for (const auto &[a, z, s] : comp) + EXPECT_GE(s, 1); +} + +TEST(AtELossManagerUtils, ExtractNullMaterial) +{ + auto comp = AtELossManager::ExtractComposition(nullptr); + EXPECT_TRUE(comp.empty()); +} + +// ---- EffectiveMeanIonization tests ---- + +TEST_F(GeoFixture, PureMaterialIonization) +{ + auto *mat = new TGeoMaterial("H_ionize", 1.008, 1, 8.376e-5); + double I = AtELossManager::EffectiveMeanIonization(mat); + EXPECT_DOUBLE_EQ(I, 13.5); +} + +TEST_F(GeoFixture, PureHeliumIonization) +{ + auto *mat = new TGeoMaterial("He_ionize", 4.003, 2, 1.664e-4); + double I = AtELossManager::EffectiveMeanIonization(mat); + EXPECT_DOUBLE_EQ(I, 27.0); +} + +TEST_F(GeoFixture, MixtureIonization) +{ + auto *mix = new TGeoMixture("H2_ionize", 1, 8.376e-5); + mix->AddElement(1, 1, 1.0); + double I = AtELossManager::EffectiveMeanIonization(mix); + EXPECT_NEAR(I, 13.5, 0.5); +} + +TEST(AtELossManagerUtils, NullIonization) +{ + double I = AtELossManager::EffectiveMeanIonization(nullptr); + EXPECT_DOUBLE_EQ(I, 0.0); +} + +// ---- BetheBloch manager (autogenerate) tests ---- + +TEST_F(GeoFixture, BBGenerateFromPureMaterial) +{ + auto *mat = new TGeoMaterial("H_bbgen", 1.008, 1, 6.5643e-5); + AtELossManagerBetheBloch manager; + + auto model = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(model, nullptr); + EXPECT_GT(model->GetdEdx(10.0), 0.0); +} + +TEST_F(GeoFixture, BBGenerateFromMixture) +{ + auto *mix = new TGeoMixture("HeCO2_bbgen", 3, 1.0e-3); + mix->AddElement(4, 2, 0.90); + mix->AddElement(12, 6, 0.03); + mix->AddElement(16, 8, 0.07); + + AtELossManagerBetheBloch manager; + auto model = manager.GetModel(2, 4, 4.002603, mix); + ASSERT_NE(model, nullptr); + EXPECT_GT(model->GetdEdx(10.0), 0.0); +} + +TEST_F(GeoFixture, GenerateNullMaterialReturnsNull) +{ + AtELossManagerBetheBloch manager; + auto model = manager.GetModel(1, 1, 1.007825, nullptr); + EXPECT_EQ(model, nullptr); +} + +// ---- Registration and priority tests ---- + +namespace { +// Minimal stand-in AtELossModel: fixed values so tests can detect which instance was served. +class StubModel : public AtELossModel { +public: + explicit StubModel(double tag) : AtELossModel(1), fTag(tag) {} + double GetdEdx(double /*e*/) const override { return fTag; } + double GetRange(double /*ei*/, double /*ef*/ = 0) const override { return 0; } + double GetEnergyLoss(double /*ei*/, double /*d*/) const override { return 0; } + double GetEnergy(double /*ei*/, double /*d*/) const override { return 0; } + double GetElossStraggling(double /*ei*/, double /*ef*/) const override { return 0; } + double GetdEdxStraggling(double /*ei*/, double /*ef*/) const override { return 0; } + double GetRangeVariance(double /*e*/) const override { return 0; } + +private: + double fTag; +}; +} // namespace + +TEST_F(GeoFixture, MaterialAgnosticRegistrationServedRegardlessOfMaterial) +{ + auto *mat = new TGeoMaterial("M_agnostic", 1.008, 1, 6.5643e-5); + AtELossManager manager; // accept-only base class + manager.AddModel(1, 1, std::make_shared(42.0)); + + auto model = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(model, nullptr); + EXPECT_DOUBLE_EQ(model->GetdEdx(1.0), 42.0); +} + +TEST_F(GeoFixture, MaterialSpecificRegistrationTakesPriority) +{ + auto *mat = new TGeoMaterial("M_priority", 1.008, 1, 6.5643e-5); + AtELossManager manager; + manager.AddModel(1, 1, std::make_shared(1.0)); + manager.AddModel(1, 1, "M_priority", std::make_shared(2.0)); + + auto model = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(model, nullptr); + EXPECT_DOUBLE_EQ(model->GetdEdx(1.0), 2.0); // material-specific wins +} + +TEST_F(GeoFixture, SameZaDifferentMaterialsGiveDifferentModels) +{ + auto *mat1 = new TGeoMaterial("M_first", 4.003, 2, 1.664e-4); + auto *mat2 = new TGeoMaterial("M_second", 1.008, 1, 6.5643e-5); + AtELossManagerBetheBloch manager; + + auto m1 = manager.GetModel(1, 1, 1.007825, mat1); + auto m2 = manager.GetModel(1, 1, 1.007825, mat2); + ASSERT_NE(m1, nullptr); + ASSERT_NE(m2, nullptr); + EXPECT_NE(m1.get(), m2.get()); +} + +TEST_F(GeoFixture, CacheReturnsSameInstanceOnRepeatedCalls) +{ + auto *mat = new TGeoMaterial("M_cache", 1.008, 1, 6.5643e-5); + AtELossManagerBetheBloch manager; + + auto m1 = manager.GetModel(1, 1, 1.007825, mat); + auto m2 = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(m1, nullptr); + EXPECT_EQ(m1.get(), m2.get()); +} + +TEST_F(GeoFixture, ClearCacheDropsGeneratedButKeepsRegistered) +{ + auto *mat = new TGeoMaterial("M_clear", 1.008, 1, 6.5643e-5); + AtELossManagerBetheBloch manager; + manager.AddModel(2, 4, std::make_shared(7.0)); + + auto generated = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(generated, nullptr); + + manager.ClearCache(); + + // Registered model survives the ClearCache and can still be served. + auto registered = manager.GetModel(2, 4, 4.002603, mat); + ASSERT_NE(registered, nullptr); + EXPECT_DOUBLE_EQ(registered->GetdEdx(1.0), 7.0); + + // Generated model is re-created on next query (fresh pointer after cache clear). + auto regenerated = manager.GetModel(1, 1, 1.007825, mat); + ASSERT_NE(regenerated, nullptr); + EXPECT_NE(regenerated.get(), generated.get()); +} + +TEST_F(GeoFixture, BaseManagerDoesNotGenerate) +{ + auto *mat = new TGeoMaterial("M_nogen", 1.008, 1, 6.5643e-5); + AtELossManager manager; // no GenerateModel override + auto model = manager.GetModel(1, 1, 1.007825, mat); + EXPECT_EQ(model, nullptr); +} diff --git a/AtTools/AtKinematics.cxx b/AtTools/AtKinematics.cxx index 2431d34f2..886357b3c 100644 --- a/AtTools/AtKinematics.cxx +++ b/AtTools/AtKinematics.cxx @@ -303,5 +303,25 @@ double EtoA(double mass) { return mass / 931.5; } +double GetSpeed(double p, double mass) +{ + return GetBeta(p, mass) * fC; +} +double GetRelMomFromKE(double KE, double mass) +{ + return std::sqrt((KE + mass) * (KE + mass) - mass * mass); +} +double KE(ROOT::Math::XYZVector mom, double mass) +{ + return std::sqrt(mom.Mag2() + mass * mass) - mass; +} +double KE(double mom, double mass) +{ + return std::sqrt(mom * mom + mass * mass) - mass; +} +ROOT::Math::XYZVector GetVel(ROOT::Math::XYZVector mom, double mass) +{ + return mom / Get4Vector(mom, mass).E() * fC; +} } // namespace AtTools::Kinematics diff --git a/AtTools/AtKinematics.h b/AtTools/AtKinematics.h index b0d343fd2..dd28563e2 100644 --- a/AtTools/AtKinematics.h +++ b/AtTools/AtKinematics.h @@ -7,6 +7,8 @@ #ifndef ATKINEMATICS_H #define ATKINEMATICS_H +#include +#include // for XYZVector #include #include // for PxPyPzEVector #include // for Double_t, THashConsistencyHolder, Int_t, ClassDef @@ -64,6 +66,8 @@ class AtKinematics : public TObject { namespace Kinematics { +static constexpr double fC = 299792458.0; // Speed of light in m/s + double GetGamma(double KE, double m1, double m2); double GetGamma(double beta); double GetVelocity(double gamma); @@ -73,6 +77,19 @@ double GetBeta(double p, double mass); double GetRelMom(double gamma, double mass); double AtoE(double Amu); double EtoA(double mass); +double GetSpeed(double p, double mass); ///< Speed of a particle given momentum (MeV/c) and mass (MeV/c²) in m/s +double GetRelMomFromKE(double KE, double mass); + +/** + * Kinetic energy from 3-momentum vector (MeV/c) and mass (MeV/c²). Returns MeV. + */ +double KE(ROOT::Math::XYZVector mom, double mass); +double KE(double mom, double mass); + +/** + * Velocity vector in m/s from 3-momentum (MeV/c) and mass (MeV/c²). + */ +ROOT::Math::XYZVector GetVel(ROOT::Math::XYZVector mom, double mass); template ROOT::Math::PxPyPzEVector Get4Vector(Vector mom, double m) diff --git a/AtTools/AtPropagator.cxx b/AtTools/AtPropagator.cxx new file mode 100644 index 000000000..b35c18ca5 --- /dev/null +++ b/AtTools/AtPropagator.cxx @@ -0,0 +1,492 @@ +#include "AtPropagator.h" + +#include "AtKinematics.h" + +#include + +// Butcher tableau coefficients for Dormand–Prince 5(4) method +// https://en.wikipedia.org/wiki/Dormand%E2%80%93Prince_method + +static constexpr double c[7] = {0.0, 1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0}; +static constexpr double a[7][6] = { + {0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + {3.0 / 40.0, 9.0 / 40.0, 0.0, 0.0, 0.0, 0.0}, + {44.0 / 45.0, -56.0 / 15.0, 32.0 / 9.0, 0.0, 0.0, 0.0}, + {19372.0 / 6561.0, -25360.0 / 2187.0, 64448.0 / 6561.0, -212.0 / 729.0, 0.0, 0.0}, + {9017.0 / 3168.0, -355.0 / 33.0, 46732.0 / 5247.0, 49.0 / 176.0, -5103.0 / 18656.0, 0.0}, + {35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0}}; +// b (5th-order) +static constexpr double b[7] = {35.0 / 384.0, 0.0, 500.0 / 1113.0, 125.0 / 192.0, -2187.0 / 6784.0, 11.0 / 84.0, 0.0}; +// b* (4th-order) +static constexpr double bs[7] = {5179.0 / 57600.0, 0.0, 7571.0 / 16695.0, 393.0 / 640.0, -92097.0 / 339200.0, + 187.0 / 2100.0, 1.0 / 40.0}; + +using ROOT::Math::Plane3D; +using ROOT::Math::XYZPoint; +using ROOT::Math::XYZVector; +namespace AtTools { + +AtPropagator::XYZVector AtPropagator::Force(XYZPoint pos, XYZVector mom) const +{ + auto v = Kinematics::GetVel(mom, fState.fMass); + + auto F_lorentz = fState.fQ * (fEField + v.Cross(fBField)); + LOG(debug) << "F_lorentz: " << F_lorentz; + + auto dedx = fScalingFactor * fELossModel->GetdEdx(Kinematics::KE(mom, fState.fMass)); // Stopping power in MeV/mm + auto dedx_si = dedx * 1.60218e-10; // de_dx in SI units (J/m) + + auto drag = -dedx_si * mom.Unit(); + LOG(debug) << "drag: " << drag << " mom " << mom << " dedx " << dedx_si; + + return F_lorentz + drag; // Force in N +} + +AtPropagator::XYZVector AtPropagator::dpds(const XYZPoint &pos, const XYZVector &mom) const +{ + // Calculate the force acting on the particle at the given position and momentum + auto speed = Kinematics::GetSpeed(mom.R(), fState.fMass); // Speed in m/s + return Force(pos, mom) / speed; +} +AtPropagator::XYZVector AtPropagator::d2xds2(const XYZPoint &pos, const XYZVector &mom) const +{ + auto phat = mom.Unit(); // Unit vector in the direction of momentum + auto p = mom.R(); // Magnitude of the momentum + auto dpds_vec = dpds(pos, mom); // Derivative of momentum w.r.t. arc length + + return 1 / p * (dpds_vec - phat * (phat.Dot(dpds_vec))); // Second derivative of position w.r.t. arc length +} + +void AtPropagator::PropagateOneStep(AtStepper &stepper) +{ + if (fState.h == 0) + fState.h = stepper.GetInitialStep(); // Set the initial step size + + stepper.fDeriv = [this](const XYZPoint &pos, const XYZVector &mom) { return this->Derivatives(pos, mom); }; + + auto result = stepper.Step(fState); + if (!result) { + LOG(error) << "Integration step failed, aborting propagation."; + return; // Abort propagation if step failed + } + fState = result; // Update the internal state +} + +void AtPropagator::PropagateToMeasurementSurface(const AtMeasurementSurface &surface, AtStepper &stepper) +{ + LOG(debug) << "Propagating to measurement surface from: " << fState.fPos; + fState.h = stepper.GetInitialStep(); // Set the initial step size + + auto KE_initial = Kinematics::KE(fState.fMom, fState.fMass); + if (KE_initial < fStopTol) { + LOG(warning) << "Initial kinetic energy is below stopping threshold, cannot propagate."; + return; // Cannot propagate if the initial kinetic energy is below the stopping threshold + } + stepper.fDeriv = [this](const XYZPoint &pos, const XYZVector &mom) { return this->Derivatives(pos, mom); }; + + while (true) { + LOG(debug) << "Position: " << GetPosition().X() / 10 << ", " << GetPosition().Y() / 10 << ", " + << GetPosition().Z() / 10; + LOG(debug) << "Momentum: " << GetMomentum().X() << ", " << GetMomentum().Y() << ", " << GetMomentum().Z(); + + auto result = stepper.Step(fState); + if (!result) { + LOG(error) << "Integration step failed, aborting propagation."; + return; // Abort propagation if step failed + } + fState = result; // Update the internal state + + bool reachedMeasurementPoint = surface.PassedSurface(fState); + bool particleStopped = Kinematics::KE(fState.fMom, fState.fMass) < fStopTol; + bool momentumReversed = (fState.fLastMom.Dot(fState.fMom) < 0); + + if (reachedMeasurementPoint && !particleStopped && !momentumReversed) { + // We reached the measurement surface, so we should figure out how far we are from the measurement point + LOG(debug) << "------ Reached measurement surface ------"; + double finalH = (fState.fLastPos - fState.fPos).R(); // Distance traveled in the last step + double approach = surface.Distance(fState.fLastPos); + + LOG(debug) << "Distance to plane: " << approach << " mm"; + LOG(debug) << "Final step size: " << finalH << " mm"; + LOG(debug) << "Position before surface: " << fState.fLastPos.X() << ", " << fState.fLastPos.Y() << ", " + << fState.fLastPos.Z(); + LOG(debug) << "Momentum before surface: " << fState.fLastMom.X() << ", " << fState.fLastMom.Y() << ", " + << fState.fLastMom.Z(); + + finalH = approach * 1e-3; // Convert to meters for the RK4 step + fState.h = finalH; // Set the step size to the distance to the surface + fState.fPos = fState.fLastPos; // Set position to last position + fState.fMom = fState.fLastMom; // Set momentum to last momentum + result = stepper.Step(fState); + if (!result) { + LOG(error) << "Failed to propagate to measurement point, aborting."; + return; // Abort propagation if step failed + } + auto origH = fState.h; // Save original step size + fState = result; // Update the internal state + fState.h = origH; // Restore original step size + } + + if (particleStopped || momentumReversed) { + // In this case the particle stopped before hitting the plane + // we should throw a warning to let the user know that there wasn't + // enough energy to reach the surface. + LOG(warning) << "------ Particle stopped before reaching measurement surface ------"; + + // Calculate how far to travel before stopping + double KE_last = Kinematics::KE(fState.fLastMom, fState.fMass); + double deltaE = KE_last - fStopTol; + deltaE = std::max(deltaE, 0.0); // Ensure we don't have negative energy loss + + LOG(debug) << "Last KE: " << KE_last << " MeV"; + LOG(debug) << "Energy to loose to stop: " << deltaE << " MeV"; + double h_Stop = deltaE / fELossModel->GetdEdx(KE_last); // Distance to stop in mm + LOG(debug) << "Estimated distance to stop: " << h_Stop << " mm"; + + fState.h = h_Stop * 1e-3; // Convert to meters for the RK4 step + fState.fPos = fState.fLastPos; // Set position to last position + fState.fMom = fState.fLastMom; // Set momentum to last momentum + result = stepper.Step(fState); + if (!result) { + LOG(error) << "Failed to propagate to stopping point, aborting."; + fState.status = AtPropagator::StepStateStatus::kStopped; + return; // Abort propagation if step failed + } + auto origH = fState.h; // Save original step size + fState = result; // Update the internal state + fState.h = origH; // Restore original step size + LOG(debug) << "Propagated to stopping point: " << fState.fPos.X() << ", " << fState.fPos.Y() << ", " + << fState.fPos.Z(); + LOG(debug) << "Energy after stopping: " << Kinematics::KE(fState.fMom, fState.fMass) << " MeV"; + + while (surface.fClipToSurface) { + fScalingFactor = 0; // Turn off energy loss. + + // If we still haven't intersected the surface, we need to adjust the step size + double h = surface.Distance(fState.fPos); // Reduce step size so we hit the surface + if (h <= fDistTol || surface.PassedSurface(result)) { + reachedMeasurementPoint = true; + break; + } + LOG(debug) << "Propagating to surface after stopping with step size: " << h << " mm"; + fState.h = h * 1e-3; // Convert to meters for the RK4 step + result = stepper.Step(fState); + if (!result) { + LOG(error) << "Failed to propagate to surface after stopping, aborting."; + return; // Abort propagation if step failed + } + fState = result; // Update the internal state + LOG(debug) << "New position after adjusting step size: " << fState.fPos.X() << ", " << fState.fPos.Y() + << ", " << fState.fPos.Z(); + } + fState.fLastMom = fState.fMom; + fState.fMom = XYZVector(0, 0, 0); // Set momentum to zero since we stopped + } + + if (reachedMeasurementPoint || particleStopped || momentumReversed) { + double distanceToSurface = surface.Distance(fState.fPos); + + double KE_final = Kinematics::KE(fState.fMom, fState.fMass); + LOG(debug) << "Initial KE: " << KE_initial << " MeV"; + LOG(debug) << "Final KE: " << KE_final << " MeV"; + auto calc_eLoss = KE_initial - KE_final; // Energy loss in MeV + LOG(debug) << "Particle stopped: " << particleStopped; + LOG(debug) << "Reached measurement point: " << reachedMeasurementPoint; + LOG(debug) << "Distance to surface: " << distanceToSurface << " mm"; + LOG(debug) << "Calculated energy loss: " << calc_eLoss << " MeV"; + LOG(debug) << "Scaling factor: " << fScalingFactor; + LOG(debug) << "Final Position: " << fState.fPos.X() << ", " << fState.fPos.Y() << ", " << fState.fPos.Z(); + LOG(debug) << "------- End of RK4 interation ---------" << std::endl; + + // If we reached the measurement surface, we should project the position onto the surface + if (reachedMeasurementPoint) { + fState.fPos = surface.ProjectToSurface(fState.fPos); + } + if (particleStopped || momentumReversed) + fState.status = AtPropagator::StepStateStatus::kStopped; + LOG(debug) << "Projected on surface: " << fState.fPos.X() << ", " << fState.fPos.Y() << ", " + << fState.fPos.Z(); + LOG(debug) << "Final Momentum: " << fState.fMom.X() << ", " << fState.fMom.Y() << ", " << fState.fMom.Z(); + return; + } + } // End of loop over RK4 integration +} + +void AtPropagator::PropagateToMeasurementSurface(const AtMeasurementSurface &surface, double eLoss, AtStepper &stepper) +{ + LOG(debug) << "Propagating to surface with eLoss: " << eLoss; + + if (eLoss == 0) { + LOG(warn) << "No energy loss specified, propagating without energy loss adjustment."; + PropagateToMeasurementSurface(surface, stepper); + return; + } + + int iterations = 0; + double calc_eLoss = 0; + double KE_initial = Kinematics::KE(fState.fMom, fState.fMass); + auto initialMom = fState.fMom; // Save initial momentum for energy loss calculation + auto initialPos = fState.fPos; // Save initial position for energy loss calculation + + while (std::abs(calc_eLoss - eLoss) > 1e-4) { + fState.fMom = initialMom; // Reset position and momentum to initial values for the next iteration + fState.fPos = initialPos; + + LOG(debug) << "Running iteration " << iterations << " with scaling factor: " << fScalingFactor + << " and energy loss: " << calc_eLoss; + + if (iterations > 100) { + // If we are not converging, we should probably throw an error. + throw std::runtime_error("Energy loss did not converge after 100 iterations."); + } + + iterations++; + PropagateToMeasurementSurface(surface, stepper); // Propagate without energy loss adjustment + + double KE_final = Kinematics::KE(fState.fMom, fState.fMass); + calc_eLoss = KE_initial - KE_final; // Energy loss in MeV + fScalingFactor *= eLoss / calc_eLoss; + LOG(debug) << "Desired energy loss: " << eLoss << " MeV"; + LOG(debug) << "Calculated energy loss: " << calc_eLoss << " MeV"; + LOG(debug) << "Difference: " << calc_eLoss - eLoss << " MeV"; + LOG(debug) << "New scaling factor: " << fScalingFactor; + LOG(debug) << "Condition: " << (std::abs(calc_eLoss - eLoss) > 1e-4); + + } // End loop over energy loss convergence + + LOG(debug) << "Energy loss converged after " << iterations << " iterations."; + + fScalingFactor = 1; // Reset scaling factor after convergence +} + +AtPropagator::StepState AtRK4Stepper::Step(const AtPropagator::StepState &state) const +{ + + auto result = state; + result.fLastPos = state.fPos; + result.fLastMom = state.fMom; + result.hUsed = state.h; + result.status = AtPropagator::StepStateStatus::kSuccess; + + auto h = state.h; // Step size in m + auto fPos = state.fPos; + auto fMom = state.fMom; + + LOG(debug) << "Starting RK4 step with initial position: " << fPos.X() << ", " << fPos.Y() << ", " << fPos.Z(); + LOG(debug) << "Initial momentum: " << fMom.X() << ", " << fMom.Y() << ", " << fMom.Z(); + LOG(debug) << "Step size (h): " << h << " m"; + + auto [x_k1, p_k1] = + fDeriv(fPos, fMom); // The derivative of the position is then just the unit vector of the momentum. + + auto x_2 = fPos + x_k1 * h / 2; // Position at the midpoint + auto p_2 = fMom + p_k1 * h / 2 / fReltoSImom; // Momentum at the midpoint + + auto [x_k2, p_k2] = fDeriv(x_2, p_2); // Derivative at the midpoint + + auto x_3 = fPos + x_k2 * h / 2; // Position at the second midpoint + auto p_3 = fMom + p_k2 * h / 2 / fReltoSImom; // Momentum at the second midpoint + auto [x_k3, p_k3] = fDeriv(x_3, p_3); + + auto x_4 = fPos + x_k3 * h; // Position at the end of the step + auto p_4 = fMom + p_k3 * h / fReltoSImom; // Momentum at the end of the step + auto [x_k4, p_k4] = fDeriv(x_4, p_4); + + auto dpds_SI = (p_k1 + 2 * p_k2 + 2 * p_k3 + p_k4) / 6; // "Force" in SI units (N) + auto dxds_SI = (x_k1 + 2 * x_k2 + 2 * x_k3 + x_k4) / 6; // Position derivative in SI units (m) + + LOG(debug) << "dp/ds (SI units): " << dpds_SI.X() << ", " << dpds_SI.Y() << ", " << dpds_SI.Z(); + LOG(debug) << "dx/ds (SI units): " << dxds_SI.X() << ", " << dxds_SI.Y() << ", " << dxds_SI.Z(); + + auto mom_SI = fReltoSImom * fMom; + mom_SI += dpds_SI * h; // Update momentum in SI units (kg m/s) + result.fMom = mom_SI / fReltoSImom; // Convert back to + + auto pos_SI = fPos * 1e-3; // Convert position to SI units (m) + pos_SI += dxds_SI * h; // Update position in SI units (m + result.fPos = pos_SI * 1e3; // Convert back to mm + + return result; +} + +AtPropagator::StepState AtRK4AdaptiveStepper::Step(const AtPropagator::StepState &state) const +{ + + // Take h to be the step size in m. + auto result = state; + result.fLastPos = state.fPos; + result.fLastMom = state.fMom; + result.status = AtPropagator::StepStateStatus::kSuccess; + + auto h = state.h; // Step size in m + auto fPos = state.fPos; + auto fMom = state.fMom; + + // Take h to be the step size in m. + // Use DP5(4) method for adaptive step size control. + + auto x0_mm = fPos; + auto p0 = fMom; + LOG(debug) << "Starting RK4 step with initial position: " << x0_mm.X() << ", " << x0_mm.Y() << ", " << x0_mm.Z(); + LOG(debug) << "Initial momentum: " << p0.X() << ", " << p0.Y() << ", " << p0.Z(); + + while (true) { + auto x_SI = fPos * 1e-3; // Convert position to SI units (m) + auto p_SI = fReltoSImom * fMom; // Convert momentum to SI units (kg m/s) + XYZVector kx[7]; // kx[i] will hold the position derivatives (unitless) + XYZVector kp[7]; // kp[i] will hold the momentum derivatives (SI units) + + // anonymous lambda to calculate and store the kx and kp values. Input is SI units. + auto calc_k = [&](const XYZPoint &x, const XYZVector &p, int i) { + auto [k_x, k_p] = fDeriv(x * 1e3, p / fReltoSImom); + kx[i] = k_x; // Store the position derivative (unitless) + kp[i] = k_p; // Store the momentum derivative (SI units) + }; + + // anonymous lambda to calculate the position and momentum at the i-th stage + auto calc_xp = [&](int i) { + XYZVector dx(0, 0, 0); + XYZVector dp(0, 0, 0); + for (int j = 0; j < i; ++j) { + dx = dx + kx[j] * a[i][j]; + dp = dp + kp[j] * a[i][j]; + } + XYZPoint x = x_SI + dx * h; + XYZVector p = p_SI + dp * h; + return std::make_pair(x, p); + }; + + // Calculate kx and kp for each stage + // build stage 0 + calc_k(x_SI, p_SI, 0); + + // build stage 1 + auto [x1, p1] = calc_xp(1); + calc_k(x1, p1, 1); // k1 + + // build stage 2 + auto [x2, p2] = calc_xp(2); + calc_k(x2, p2, 2); // k2 + + // build stage 3 + auto [x3, p3] = calc_xp(3); + calc_k(x3, p3, 3); // k3 + + // build stage 4 + auto [x4, p4] = calc_xp(4); + calc_k(x4, p4, 4); // k4 + + // build stage 5 + auto [x5, p5] = calc_xp(5); + calc_k(x5, p5, 5); // k5 + + // build stage 6 + auto [x6, p6] = calc_xp(6); + calc_k(x6, p6, 6); // k6 + + // Calculate the new position and momentum using the 5th-order method + XYZVector dx(0, 0, 0); + XYZVector dp(0, 0, 0); + for (int i = 0; i < 7; ++i) { + dx = dx + kx[i] * b[i]; + dp = dp + kp[i] * b[i]; + } + XYZPoint x_new_5 = x_SI + dx * h; // New position in SI units (m) + XYZVector p_new_5 = p_SI + dp * h; // New momentum in SI units (kg m/s) + + // Calculate the new position and momentum using the 4th-order method + dx = XYZVector(0, 0, 0); + dp = XYZVector(0, 0, 0); + for (int i = 0; i < 7; ++i) { + dx = dx + kx[i] * bs[i]; + dp = dp + kp[i] * bs[i]; + } + XYZPoint x_new_4 = x_SI + dx * h; // New position in SI units (m) + XYZVector p_new_4 = p_SI + dp * h; // New momentum in SI units (kg m/s) + + auto x_4_mm = x_new_4 * 1e3; // Convert back to mm + auto p_4_MeV = p_new_4 / fReltoSImom; // Convert back to MeV/c + auto x_5_mm = x_new_5 * 1e3; // Convert back to mm + auto p_5_MeV = p_new_5 / fReltoSImom; // Convert back to MeV/c + LOG(debug) << "New position (5th order): " << x_5_mm.X() << ", " << x_5_mm.Y() << ", " << x_5_mm.Z(); + LOG(debug) << "New momentum (5th order): " << p_5_MeV.X() << ", " << p_5_MeV.Y() << ", " << p_5_MeV.Z(); + LOG(debug) << "New position (4th order): " << x_4_mm.X() << ", " << x_4_mm.Y() << ", " << x_4_mm.Z(); + LOG(debug) << "New momentum (4th order): " << p_4_MeV.X() << ", " << p_4_MeV.Y() << ", " << p_4_MeV.Z(); + + // Convert back to mm and MeV/c + XYZVector x_err = (x_5_mm - x_4_mm); // Error in position (mm) + XYZVector p_err = (p_5_MeV - p_4_MeV); // Error in momentum (MeV/c) + + // Calculate the overall error + double ex = x_err.X() / (fAtolPos + fRtol * std::abs(x_5_mm.X())); + double ey = x_err.Y() / (fAtolPos + fRtol * std::abs(x_5_mm.Y())); + double ez = x_err.Z() / (fAtolPos + fRtol * std::abs(x_5_mm.Z())); + + double ep_x = p_err.X() / (fAtolMom + fRtol * std::abs(p_5_MeV.X())); + double ep_y = p_err.Y() / (fAtolMom + fRtol * std::abs(p_5_MeV.Y())); + double ep_z = p_err.Z() / (fAtolMom + fRtol * std::abs(p_5_MeV.Z())); + + // Combine errors (norm) + double err = std::sqrt(ex * ex + ey * ey + ez * ez + ep_x * ep_x + ep_y * ep_y + ep_z * ep_z); + + double factor = std::pow(err, -1.0 / 5.0); // Adjust step size based on error + factor = std::clamp(factor, 0.25, 4.0); // Clamp factor to reasonable limits + double hNew = h * factor; + // We now know the local error at this point. Now we need to decide to accept the point or not. + if (err <= 1.0) { + // Accept the step + result.fPos = x_5_mm; // Update position in mm + result.fMom = p_5_MeV; // Update momentum in MeV/c + LOG(debug) << "Accepted step with error: " << err; + LOG(debug) << "Step size: " << h << " m"; + LOG(debug) << "New step size: " << hNew << " m"; + LOG(debug) << "New Position: " << result.fPos.X() << ", " << result.fPos.Y() << ", " << result.fPos.Z(); + LOG(debug) << "New Momentum: " << result.fMom.X() << ", " << result.fMom.Y() << ", " << result.fMom.Z(); + + result.h = std::clamp(hNew, fMinStep, fMaxStep); // Adjust the step size for the next iteration + result.hUsed = h; // Store the step size used + result.status = AtPropagator::StepStateStatus::kSuccess; // Step accepted + return result; + } else { + // Reject the step and reduce the step size + LOG(debug) << "Rejected step with error: " << err; + LOG(debug) << "Step size: " << h << " m"; + LOG(debug) << "Reducing step size to: " << hNew << " m"; + + result.h = hNew; // Reduce step size for next iteration + h = hNew; // Update h for the next iteration + if (result.h < fMinStep || result.h > fMaxStep) { + LOG(error) << "Step size out of bounds, aborting propagation."; + result.status = AtPropagator::StepStateStatus::kInvalidStepSize; + result.hUsed = h; + return result; // Abort propagation if step size is out of bounds + } + } + } +} + +bool AtMeasurementPoint::PassedSurface(AtPropagator::StepState &result) const +{ + // Check if the particle has passed the measurement point + auto lastDeriv = (fPoint - result.fLastPos).Dot(result.fLastMom.Unit()); + auto currDeriv = (fPoint - result.fPos).Dot(result.fMom.Unit()); + LOG(debug) << "Last Derivative: " << lastDeriv << ", Current Derivative: " << currDeriv; + return lastDeriv * currDeriv <= 0; +} + +bool AtMeasurementPlane::PassedSurface(AtPropagator::StepState &result) const +{ + // Check if the particle has crossed the plane this step. + auto prevSign = fPlane.Distance(result.fLastPos) > 0 ? 1 : -1; + auto currSign = fPlane.Distance(result.fPos) > 0 ? 1 : -1; + return (prevSign != currSign); +} + +ROOT::Math::XYZPoint AtMeasurementPlane::ProjectToSurface(const ROOT::Math::XYZPoint &pos) const +{ + // Project the position onto the measurement plane + auto dist = fPlane.Distance(pos); + return pos - dist * fPlane.Normal(); +} +} // namespace AtTools diff --git a/AtTools/AtPropagator.h b/AtTools/AtPropagator.h new file mode 100644 index 000000000..6e5e707d3 --- /dev/null +++ b/AtTools/AtPropagator.h @@ -0,0 +1,277 @@ +#ifndef ATPROPAGATOR_H +#define ATPROPAGATOR_H + +#include "AtELossModel.h" + +#include + +#include "Math/Plane3D.h" +#include "Math/Point3D.h" +#include "Math/Vector3D.h" + +namespace AtTools { + +class AtMeasurementSurface; +class AtStepper; + +/** + * @brief Class for propagating particles through a medium. + * + * This class is responsible for simulating the propagation of particles + * through a medium, taking into account energy loss and other effects. + * Uses an AtELossModel to calculate the energy loss and propagates particles + * in the presence of electric and magnetic fields. + * + * Class is designed to be used with a single particle type. Create a new instance of the + * class if the material or particle type changes. + */ +class AtPropagator { +public: + enum class StepStateStatus { + kSuccess, /// Step was successful + kInvalidStepSize, /// Step failed + kStopped /// Particle is stopped in material + }; + struct StepState { + ROOT::Math::XYZPoint fPos; /// Position of the particle in mm + ROOT::Math::XYZVector fMom; /// Momentum of the particle in MeV/c + ROOT::Math::XYZPoint fLastPos; /// Last position of the particle in mm + ROOT::Math::XYZVector fLastMom; /// Last momentum of the particle in MeV/c + double fMass = 0; /// Mass of the particle in MeV/c^2 + double fQ = 0; /// Charge of the particle in Coulombs + double h = 0; /// Step size to use in m + double hUsed = 0; /// Step size used in this step in m + StepStateStatus status = StepStateStatus::kSuccess; /// Whether the step was successful + + operator bool() const { return status == StepStateStatus::kSuccess; } + }; + +protected: + using XYZVector = ROOT::Math::XYZVector; + using XYZPoint = ROOT::Math::XYZPoint; + using Plane3D = ROOT::Math::Plane3D; + + // Variables used for the force + XYZVector fEField{0, 0, 0}; // Electric field vector + XYZVector fBField{0, 0, 0}; // Magnetic field vector + const AtELossModel *fELossModel; // Energy loss model (non-owning; caller ensures lifetime) + + // Internal state variables for the propagator + StepState fState; /// Current state of the particle + + // Tolerances and limits + double fETol = 1e-4; /// Energy tolerance for convergence when fixing energy loss + double fStopTol = 0.01; /// Maximum kinetic energy to consider the particle stopped (MeV) + double fDistTol = 1e-2; /// Distance tolerance when considering positions equal. (mm) + + static constexpr double fReltoSImom = 1.60218e-13 / 299792458; // Conversion factor from MeV/c to kg m/s (SI units) + +public: + double fScalingFactor = 1.0; /// Scaling factor for energy loss + + /** + * @brief Constructor for AtPropagator. + * @param charge Charge of the particle in Coulombs. + * @param mass Mass of the particle in MeV/c^2. + * @param elossModel Energy loss model to use for the particle. + */ + AtPropagator(double charge, double mass, const AtELossModel *elossModel) : fELossModel(elossModel) + { + fState.fMass = mass; + fState.fQ = charge; + } + AtPropagator(AtPropagator &&) = default; + /** + * @brief Set the electric field (V/m) + */ + void SetEField(const XYZVector &eField) { fEField = eField; } + void SetEField(double ex, double ey, double ez) { fEField.SetXYZ(ex, ey, ez); } + + /** + * @brief Set the magnetic field (T) + */ + void SetBField(const XYZVector &bField) { fBField = bField; } + void SetBField(double bx, double by, double bz) { fBField.SetXYZ(bx, by, bz); } + + /** + * @brief Set the state of the particle. + * + * @param pos Position of the particle in mm. + * @param mom Momentum of the particle in MeV/c. + */ + void SetState(const XYZPoint &pos, const XYZVector &mom) + { + fState.fPos = pos; + fState.fMom = mom; + } + const StepState &GetState() const { return fState; } + const AtELossModel *GetELossModel() const { return fELossModel; } + /// Swap in a new energy-loss model (non-owning; caller ensures lifetime). Used by + /// transport drivers that re-query a manager on volume/material changes. + void SetELossModel(const AtELossModel *elossModel) { fELossModel = elossModel; } + + XYZPoint GetPosition() const { return fState.fPos; } + XYZVector GetMomentum() const { return fState.fMom; } + + /** + * @brief Propagate the particle to the point of closest approach to the given point. + * + * Propagate to a given point in space, adjusting the magnitude of the stopping power + * to ensure that a specific about of energy is lost during the propagation. + * + * @param point The point to approach. + * @param eLoss If not 0, constrain the energy loss to this value by adjusting fScalingFactor. + */ + void PropagateToMeasurementSurface(const AtMeasurementSurface &point, double eLoss, AtStepper &stepper); + + void PropagateToMeasurementSurface(const AtMeasurementSurface &surface, AtStepper &stepper); + + /** + * @brief Propagate the particle using the given stepper. + * Propagates one step using the provided stepper. + * + * @param stepper The stepper to use for propagation. + */ + void PropagateOneStep(AtStepper &stepper); + + /** + * @brief Calculate the force acting on the particle. + * + * @param pos Position of the particle in mm. + * @param mom Momentum of the particle in MeV/c. + * @return The force acting on the particle in N. + */ + XYZVector Force(XYZPoint pos, XYZVector mom) const; + + /** + * @brief Calculate the derivate of the momentum w.r.t. arc length. + * + * @param pos Position of the particle in mm. + * @param mom Momentum of the particle in MeV/c. + * @return The derivative of the momentum w.r.t. arc length in N/m. + */ + XYZVector dpds(const XYZPoint &pos, const XYZVector &mom) const; + + XYZVector dxds(const XYZPoint &pos, const XYZVector &mom) const + { + return mom.Unit(); // The derivative of the position is just the unit vector of the momentum. + } + + std::pair + Derivatives(const ROOT::Math::XYZPoint &pos, const ROOT::Math::XYZVector &mom) const + { + return {dxds(pos, mom), dpds(pos, mom)}; + } + +protected: + /** + * @brief Calculate the second derivative of the position w.r.t. arc length. + * + * \frac{d^2\vec{x}}{ds^2} = \frac{1}{p} \left( \frac{d\vec{p}}{ds} - \hat{p} (\hat{p} \cdot \frac{d\vec{p}}{ds}) + * \right) + * + * @param pos Position of the particle in mm. + * @param mom Momentum of the particle in MeV/c. + * @return The second derivative of the position w.r.t. arc length in m/m^2. + */ + XYZVector d2xds2(const XYZPoint &pos, const XYZVector &mom) const; +}; +class AtStepper { +public: + /** + * @brief Function type defining the derivative of the position and momentum w.r.t. distance. + * + * This function takes the current position and momentum and returns the derivate of the position and momentum. + * + * @param pos Current position of the particle in mm. + * @param mom Current momentum of the particle in MeV/c. + * @return A pair containing the derivatives of the position and momentum in SI units (m and kg m/s). + * The first element is the derivative of the position, and the second element is the derivative + * of the momentum. + */ + using DerivFunc = std::function( + const ROOT::Math::XYZPoint &, const ROOT::Math::XYZVector &)>; + + DerivFunc fDeriv; + + virtual AtPropagator::StepState Step(const AtPropagator::StepState &state) const = 0; + virtual double GetInitialStep() const { return 1e-4; } /// Default initial step size in m + + virtual ~AtStepper() = default; + +protected: + static constexpr double fReltoSImom = 1.60218e-13 / 299792458; // Conversion factor from MeV/c to kg m/s (SI units) +}; + +class AtRK4Stepper : public AtStepper { + double fStepSize = 1e-4; + +public: + AtPropagator::StepState Step(const AtPropagator::StepState &state) const override; + double GetInitialStep() const override { return fStepSize; } /// Default initial step size in m +}; +class AtRK4AdaptiveStepper : public AtStepper { +public: + double fAtolPos = 1e-2; /// Absolute tolerance for position in mm + double fAtolMom = 1e-2; /// Absolute tolerance for momentum in MeV/c + double fRtol = 1e-6; /// Relative tolerance for position and momentum + double fMinStep = 1e-6; /// Minimum step size in m + double fMaxStep = 10.0; /// Maximum step size in m + double fInitialStep = 1e-4; /// Initial step size in m + + AtPropagator::StepState Step(const AtPropagator::StepState &state) const override; + double GetInitialStep() const override { return fInitialStep; } /// Default initial step size in m +}; + +/** + * @brief Class for measurement surface in the AT-TPC. + * + * This class represents a measurement surface or point in the AT-TPC. It's used to define the stopping + * point and behavior of the propagator. + */ +class AtMeasurementSurface { +public: + bool fClipToSurface = false; // Whether to clip to the surface + + /** + * @brief Calculate the distance from the position to the surface. + */ + virtual double Distance(const ROOT::Math::XYZPoint &pos) const = 0; + + /** + * @brief Check if we have passed the surface between the last position and the current position. + */ + virtual bool PassedSurface(AtPropagator::StepState &result) const = 0; + + virtual ROOT::Math::XYZPoint ProjectToSurface(const ROOT::Math::XYZPoint &pos) const = 0; +}; +class AtMeasurementPoint : public AtMeasurementSurface { +protected: + ROOT::Math::XYZPoint fPoint; // The measurement point in mm + +public: + AtMeasurementPoint(const ROOT::Math::XYZPoint &point) : fPoint(point) {} + + template + AtMeasurementPoint(const T &point) + { + fPoint = ROOT::Math::XYZPoint(point[0], point[1], point[2]); + } + + double Distance(const ROOT::Math::XYZPoint &pos) const override { return (fPoint - pos).R(); } + bool PassedSurface(AtPropagator::StepState &result) const override; + ROOT::Math::XYZPoint ProjectToSurface(const ROOT::Math::XYZPoint &pos) const override { return pos; } +}; + +class AtMeasurementPlane : public AtMeasurementSurface { +protected: + ROOT::Math::Plane3D fPlane; // The measurement plane +public: + AtMeasurementPlane(const ROOT::Math::Plane3D &plane) : fPlane(plane) { fClipToSurface = true; } + + double Distance(const ROOT::Math::XYZPoint &pos) const override { return std::abs(fPlane.Distance(pos)); } + bool PassedSurface(AtPropagator::StepState &result) const override; + ROOT::Math::XYZPoint ProjectToSurface(const ROOT::Math::XYZPoint &pos) const override; +}; +} // namespace AtTools +#endif // #ifndef ATPROPAGATOR_H diff --git a/AtTools/AtPropagatorTest.cxx b/AtTools/AtPropagatorTest.cxx new file mode 100644 index 000000000..bfb989ba7 --- /dev/null +++ b/AtTools/AtPropagatorTest.cxx @@ -0,0 +1,401 @@ +#include "AtPropagator.h" + +#include "AtELossTable.h" +#include "AtKinematics.h" + +#include + +#include +#include +#include +using ROOT::Math::Plane3D; +using ROOT::Math::XYZPoint; +using ROOT::Math::XYZVector; + +using namespace AtTools; + +const double mass_p = 938.272; // Mass of proton in MeV/c^2 +const double charge_p = 1.602176634e-19; // Charge of proton +namespace { + +std::string getEnergyPath() +{ + auto env = std::getenv("VMCWORKDIR"); + if (env == nullptr) { + return "../../resources/energy_loss/HinH.txt"; // Default path assuming cwd is build/AtTools + } + return std::string(env) + "/resources/energy_loss/HinH.txt"; // Use environment variable +} +} // namespace +class DummyELossModel : public AtELossModel { +public: + double eLoss = 1; + DummyELossModel() : AtELossModel(0) {} + + double GetdEdx(double /*KE*/) const override { return eLoss; } + double GetRange(double /*energyIni*/, double /*energyFin = 0*/) const override { return 1.0; } + double GetEnergyLoss(double /*energyIni*/, double /*distance*/) const override { return 1.0; } + double GetEnergy(double /*energyIni*/, double /*distance*/) const override { return 1.0; } + double GetElossStraggling(double /*energyIni*/, double /*energyFin*/) const override { return 0.0; } + double GetdEdxStraggling(double /*energyIni*/, double /*energyFin*/) const override { return 0.0; } + double GetRangeVariance(double /*energy*/) const override { return 0.0; } +}; + +TEST(AtPropagatorTest, ForceNoField) +{ + XYZPoint pos(0, 0, 0); // Position in mm + XYZVector mom(100, 0, 0); // Momentum in MeV/c + + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + double dedx = 1; // Stopping power in MeV/mm + + // Create a dummy energy loss model + auto elossModel = std::make_unique(); + AtPropagator propagator(charge, mass, elossModel.get()); + propagator.SetEField({0, 0, 0}); + propagator.SetBField({0, 0, 0}); + + auto force = propagator.Force(pos, mom); + + ASSERT_NEAR(force.X(), -1.602e-10, 1e-12); + ASSERT_NEAR(force.Y(), 0, 1e-12); + ASSERT_NEAR(force.Z(), 0, 1e-12); + + mom = XYZVector(100, 0, 100); // Reset momentum + force = propagator.Force(pos, mom); + ASSERT_NEAR(force.X(), -1.602e-10 / std::sqrt(2), 1e-12); + ASSERT_NEAR(force.Y(), 0, 1e-12); + ASSERT_NEAR(force.Z(), -1.602e-10 / std::sqrt(2), 1e-12); +} + +TEST(AtPropagatorTest, ForceEField) +{ + XYZPoint pos(0, 0, 0); // Position in mm + XYZVector mom(100, 0, 0); // Momentum in MeV/c + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + + // Create a dummy energy loss model + auto elossModel = std::make_unique(); + elossModel->eLoss = 0; // No energy loss for this test + AtPropagator propagator(charge, mass, elossModel.get()); + propagator.SetEField({0, 0, 70000}); + propagator.SetBField({0, 0, 0}); + + auto force = propagator.Force(pos, mom); + + ASSERT_NEAR(force.X(), 0, 1e-12); + ASSERT_NEAR(force.Y(), 0, 1e-12); + ASSERT_NEAR(force.Z(), 1.121e-14, 1e-15); +} + +TEST(AtPropagatorTest, ForceBField) +{ + XYZPoint pos(0, 0, 0); // Position in mm + XYZVector mom(100, 0, 0); // Momentum in MeV/c + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + + // Create a dummy energy loss model + auto elossModel = std::make_unique(); + elossModel->eLoss = 0; // No energy loss for this test + AtPropagator propagator(charge, mass, elossModel.get()); + propagator.SetEField({0, 0, 0}); + propagator.SetBField({0, 0, 1}); + + auto force = propagator.Force(pos, mom); + + ASSERT_NEAR(force.X(), 0, 1e-12); + ASSERT_NEAR(force.Y(), -5.09e-12, 1e-13); + ASSERT_NEAR(force.Z(), 0, 1e-12); +} + +TEST(AtPropagatorTest, PropagateToPoint_StoppingNoField) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + AtPropagator propagator(charge, mass, elossModel.get()); + AtRK4Stepper stepper; + AtMeasurementPoint measurementPoint({1e3, 0, 0}); + + double KE = 1; // Kinetic energy in MeV + double E = KE + mass_p; + double p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + XYZPoint startPos(0, 0, 0); // Start position in mm + XYZVector startMom(p, 0, 0); // Start momentum in MeV/c + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 210, 10); // Final position in x-direction should be close to 210 mm + ASSERT_NEAR(finalMom.X(), 0, 0.1); + + KE = 0.75; + E = KE + mass_p; + p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + startMom.SetXYZ(p, 0, 0); // Reset momentum + propagator.SetState(startPos, startMom); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); // Propagate to range + finalPos = propagator.GetPosition(); + finalMom = propagator.GetMomentum(); + ASSERT_NEAR(finalPos.X(), 130, 10); // Final position in x-direction should be close to 130 mm + ASSERT_NEAR(finalMom.X(), 0, 0.1); // Final momentum in x-direction should be close to 0 +} + +TEST(AtPropagatorTest, PropagateToPoint_NoField) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + AtPropagator propagator(charge, mass, elossModel.get()); + AtRK4Stepper stepper; + AtMeasurementPoint measurementPoint({10, 0, 0}); + + double KE = 1; // Kinetic energy in MeV + double E = KE + mass_p; + double p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + XYZPoint startPos(0, 0, 0); // Start position in mm + XYZVector startMom(p, 0, 0); // Start momentum in MeV/c + + double eLoss = 0.0285; // Expected energy loss in MeV (LISE) + double E_fin = KE - eLoss + mass_p; // Expected final energy after loss + double p_fin = std::sqrt(E_fin * E_fin - mass_p * mass_p); // Expected final momentum in MeV/c + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 10, 1); // Final position in x-direction should be close to 10 mm + ASSERT_NEAR(finalMom.X(), p_fin, 0.01); +} + +TEST(AtPropagatorTest, PropagateToPlane_NoField) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + AtPropagator propagator(charge, mass, elossModel.get()); + AtRK4Stepper stepper; + + double KE = 1; // Kinetic energy in MeV + double E = KE + mass_p; + double p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + XYZPoint startPos(0, 0, 0); // Start position in mm + XYZVector startMom(p, 0, 0); // Start momentum in MeV/c + + double eLoss = 0.0285; // Expected energy loss in MeV in 10 mm (LISE) + double E_fin = KE - eLoss + mass_p; // Expected final energy after loss + double p_fin = std::sqrt(E_fin * E_fin - mass_p * mass_p); // Expected final momentum in MeV/c + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + XYZPoint planePoint(10, 10, 10); // Target point to propagate to 10 mm + XYZVector planeNormal(1, 0, 0); // Normal vector of the plane in x-direction + Plane3D plane(planeNormal, planePoint); // Create the plane + AtMeasurementPlane measurementPlane(plane); + propagator.PropagateToMeasurementSurface(measurementPlane, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 10, 1); // Final position in x-direction should be close to 10 mm + ASSERT_NEAR(finalMom.X(), p_fin, 0.1); +} + +TEST(AtPropagatorTest, PropagateToPlane_StoppingNoField) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + AtPropagator propagator(charge, mass, elossModel.get()); + AtRK4Stepper stepper; + + double KE = 1; // Kinetic energy in MeV + double E = KE + mass_p; + double p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + XYZPoint startPos(0, 0, 0); // Start position in mm + XYZVector startMom(p, 0, 0); // Start momentum in MeV/c + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + XYZPoint planePoint(220, 0, 0); // Target point to propagate to 215 mm + XYZVector planeNormal(1, 0, 0); // Normal vector of the plane in x-direction + Plane3D plane(planeNormal, planePoint); // Create the plane + AtMeasurementPlane measurementPlane(plane); + propagator.PropagateToMeasurementSurface(measurementPlane, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 220, 1); // Final position in x-direction should be close to 215 mm + ASSERT_NEAR(finalMom.X(), 0, 0.1); +} + +TEST(AtPropagatorTest, PropagateToPointAdaptive_NoField) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + AtPropagator propagator(charge, mass, elossModel.get()); + AtRK4AdaptiveStepper stepper; + AtMeasurementPoint measurementPoint({10, 0, 0}); + + double KE = 1; // Kinetic energy in MeV + double E = KE + mass_p; + double p = std::sqrt(E * E - mass_p * mass_p); // Momentum in MeV/c + XYZPoint startPos(0, 0, 0); // Start position in mm + XYZVector startMom(p, 0, 0); // Start momentum in MeV/c + + double eLoss = 0.0285; // Expected energy loss in MeV in 10 mm (LISE) + double E_fin = KE - eLoss + mass_p; // Expected final energy after loss + double p_fin = std::sqrt(E_fin * E_fin - mass_p * mass_p); // Expected final momentum in MeV/c + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + stepper.fInitialStep = 1; // Set initial step size to 1 m + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 10, 10 * 1e-3); // Final position in x-direction should be close to 10 mm + ASSERT_NEAR(finalMom.X(), p_fin, 0.1); + + propagator.SetState(startPos, startMom); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 0}); // No magnetic field + stepper.fInitialStep = 1e-6; // Set initial step size to 1e-6 m + + ASSERT_NEAR(propagator.GetMomentum().X(), 43.331, 1e-1); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + finalPos = propagator.GetPosition(); + finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), 10, 10 * 1e-3); // Final position in x-direction should be close to 10 mm + ASSERT_NEAR(finalMom.X(), p_fin, 0.1); +} + +TEST(AtPropagatorTest, PropagateToPoint_Field) +{ + double charge = charge_p; // Charge in Coulombs + double mass = mass_p; // Mass in MeV/c^2 + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); // Use the function to get the path + elossModel->SetDensity(3.3084e-05); // Set density in g/cm^3 for 300 torr H2 + AtPropagator propagator(charge, mass, elossModel.get()); + propagator.SetEField({0, 0, 0}); // No electric field + propagator.SetBField({0, 0, 2.85}); // Magnetic field + AtRK4Stepper stepper; + + XYZPoint startPos(-3.40046e-05, -1.49863e-05, 0.10018); // Start position in cm + startPos *= 10; // Convert to mm + XYZVector startMom(0.00935463, -0.0454279, 0.00826042); // Start momentum in GeV/c + startMom *= 1e3; // Convert to MeV/c + + auto KE = Kinematics::KE(startMom, mass); // Convert momentum to kinetic energy + std::cout << "Propagating proton with KE: " << KE << " MeV" << std::endl; + std::cout << "Initial position: " << startPos.X() << ", " << startPos.Y() << ", " << startPos.Z() << std::endl; + + propagator.SetState(startPos, startMom); + + XYZPoint point({-1.4895, -4.8787, 1.01217}); // measurement point in cm + point *= 10; // Convert to mm + AtMeasurementPoint measurementPoint(point); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + auto finalPos = propagator.GetPosition(); + auto finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), point.X(), 1); // Check final position is within 1 mm of the measurement point + ASSERT_NEAR(finalPos.Y(), point.Y(), 1); + ASSERT_NEAR(finalPos.Z(), point.Z(), 1); + std::cout << "Difference in position: " << measurementPoint.Distance(finalPos) << " mm" << std::endl; + + /*** Propagate to new measurement point ****/ + propagator.SetState(startPos, startMom); + + point = XYZPoint({-3.6942, -6.13106, 1.45025}); // measurement point in cm + point *= 10; // Convert to mm + measurementPoint = AtMeasurementPoint(point); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + finalPos = propagator.GetPosition(); + finalMom = propagator.GetMomentum(); + + ASSERT_NEAR(finalPos.X(), point.X(), 1); // Check final position is within 1 mm of the measurement point + ASSERT_NEAR(finalPos.Y(), point.Y(), 1); + ASSERT_NEAR(finalPos.Z(), point.Z(), 1); + std::cout << "Difference in position: " << measurementPoint.Distance(finalPos) << " mm" << std::endl; +} + +TEST(AtPropagatorTest, PropagateToPointAdaptive_Field) +{ + double charge = charge_p; + double mass = mass_p; + auto elossModel = std::make_unique(0); + elossModel->LoadSrimTable(getEnergyPath()); + elossModel->SetDensity(3.3084e-05); + AtPropagator propagator(charge, mass, elossModel.get()); + propagator.SetEField({0, 0, 0}); + propagator.SetBField({0, 0, 2.85}); + AtRK4AdaptiveStepper stepper; + stepper.fInitialStep = 1e-3; + stepper.fMaxStep = 1e-3; + + XYZPoint startPos(-3.40046e-05, -1.49863e-05, 0.10018); + startPos *= 10; + XYZVector startMom(0.00935463, -0.0454279, 0.00826042); + startMom *= 1e3; + + propagator.SetState(startPos, startMom); + + XYZPoint point({-1.4895, -4.8787, 1.01217}); + point *= 10; + AtMeasurementPoint measurementPoint(point); + + propagator.PropagateToMeasurementSurface(measurementPoint, stepper); + + auto finalPos = propagator.GetPosition(); + ASSERT_NEAR(finalPos.X(), point.X(), 1); + ASSERT_NEAR(finalPos.Y(), point.Y(), 1); + ASSERT_NEAR(finalPos.Z(), point.Z(), 1); +} diff --git a/AtTools/AtToolsLinkDef.h b/AtTools/AtToolsLinkDef.h index e2337413e..e65de3002 100644 --- a/AtTools/AtToolsLinkDef.h +++ b/AtTools/AtToolsLinkDef.h @@ -13,7 +13,6 @@ #pragma link C++ namespace AtTools::Kinematics; #pragma link C++ namespace AtTools::DataCleaning; -#pragma link C++ class AtTools::AtELossManager + ; #pragma link C++ class AtTools::AtParsers + ; #pragma link C++ class AtEulerTransformation + ; #pragma link C++ class AtTools::AtTrackTransformer - !; @@ -21,6 +20,9 @@ #pragma link C++ class AtTools::AtELossTable - !; #pragma link C++ class AtTools::AtELossCATIMA - !; #pragma link C++ class AtTools::AtELossBetheBloch - !; +#pragma link C++ class AtTools::AtELossManager - !; +#pragma link C++ class AtTools::AtELossManagerBetheBloch - !; +#pragma link C++ class AtTools::AtELossManagerCATIMA - !; #pragma link C++ class AtSpaceChargeModel - !; #pragma link C++ class AtLineChargeModel - !; @@ -58,6 +60,14 @@ #pragma link C++ class AtFindVertex - !; +#pragma link C++ class AtTools::AtPropagator - !; +#pragma link C++ class AtTools::AtStepper - !; +#pragma link C++ class AtTools::AtRK4Stepper - !; +#pragma link C++ class AtTools::AtRK4AdaptiveStepper - !; +#pragma link C++ class AtTools::AtMeasurementSurface - !; +#pragma link C++ class AtTools::AtMeasurementPoint - !; +#pragma link C++ class AtTools::AtMeasurementPlane - !; + #pragma link C++ function AtTools::GetHitFunctionTB; #pragma link C++ function AtTools::GetHitFunction; #pragma link C++ function AtTools::GetTB; diff --git a/AtTools/CMakeLists.txt b/AtTools/CMakeLists.txt index 62df5fd37..ef4903aca 100644 --- a/AtTools/CMakeLists.txt +++ b/AtTools/CMakeLists.txt @@ -40,11 +40,15 @@ set(SRCS DataCleaning/AtkNN.cxx AtELossBetheBloch.cxx + AtPropagator.cxx + AtELossManager.cxx + AtELossManagerBetheBloch.cxx ) Set(DEPENDENCIES ROOT::XMLParser ROOT::Core + ROOT::Geom FairRoot::Base FairRoot::FairTools @@ -64,6 +68,7 @@ endif() if(CATIMA_FOUND) set(SRCS ${SRCS} AtELossCATIMA.cxx + AtELossManagerCATIMA.cxx ) set(DEPENDENCIES ${DEPENDENCIES} CATIMA::catima @@ -80,6 +85,8 @@ set(TEST_SRCS DataCleaning/AtkNNTest.cxx AtELossTableTest.cxx AtELossBetheBlochTest.cxx + AtPropagatorTest.cxx + AtELossManagerTest.cxx ) if(CATIMA_FOUND) set(TEST_SRCS ${TEST_SRCS} diff --git a/docs/development/simplesim-integration-review.md b/docs/development/simplesim-integration-review.md new file mode 100644 index 000000000..cf8b058e8 --- /dev/null +++ b/docs/development/simplesim-integration-review.md @@ -0,0 +1,382 @@ +# SimpleSim FairRoot Integration Review + +> **Historical note (post-review rename):** the class names below were renamed after this review. +> `AtSimpleSimulation` (transport engine) → `AtSimTransport`; +> `AtStandaloneSimulation` (hit-recording wrapper) → `AtSimpleSimulation`; +> `AtSimpleSimulationTask` / `AtSimpleSimulationGeneratorTask` / `AtSimpleSimulationReplayTask` → `AtSimTransportTask` / `AtSimTransportGeneratorTask` / `AtSimTransportReplayTask`. +> `AtELossModelFactory` / `AtELossFactoryBetheBloch` / `AtELossFactoryCATIMA` → `AtELossManager` / `AtELossManagerBetheBloch` / `AtELossManagerCATIMA`. +> The review text below is preserved with the original names for historical context. + +Review of the `SimpleSimAddition` branch, which integrates `AtSimpleSimulation` into the FairRoot simulation pipeline as a drop-in replacement for Geant4 transport. + +Scope: integration layer design only. The physics of `AtSimpleSimulation` itself is assumed correct. + +## 1. Architectural Overview + +The integration replaces Geant4 transport with AtSimpleSimulation's RK4 propagator while reusing the rest of the FairRoot simulation pipeline (geometry, generators, detector hit recording, digitization). + +Three key abstractions bridge the gap: + +1. **`AtSimParticleCollector`** -- a `FairGenericStack` stub that intercepts `PushTrack()` calls from `FairPrimaryGenerator::GenerateEvent()`. Generators run unchanged; their output lands in a vector instead of going to Geant4. + +2. **`AtSimpleSimulationTask`** (abstract base) -- a `FairTask` that orchestrates event flow: load particles from a source, transport them through AtSimpleSimulation, and feed each step to `AtTpc::ProcessStep()` for hit recording. + +3. **`AtTpc::StepState` + `ProcessStep()`** -- the detector was refactored to extract a transport-neutral step contract. Both Geant4 (`ProcessHits`) and SimpleSim feed the same internal logic via this struct. + +The design solves: letting physics users swap Geant4 for a fast, controllable transport without changing their generators, geometry, or downstream analysis. + +Left to the user: choosing a generator, providing an energy loss model (or factory), and wiring the macro. The physics configuration (E/B fields, step sizes) is auto-extracted from FairRun where possible. + +## 2. API Design Assessment + +### Discoverability + +Good, with one gap. The generator and replay tasks have clean, small interfaces. A user who has seen a Geant4 macro can follow the pattern: create simulation, create task, set detector, set generator, run. The model factory auto-creates energy loss models from geometry materials, which is the right default -- users shouldn't need to manually register models per species. + +However, the `SetSensitiveDetector(AtTpc*)` requirement is non-obvious. A user who forgets it gets a fatal at `Init()` -- which is correct -- but the error message is the only documentation of this requirement. The alias `SetDetector()` helps. + +### Error surface + +Mostly narrow, one silent footgun. + +- Missing energy loss model: clear `invalid_argument` exception. +- Particle outside geometry: clear exception. +- Missing detector: fatal at Init with descriptive message. +- **Footgun:** `AtSimpleSimulationReplayTask::LoadEvent()` hardcodes `beamEvent = (eventIndex % 2 == 0)`. This is a fragile heuristic that silently produces wrong beam/reaction event pairing if the source file doesn't alternate. A physics user replaying their own Geant4 output would not expect this behavior and would get silently wrong results. + +### Consistency + +Strong. Follows the `FairTask` pattern exactly: `Init()` -> `Exec()` -> `Finish()`. Uses `FairPrimaryGenerator` and `FairRootManager` the same way Geant4 simulations do. The generator task is particularly clean -- a user familiar with FairRoot would recognize the pattern immediately. + +### Completeness + +Two gaps: + +1. No electric field auto-configuration (only magnetic field is extracted from FairRun). The `ConfigureFieldFromFairRun` method samples B but not E, though `SetElectricField()` exists. This is probably fine for now (E-field is a drift field, not a transport field), but worth noting. +2. No way to set per-particle energy loss models from the task level without reaching through `GetSimulation()`. The factory pattern mostly eliminates this need, but direct model registration requires breaking the abstraction: `task->GetSimulation()->AddModel(...)`. + +## 3. Separation of Concerns + +The split between GeneratorTask and ReplayTask is well-motivated. They share the core transport loop (base class) but differ only in how particles are sourced. This is the right seam. + +AtSimParticleCollector is clean. It implements just enough of `FairGenericStack` to capture generator output. The pure-virtual stubs are no-ops with appropriate comments. It avoids pulling in VMC/Geant4 dependencies. + +FairRoot-specific logic is well-separated from physics logic. `AtSimpleSimulation` knows nothing about `FairTask`, `FairRun`, or `AtTpc`. It deals in mm/MeV coordinates and callbacks. The unit conversion (mm<->cm, MeV<->GeV) lives entirely in `AtSimpleSimulationTask`, which is the right place. + +One entanglement: `AtSimpleSimulation` still depends on `FairRootManager` in `RegisterBranch()` -- this is the legacy standalone API and doesn't affect the new integration path, but it means the physics class isn't fully decoupled from FairRoot. + +## 4. Framework Integration Quality + +The detector step contract is the strongest part of this design. By extracting `AtTpc::ProcessStep(StepState)` and routing both Geant4 and SimpleSim through it, the integration ensures hit recording logic stays in one place. This is exactly the right pattern -- the detector doesn't care who drives transport. + +Pipeline composition is correct. The task reads nothing from upstream `FairRootManager` branches; it writes `MCTrack` and feeds `AtTpc` directly. Downstream digitization tasks (`AtClusterizeTask`, etc.) consume `AtTpcPoint` from the detector's collection, which is populated identically regardless of transport engine. + +### Implicit assumptions that could break + +- `ConfigureFieldFromFairRun()` calls `gGeoManager->FindVolumeFast("drift_volume")` -- hardcoded volume name. If the geometry uses a different naming convention, the field auto-config silently falls back to sampling at (0,0,0), which may be wrong. +- `fDetector->SetStopOnReactionVolumeExit(true)` is called unconditionally in `Init()`. This changes AtTpc behavior globally. If another task also uses the detector (unlikely but possible), this side effect is invisible. +- `IsSensitiveVolume` is a static method checking for hardcoded volume name substrings ("drift_volume", "window", "cell"). This is a framework-wide convention, not something introduced here, but the integration depends on it critically for stepping logic. + +## 5. Tradeoffs and Direction + +### Key tradeoffs made + +| Decision | In favor of | At the cost of | +|---|---|---| +| Uniform field assumption | Simplicity, speed | Cannot handle field maps | +| Single-point field sampling | Drop-in behavior | Wrong if field varies spatially | +| Factory auto-creates models | User doesn't register per species | Factory must handle all materials correctly | +| Callback-based stepping | Clean separation from detector | Slightly more complex internal flow | +| `ELossModelShared` wrapper | Shared ownership in SimpleSim, unique in Propagator | Extra indirection, wrapper class | + +These are appropriate. The uniform field assumption is reasonable for AT-TPC (the solenoidal field is approximately uniform in the drift volume). The factory pattern is the right answer for a multi-species simulation. The callback design is the right structural choice for keeping transport and detection separate. + +This is a clean pipeline replacement pattern, not a workaround. The detector step contract means future transport engines could be plugged in the same way. The design is coherent and sustainable. + +## 6. Weak Points / Design Smells + +In order of importance: + +1. **Replay task beam-event heuristic.** `beamEvent = (eventIndex % 2 == 0)` in `AtSimpleSimulationReplayTask::LoadEvent()` is an implicit contract that will silently produce wrong physics. The source file may not follow this convention. This should either be read from the source file metadata or made configurable. + +2. **`ELossModelShared` wrapper class.** AtPropagator requires `unique_ptr` but AtSimpleSimulation holds `shared_ptr`. The wrapper is a workaround for a design mismatch. The real fix is to have AtPropagator accept a non-owning reference or `shared_ptr` -- the current pattern creates a fake `unique_ptr` that secretly shares ownership, which violates the semantic contract of `unique_ptr`. + +3. **Field auto-config hardcodes "drift_volume".** The volume name is also hardcoded in `fStandaloneVolumeName`. These should be the same string by construction, but they're set independently and could diverge. + +4. **`RegisterBranch()` on the physics class.** FairRootManager coupling in AtSimpleSimulation is vestigial. The integration path doesn't use it, but a user might call it accidentally, creating confusion about which path records hits. + +5. **Thread-local `fMCPoints` and `fTrackID`.** These exist for the standalone API but have no role in the detector-coupled path. They add cognitive load and a footgun -- if someone calls `SimulateParticle()` and `TransportParticle()` in the same event, track IDs will collide. + +## 7. Missed Opportunities + +1. **The `AtELossModelFactory` could have been an argument to the `AtSimpleSimulationTask` constructor** rather than a `SetModelFactory()` call. Since it's always needed (or the user must register models manually), making it a constructor parameter would make the requirement explicit and eliminate a misconfiguration path. + +2. **`FindSensitiveEntry()` walks in 1mm steps up to 5m.** A TGeo ray-trace (`FindNextBoundary`) would be exact, faster, and wouldn't miss thin volumes. The linear scan is the obvious implementation but the geometry manager already solves this problem. + +3. **The `TransportStep` struct duplicates `AtTpc::StepState`** with different units and types (`std::string` vs `TString`, mm/MeV vs cm/GeV). A shared step type with unit-tagged fields, or a conversion constructor, would make the mapping less error-prone and reduce the 30-line `ProcessDetectorStep` method to a few lines. + +## 8. Overall Judgment + +This is a good integration design. The core architectural decisions -- intercepting the generator stack, routing transport through a callback, unifying detector logic through `ProcessStep` -- are sound. The separation between physics (AtSimpleSimulation), pipeline orchestration (AtSimpleSimulationTask), and event sourcing (Generator/Replay subclasses) is clean and well-motivated. + +### Where it succeeds + +- The detector step contract is the design's best contribution. It makes the transport engine pluggable without the detector caring. +- AtSimParticleCollector is an elegant solution for bypassing VMC without rewriting generators. +- The factory pattern for energy loss models is the right abstraction for multi-species physics. +- Unit conversion is concentrated in one place (the task), not scattered. + +### Where it falls short + +- The replay task's beam-event heuristic is a latent correctness bug. +- The `ELossModelShared` wrapper papers over an ownership design mismatch rather than fixing it. +- There are two parallel hit-recording paths (standalone `AddHit` vs detector-coupled `ProcessStep`) sharing a class, which muddies the API boundary. + +### Is this sustainable? + +Yes. The `StepState` contract is the right foundation. The task hierarchy is extensible. The weak points are real but fixable without structural changes. The biggest risk for users is the replay beam-event logic; the biggest risk for maintainers is the dual standalone/detector-coupled API living on the same class. + +The direction is coherent and this is not a workaround -- it is a genuine architectural step toward transport-engine independence. + +--- + +# SimpleSim Integration Correctness Review + +Review of the `SimpleSimAddition` branch, focused on whether the integration correctly fulfills FairRoot framework contracts and produces output that downstream tasks can consume without modification. + +Scope: correctness of the integration layer. The physics of `AtSimpleSimulation` and the design quality are reviewed separately above. + +## 1. Integration Summary + +### Geant4 path (framework level) + +`FairPrimaryGenerator` pushes particles onto `AtStack`. Geant4 transports them step-by-step through the ROOT geometry. At each step inside a sensitive volume, `AtTpc::ProcessHits()` extracts VMC state into a `StepState`, delegates to `ProcessStep()`, and records `AtMCPoint` entries into a `TClonesArray` registered as the `AtTpcPoint` branch. The `MCTrack` branch is filled by `AtStack`. Beam/reaction alternation is controlled by `AtVertexPropagator` -- the beam event accumulates energy loss until a reaction threshold (`RndELoss`) is reached, at which point `startReactionEvent()` populates the vertex state for the subsequent reaction event. + +### SimpleSim path + +`AtSimpleSimulationTask` (a `FairTask`) runs inside the same `FairRunSim` event loop, after a no-op Geant4 transport (dummy generator produces zero primaries). On each `Exec()`: + +1. `LoadEvent()` runs generators through the same `FairPrimaryGenerator::GenerateEvent()` machinery, capturing particles into an `AtSimParticleCollector`. +2. Particles are transported through the geometry via `AtSimpleSimulation::TransportParticle()`, with each step delivered to `AtTpc::ProcessStep()` through the same `StepState` struct used by the Geant4 path. +3. `MCTrack` is filled by the task itself from the collector. + +### Integration layer responsibilities + +- Unit conversion between SimpleSim internals (mm/MeV) and FairRoot conventions (cm/GeV) +- Mapping transport steps to the `AtTpc::StepState` struct +- Driving generators and capturing primaries without a real VMC stack +- Determining entering/exiting/stopping flags from volume boundary crossings +- Identifying beam vs. reaction tracks + +## 2. Framework Contract Compliance + +**FairTask lifecycle -- correct.** `Init()` configures the detector, auto-extracts the B-field from FairRun, initializes the event source, and registers the MCTrack branch. `Exec()` runs per event. `Finish()` cleans up. No issues. + +**Branch naming and types -- correct.** `AtTpcPoint` is registered by `AtTpc::Register()` (called by FairRunSim detector initialization). `MCTrack` is registered by the task (`AtSimpleSimulationTask.cxx:208`). Both match the Geant4 path naming. + +**AtTpcPoint production -- correct with one caveat.** The detector-coupled path feeds steps through `AtTpc::ProcessStep()`, which calls `addHit()` exactly as in the Geant4 path. All fields (position, momentum, energy loss, A, Z, EIni, AIni) are populated by the same detector code. The one issue is the `entering` flag (see Finding 1 below). + +**AtVertexPropagator state -- correct for the primary use case.** `AtTPCIonGenerator` only adds beam on beam events when `fDoReact=true` (`AtTPCIonGenerator.cxx:157-170`). The reaction trigger fires correctly during beam transport: `ProcessStep` -> `reactionOccursHere()` -> `startReactionEvent()` -> `SetVertex()`. The subsequent reaction event reads vertex/momentum from the propagator through the same generator chain. + +**Detector hook management -- correct but AtTpc-specific.** `SetDetector(tpc)` is required and validated in `Init()`. `ProcessStep()` is called on the same `AtTpc` instance. `SetStopOnReactionVolumeExit(true)` is set to prevent transport beyond the active volume. `IsSensitiveVolume` is hardcoded to `AtTpc::IsSensitiveVolume`, so the task is AtTpc-specific. Other detectors would need to implement the same `ProcessStep(StepState)` interface. + +## 3. Integration Correctness + +### Finding 1 (HIGH): `entering` flag misses inter-sensitive-volume boundaries + +**Location:** `AtSimpleSimulationTask.cxx:255` + +```cpp +const bool entering = !preSensitive && postSensitive; +const bool exiting = preSensitive && !postSensitive; +``` + +This only detects transitions from non-sensitive to sensitive. When a particle crosses between two *different* sensitive volumes (e.g. window -> drift_volume), both are sensitive, so `entering = false`. + +In Geant4, `gMC->IsTrackEntering()` is true at every volume boundary, including sensitive-to-sensitive transitions. `AtTpc::trackEnteringVolume()` (`AtTpc.cxx:63`) fires on each entering, resetting `fELossAcc` and capturing `InPos` for beam tracks. + +**Consequences:** + +1. **`fELossAcc` not reset at drift_volume entry** -- energy loss accumulated in the window is carried into the drift_volume's reaction threshold check. For typical AT-TPC windows (5-50 um Mylar), this is ~0.01-0.1 MeV, negligible vs. the ~10+ MeV reaction threshold. **Low practical impact.** +2. **`InPos` never set for beam tracks** -- `trackEnteringVolume` sets `InPos` only when `fIsBeamTrack && IsReactionVolume(fVolName)` (line 75). Since `trackEnteringVolume` never fires at the drift_volume entry, `InPos` stays at its default `(0,0,0,0)`. This propagates into `SetVertex()` as the input vertex `(invx, invy, invz)`. However, `GetInVx/GetInVy/GetInVz` are only used in *commented-out* code in `AtTPC2Body.cxx` (lines 327-335). **No active downstream consumer, but a latent correctness issue.** + +**Suggested fix:** + +```cpp +const bool volumeChanged = step.preVolumeName != step.postVolumeName; +const bool entering = (!preSensitive && postSensitive) || (volumeChanged && postSensitive); +const bool exiting = (preSensitive && !postSensitive) || (volumeChanged && preSensitive); +``` + +**Confidence: High** -- the mechanism is clear from code inspection. + +### Finding 2 (MEDIUM): `timeNs` always zero + +**Location:** `AtSimpleSimulationTask.cxx:284,314` + +Both `SubmitInitialSensitivePoint` and `ProcessDetectorStep` set `detectorStep.timeNs = 0.0`. In Geant4, this is `gMC->TrackTime() * 1e9` (nanoseconds). `AtMCPoint` stores this as the time field. Any downstream code that uses the time field from MC points (e.g. timing resolution studies) will silently get zeros. + +`AtClusterizeTask` and `AtPulseTask` compute drift time from position, so the main digitization chain is unaffected. + +**Confidence: High.** + +### Finding 3 (LOW): Mass inconsistency between generator and transport in straight-line path + +**Location:** `AtSimpleSimulation.cxx:312,323-326` + +In the straight-line propagation path: + +```cpp +double KE = mom.E() - mom.M(); // mom.M() = PDG mass (from generator's E^2 - p^2) +double eLoss = model->GetEnergyLoss(KE, fDistStep); // model expects KE relative to model mass +auto E = mom.E() - eLoss; +double p = sqrt(E * E - mom.M2()); // uses PDG mass again +``` + +The 4-vector's invariant mass comes from the generator (PDG mass), while `info.mass` in `ParticleInfo` comes from `AddModel(Z, A, model, massAmu)` using `massAmu * 931.494 MeV/c^2`. For protons: PDG mass = 938.272 MeV/c^2 vs. 1.0078 amu x 931.494 = 938.783 MeV/c^2. The ~0.5 MeV difference produces ~0.05% KE offset. + +The curved path correctly uses `info.mass` throughout via `AtTools::Kinematics::KE(momentum, info.mass)`. + +**Confidence: Medium** -- the mass difference is real but the practical impact is small. + +### Finding 4 (LOW): MCTrack metadata sparse + +**Location:** `AtSimpleSimulationTask.cxx:218` + +```cpp +new ((*fMCTrackArray)[particle.trackID]) AtMCTrack(particle.pdgCode, -1, + particle.px, particle.py, particle.pz, + particle.vx, particle.vy, particle.vz, 0.0, 0); +``` + +`parentID = -1`, `time = 0`, `nPoints = 0` for all tracks. In the Geant4 path, `AtStack` fills these with correct values. Code that distinguishes primary from secondary via `parentID == -1` works (both paths use -1 for primaries), but code checking `nPoints` or birth time would get wrong values. + +**Confidence: High.** + +### Finding 5 (LOW): No `correctPosOut()` equivalent + +**Location:** `AtTpc.cxx:131-158` vs `AtSimpleSimulationTask.cxx:296-331` + +In the Geant4 path, `ProcessHits` calls `correctPosOut()` to adjust exit positions to the precise volume boundary using the geometry navigator's safety distance. SimpleSim's exit position is wherever the last step landed, which may overshoot by up to one step size (~1 mm). This affects the last MC point of each track. + +**Confidence: Medium.** + +### Unit conversions -- correct throughout + +The cm<->mm and GeV<->MeV conversions in `TransportParticle` (lines 235-237) and `ProcessDetectorStep` (lines 313-327) are applied consistently. `step.trackMass` is correctly converted from MeV/c^2 to GeV/c^2. Each field in `StepState` was verified against the Geant4 `ProcessHits` path. + +### Generator invocation -- correct + +`FairPrimaryGenerator::GenerateEvent(&fCollector)` correctly drives the same generator chain. The collector captures the same particles that would land on the VMC stack. `AtReactionGenerator::ReadEvent` handles alternation as usual. The `wasBeamEvent` capture before `GenerateEvent` (`AtSimpleSimulationGeneratorTask.cxx:32`) correctly identifies the event type before the internal `EndEvent()` toggle. + +## 4. Pipeline Trace + +**Single primary particle: proton from AtTPC2Body reaction** + +1. **Generator invocation** (reaction event): `LoadEvent()` calls `fPrimGen->GenerateEvent(&fCollector)`. `AtTPC2Body::GenerateReaction()` reads vertex/momentum from `AtVertexPropagator` (set during the prior beam event), computes 2-body kinematics, calls `primGen->AddTrack()`. The collector's `PushTrack` stores `{trackID=0, pdg, px, py, pz, e, vx, vy, vz}` in GeV/cm. + +2. **Unit conversion**: `TransportParticle` converts to mm/MeV: `pos = vertex * 10`, `mom = (p*1000, E*1000)`. + +3. **Sensitive entry**: Vertex is inside drift_volume (that's where the reaction happened). `IsSensitiveVolume` returns true, no `FindSensitiveEntry` needed. + +4. **Initial point**: `SubmitInitialSensitivePoint` sends an entering `StepState` to `AtTpc::ProcessStep`. `trackEnteringVolume` resets `fELossAcc`, captures position/momentum. Since `beamTrack=false`, `InPos` is not updated (correct -- products don't need it). `addHit` records the initial MC point. + +5. **Transport loop**: `AtSimpleSimulation::TransportParticle` drives the RK4 propagator (if B != 0) or straight-line stepper. Each step: compute energy loss from `AtELossModel`, advance position/momentum, invoke callback. + +6. **Step processing**: Callback calls `ProcessDetectorStep` -> `AtTpc::ProcessStep`. `getTrackParametersFromStep` accumulates `fELossAcc`. `addHit` writes an `AtMCPoint` to the detector's `fAtTpcPointCollection` with position (cm), momentum (GeV/c), energy loss (GeV), track length (cm), A, Z, EIni, AIni. + +7. **Exit**: When the proton exits drift_volume to non-sensitive material: `exiting = true`, callback returns `false`, transport stops. + +8. **Tree fill**: After `Exec()` returns, `FairMCApplication::FinishEvent()` calls `FairRootManager::Fill()`, writing the `AtTpcPoint` and `MCTrack` branches. Then `AtTpc::EndOfEvent()` clears the collection. + +**Where output could differ from Geant4:** + +- **Energy loss**: SimpleSim uses a single `AtELossModel` per species; Geant4 models discrete interactions, delta rays, straggling. The overall dE/dx curve should match for CATIMA models but individual point-by-point energy deposits will differ. +- **Step granularity**: SimpleSim's step size is controlled by `fDistStep` (straight line) or `fMaxPropStep` (RK4). Geant4 has its own stepping. Different step counts means different numbers of MC points. +- **No secondary particles**: SimpleSim doesn't produce delta rays, photons, or nuclear fragments. +- **No multiple scattering**: SimpleSim follows the energy-loss direction only. No lateral straggling. +- **Time field is always 0** (Finding 2). + +## 5. Failure Mode Assessment + +| Failure | Downstream impact | Visibility | +|---------|------------------|------------| +| Missing `entering` at drift_volume (Finding 1) | Reaction vertex slightly early due to window energy in fELossAcc. InPos stale. | **Silent** -- shifts are tiny, InPos unused in active code | +| Zero time in MCPoints (Finding 2) | Any timing analysis on MC truth gives 0 | **Visible** if time is plotted, **silent** otherwise | +| Mass mismatch in straight-line path (Finding 3) | ~0.05% KE bias on initial energy, propagates through stopping range | **Silent** -- within model uncertainty | +| MCTrack metadata sparse (Finding 4) | nPoints and birth time wrong | **Silent** unless explicitly checked | +| No correctPosOut (Finding 5) | Last MC point per track overshoots boundary by up to 1 step | **Silent** -- small positional error | + +**Most sensitive downstream stages:** + +- **AtClusterizeTask** output depends on the spatial distribution of energy deposits. Missing delta rays and different step sizes produce quantitatively different cluster distributions. +- **AtPulseTask** produces traces from clusters. Differences propagate to simulated pad responses but the overall pattern (track shape, energy scale) is preserved. +- **Reconstruction** should work correctly -- the track topology is preserved. + +## 6. High-Risk Findings (Top 5) + +1. **`entering` flag misses sensitive-to-sensitive volume boundaries** -- `fELossAcc` includes pre-drift-volume energy, `InPos` is stale. Low practical impact today (window energy negligible, InPos unused), but a latent contract violation that would break if geometry changes or InPos is activated. **Confidence: High.** + +2. **`timeNs` always zero** -- All MC points lack transport time. Silently wrong for any code checking time. **Confidence: High.** + +3. **Mass inconsistency in straight-line path** -- KE computed from PDG mass but fed to model configured with amu-derived mass. ~0.5 MeV offset for protons. Curved path is correct. **Confidence: Medium.** + +4. **MCTrack metadata sparse** -- `parentID = -1`, `time = 0`, `nPoints = 0` for all tracks. Code that checks nPoints or birth time gets wrong values. **Confidence: High.** + +5. **No `correctPosOut()` equivalent** -- Exit positions overshoot the volume boundary by up to one step size (~1 mm). **Confidence: Medium.** + +## 7. Suggested Validation Tests + +**Contract test: entering fires at every volume boundary** + +``` +Setup: geometry with window + drift_volume. Inject a beam starting in the cave. +Assert: trackEnteringVolume is called at least twice (window entry, drift_volume entry). +Assert: fELossAcc is 0 at the first drift_volume step. +Assert: InPos is set to the beam's drift_volume entry position (not (0,0,0)). +``` + +**A/B output comparison (existing macros extend well):** + +- Run `geant4_fixed.C` and `simpleSim_fixed.C` with identical kinematics. +- Compare per-event total energy loss (sum of MCPoint eLoss), number of MC points, and reaction vertex z-coordinate. +- Expected: total energy agrees within CATIMA model accuracy; point count differs; vertex z agrees to within window energy (~0.1 MeV). + +**Invariance check: round-trip energy** + +``` +For each particle: sum(MCPoint.eLoss) + final KE ~= initial KE. +Tolerance: ~1% for step discretization. +This catches unit conversion errors and mass mismatches. +``` + +**Edge case: particle starts outside geometry** + +``` +Inject a particle at (0, 0, -500) mm (outside any volume). +Assert: TransportParticle throws std::invalid_argument. +``` + +**Edge case: missing energy-loss model** + +``` +Transport a particle species (Z, A) without registering a model or factory. +Assert: throws std::invalid_argument with descriptive message. +``` + +**Edge case: zero-momentum particle** + +``` +Inject a particle at the drift_volume entrance with p = (0,0,0). +Assert: FindSensitiveEntry throws (momentum is zero). +Or: if already in sensitive volume, SubmitInitialSensitivePoint handles stopping = true. +``` + +**Replay fidelity test:** + +``` +Run Geant4, then AtSimpleSimulationReplayTask with the output. +Assert: reaction product MC points appear on odd events only. +Assert: beam events (even) produce no MC points (transportPrimaries=false). +Assert: reaction vertex matches the Geant4 vertex from the source file. +``` diff --git a/docs/index.md b/docs/index.md index e8f9c43ab..75a35d69c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ Agent-facing docs for the current branch state of ATTPCROOT. - module map: [reference/modules.md](reference/modules.md) - data model: [reference/data-model.md](reference/data-model.md) - simulation flow: [subsystems/simulation-pipeline.md](subsystems/simulation-pipeline.md) +- SimpleSim migration: [subsystems/simplesim-migration.md](subsystems/simplesim-migration.md) - reconstruction flow: [subsystems/reconstruction-pipeline.md](subsystems/reconstruction-pipeline.md) - generators: [subsystems/generators.md](subsystems/generators.md) - PSA: [subsystems/psa.md](subsystems/psa.md) diff --git a/docs/reference/macro-cookbook.md b/docs/reference/macro-cookbook.md index 6e4938fba..f980ef6c7 100644 --- a/docs/reference/macro-cookbook.md +++ b/docs/reference/macro-cookbook.md @@ -10,11 +10,14 @@ Example macro starting points referenced by these docs. - Files under `macro/` are ROOT / Cling scripts, not normal compiled translation units. - ROOT/core headers such as `TClonesArray.h` or `TTreeReader.h`, and STL headers a macro genuinely uses, are normal. - Do not treat a macro like standalone C++ or try to fix missing symbols by adding project headers first. +- Do not write throwaway ROOT macros outside this tree (for example in `/tmp`). In this repo, macro behavior depends on local relative includes, dictionaries, and the repo runtime environment. If you need to test or inspect something, modify an existing macro in `macro/` or copy it to another local path inside `macro/`. ## Macro Do / Don't - Do: copy an existing pattern from `macro/examples/` or `macro/tests/`. +- Do: for Geant4 generator macros, preserve the generator block's role and unit conventions and mutate it incrementally. - Don't: start by adding `#include "At*.h"` to compensate for missing dictionaries or libraries, or rewrite a macro as if it were a compiled source file. +- Don't: assume beam-species changes are interchangeable with target/product changes. Revalidate beam-path changes with a one-event run before building on them. ## Starting Points diff --git a/docs/reference/modules.md b/docs/reference/modules.md index 919e81a0f..6f04d82f8 100644 --- a/docs/reference/modules.md +++ b/docs/reference/modules.md @@ -12,7 +12,7 @@ ATTPCROOT is organized as CMake library targets layered around data, simulation, | `AtReconstruction` | PSA, filtering, pattern recognition, fitting tasks | `AtPulseAnalyzer/`, `AtPatternRecognition/`, `AtPatternModification/`, `AtFitter/`, `AtFilter/` | change branch flow or task behavior | [reconstruction-pipeline.md](../subsystems/reconstruction-pipeline.md), [branch-io-contracts.md](branch-io-contracts.md) | | `AtUnpack` | experimental data to `AtRawEvent` | unpackers, `GETDecoder2/`, `deprecated/` | add file-format/front-end support | [data-model.md](data-model.md) | | `AtDigitization` | simulation truth to detector signals | clusterization, pulse generation, trigger/space-charge tasks | change MC-to-signal behavior | [simulation-pipeline.md](../subsystems/simulation-pipeline.md), [branch-io-contracts.md](branch-io-contracts.md) | -| `AtTools` | shared utilities | energy loss, kinematics, hit sampling, cleaning, electronic response | algorithmic utilities used across modules | [energy-loss.md](../subsystems/energy-loss.md) | +| `AtTools` | shared utilities | energy loss (models + factories), kinematics, hit sampling, cleaning, electronic response | algorithmic utilities used across modules | [energy-loss.md](../subsystems/energy-loss.md) | | `AtSimulationData` | MC truth and simulation shared state | MC objects, stack, propagator | change truth object layout or simulation state | [simulation-pipeline.md](../subsystems/simulation-pipeline.md), [data-model.md](data-model.md) | | `AtGenerators` | FairRoot generator implementations | reaction and beam generators | change simulation source behavior | [generators.md](../subsystems/generators.md), [simulation-pipeline.md](../subsystems/simulation-pipeline.md) | | `AtDetectors` | detector geometry and sensitive detectors | detector-specific subdirs, field/passive geometry | detector implementation changes | [geometry.md](../subsystems/geometry.md) | diff --git a/docs/subsystems/energy-loss.md b/docs/subsystems/energy-loss.md index 1f1b419af..515bbb7ce 100644 --- a/docs/subsystems/energy-loss.md +++ b/docs/subsystems/energy-loss.md @@ -1,6 +1,6 @@ # Energy Loss -ATTPCROOT provides several energy-loss utilities in `AtTools/`. The modern model interface is `AtTools::AtELossModel`; `AtTools::AtELossManager` is a separate legacy lookup-table helper, not the owner or facade for all models. +ATTPCROOT provides several energy-loss utilities in `AtTools/`. The model interface is `AtTools::AtELossModel`; model instances are served by an `AtTools::AtELossManager` (and subclasses) to transport code such as `AtSimTransport`. ## Main Types @@ -8,9 +8,11 @@ ATTPCROOT provides several energy-loss utilities in `AtTools/`. The modern model |------|------| | `AtELossModel` | abstract interface for stopping power, range, energy loss, and residual energy | | `AtELossCATIMA` | CATIMA-backed implementation | -| `AtELossTable` | table-backed implementation, typically from SRIM-style data | -| `AtELossBetheBloch` | standalone Bethe-Bloch helper for simpler analytic use cases | -| `AtELossManager` | older lookup-table utility; do not treat it as the generic `AtELossModel` entry point | +| `AtELossTable` | table-backed implementation, typically from SRIM/LISE-style data | +| `AtELossBetheBloch` | analytic Bethe-Bloch implementation | +| `AtELossManager` | accepts pre-built models and serves them to transport; base class is accept-only | +| `AtELossManagerBetheBloch` | `AtELossManager` subclass that also auto-generates Bethe-Bloch models from geometry materials | +| `AtELossManagerCATIMA` | `AtELossManager` subclass that auto-generates CATIMA models; carries a `catima::Config` | ## `AtELossModel` Interface @@ -25,7 +27,7 @@ Units in this layer are MeV and mm unless a class-specific note says otherwise. ## CATIMA -`AtELossCATIMA` is the main model-backed implementation. It wraps the CATIMA library and requires both material configuration and projectile configuration. +`AtELossCATIMA` wraps the CATIMA library and requires both material configuration and projectile configuration. Typical setup: @@ -45,15 +47,68 @@ double range = model.GetRange(energyMeV); double residual = model.GetEnergy(energyMeV, distanceMm); ``` -CATIMA is fetched by CMake if it is not already available locally. It is the only backend in this tree that currently exposes straggling calculations through the `AtELossModel` interface. - ## Table and Analytic Helpers -- `AtELossTable` follows the same `AtELossModel` interface, but uses precomputed stopping/range tables. -- `AtELossBetheBloch` is a lighter analytic helper and is not a replacement for the full CATIMA-backed workflow when you need straggling or richer material handling. +- `AtELossTable` — same `AtELossModel` interface, backed by precomputed stopping/range tables loaded via `LoadSrimTable(...)` / `LoadLiseTable(...)`. +- `AtELossBetheBloch` — analytic helper, constructed directly with projectile charge/mass and target Z/A/density/I. + +## `AtELossManager` hierarchy + +`AtELossManager` is the single entry point transport code uses to obtain a model for a given `(Z, A, material)` combination. It has three responsibilities: + +1. Accept pre-built models via `AddModel(...)`. +2. Serve them on `GetModel(Z, A, massAmu, material)`. +3. Optionally synthesize a model from geometry data when the cache misses (subclass hook `GenerateModel`). + +The base class is **accept-only** — if no registration matches the requested particle + material, `GetModel` returns `nullptr`. Two subclasses add auto-generation: + +- **`AtELossManagerBetheBloch`** — synthesizes an `AtELossBetheBloch` from density, Z/A, and mean ionization energy. Mixtures use electron-density-weighted effective Z/A and Bragg's additivity for I. +- **`AtELossManagerCATIMA`** — synthesizes an `AtELossCATIMA`, applying a user-configurable `catima::Config` to every model it creates. + +### Registration forms + +Two overloads of `AddModel` are available: + +```cpp +// Material-agnostic: served whenever the lookup (Z, A) matches, regardless of material. +// Use this for MCFission / AtMCFitter where a single (Z, A) table covers the whole run. +manager->AddModel(Z, A, model); + +// Material-specific: served only when the lookup material name matches. +// Takes priority over the agnostic registration when both are present. +manager->AddModel(Z, A, "iC4H10", model); +``` + +### Lookup priority (inside `GetModel`) + +1. Material-specific registration for `(Z, A, material->GetName())`. +2. Material-agnostic registration for `(Z, A)`. +3. Previously auto-generated model cached under `(Z, A, material->GetName())`. +4. Subclass `GenerateModel(...)` — cached on first success. +5. `nullptr` if none of the above produce a model. + +`ClearCache()` drops auto-generated entries only; registered models survive. + +### Usage with `AtSimTransport` / `AtSimpleSimulation` + +```cpp +auto manager = std::make_shared(); +// optionally: manager->SetConfig(myCatimaConfig); + +// Pre-register a specific table alongside the auto-generator: +auto pbTable = std::make_shared(); +pbTable->LoadSrimTable("PbinHe.txt"); +manager->AddModel(82, 208, pbTable); + +auto sim = std::make_unique("ATTPC_He1bar.root", manager); +``` + +`AtSimTransport` queries the manager at each volume crossing, so crossings into different materials yield different cached models. For the standalone hit-recording class (`AtSimpleSimulation`), the same registrations work via the thin forwarders (`AddModel`, `SetManager`) or by calling through `GetEngine()`. -## Legacy Lookup-Table Path +### Utility methods -`AtELossManager` predates the `AtELossModel` hierarchy. It still exists in the tree, but it is a separate lookup-table class with its own API such as `GetEnergyLoss(...)`, `GetFinalEnergy(...)`, and `GetDistance(...)`. +The following static helpers on `AtELossManager` are usable by any code that works with ROOT geometry materials: -Use it when you specifically need that legacy behavior; do not document or refactor it as if it were the generic model manager for the rest of `AtTools/`. +- `ExtractComposition(material)` — extracts `(A, Z, stoichiometry)` tuples from a `TGeoMaterial` or `TGeoMixture`. +- `WeightFractionsToStoichiometry(weights, atomicMasses)` — converts weight fractions to integer stoichiometry. +- `EffectiveMeanIonization(material)` — effective mean ionization energy via Bragg's additivity rule. diff --git a/docs/subsystems/generators.md b/docs/subsystems/generators.md index 289d3eb3a..c7fa944dd 100644 --- a/docs/subsystems/generators.md +++ b/docs/subsystems/generators.md @@ -34,3 +34,9 @@ The second generator in the chain reads the vertex and momentum from `AtVertexPr ## Beam/Reaction Alternation By default, `ReadEvent()` alternates between inserting a beam event (no reaction) and a full reaction event. This models the AT-TPC's operation where beam tracks are recorded alongside reaction tracks for calibration. The alternation is controlled via `AtVertexPropagator::EndEvent()`. + +## Macro Construction Notes + +- Preserve the generator contract from the source macro when adapting a reaction setup. +- Do not assume beam-species changes are interchangeable with target or product changes. In this branch, changing the beam path can exercise different generator behavior than changing reaction products inside `AtTPC2Body`. +- Validate generator edits incrementally with a one-event Geant4 run before making further changes. diff --git a/docs/subsystems/simplesim-migration.md b/docs/subsystems/simplesim-migration.md new file mode 100644 index 000000000..53195144e --- /dev/null +++ b/docs/subsystems/simplesim-migration.md @@ -0,0 +1,151 @@ +# Migrating a Simulation Macro to SimpleSim + +This guide shows how to convert an existing Geant4/VMC simulation macro to use SimpleSim transport instead. The macro structure, generator setup, and detector configuration stay the same. + +## Class vocabulary (after the rename) + +SimpleSim is split into two user-facing classes plus one FairRoot task: + +- **`AtSimTransport`** — low-level transport engine. Callback-based, no hit recording, no FairRoot. Used by everything else. +- **`AtSimpleSimulation`** — standalone user-facing class. Owns an `AtSimTransport` and records hits into a `TClonesArray`. Used by `AtMCFitter`, `AtMCFission`, and analysis macros that run their own event loop. +- **`AtSimTransportTask`** — FairRoot-integration task (plus `AtSimTransportGeneratorTask` and `AtSimTransportReplayTask` subclasses). Couples an `AtSimTransport` engine to `AtTpc` via the shared `ProcessStep` contract. + +Configuration forwarders on `AtSimpleSimulation` (`AddModel`, `SetMagneticField`, `SetMaxStep`, …) pass through to the engine, so macros that predate the split do not need to reach through `GetEngine()`. + +## What stays the same + +Keep these parts of your Geant4 macro unchanged: + +- `FairRunSim` setup (output file, parameter files, random seed) +- Detector modules (`AtCave`, `AtTpc`) +- Geometry files +- Magnetic field configuration on `FairRunSim` +- Generator chain (`FairPrimaryGenerator`, `AtTPCIonGenerator`, `AtTPC2Body`, etc.) +- Downstream digitization tasks (`AtClusterizeTask`, `AtPulseTask`) + +## What changes + +You replace the transport hookup. In a Geant4 macro, the generator connects directly to the run: + +```cpp +run->SetGenerator(primGen); +``` + +In a SimpleSim macro, the run gets a dummy generator for the event loop, and the real generator is passed to the SimpleSim task: + +```cpp +// Give FairRunSim a dummy generator to drive the event loop +run->SetGenerator(new FairPrimaryGenerator()); + +// Build an energy-loss manager with the models this run needs +auto manager = std::make_shared(); + +auto carbonModel = std::make_shared(gasDensity, gasMaterial); +carbonModel->SetProjectile(16, 6, 16.014701); +manager->AddModel(6, 16, carbonModel); + +auto protonModel = std::make_shared(gasDensity, gasMaterial); +protonModel->SetProjectile(1, 1, 1.0078250322); +manager->AddModel(1, 1, protonModel); + +// Build the transport engine (uses the geometry already loaded by FairRunSim) +auto sim = std::make_unique(manager); + +// Set the magnetic field (in Tesla) +sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + +// Create the task, connect the generator and detector +auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` + +## Required configuration + +### Energy-loss models + +Every particle species that crosses a given material needs a model. Two approaches: + +#### Auto-generating manager (recommended) + +Pass an `AtELossManagerBetheBloch` or `AtELossManagerCATIMA`; they synthesize a model the first time any (Z, A, material) combination is encountered during transport, using density and composition from the geometry: + +```cpp +auto manager = std::make_shared(); +// optional: manager->SetConfig(myCatimaConfig); +auto sim = std::make_unique(manager); +``` + +No per-species configuration needed. Pre-registering a model with `manager->AddModel(Z, A, model)` still works and takes priority over auto-generation. + +#### Manual-only registration + +Use the accept-only base class if you want full control (or need to register SRIM/LISE tables that the factory cannot synthesize): + +```cpp +auto manager = std::make_shared(); +auto table = std::make_shared(); +table->LoadSrimTable("PbinHe.txt"); +manager->AddModel(82, 208, table); +``` + +If a particle has no registration and the manager is accept-only, `GetModel` returns `nullptr` and transport stops. + +#### Available types + +- `AtTools::AtELossManager` — accept-only (manual registrations only). +- `AtTools::AtELossManagerCATIMA` — auto-generates CATIMA models (recommended). +- `AtTools::AtELossManagerBetheBloch` — auto-generates Bethe-Bloch models (lighter, less accurate). +- `AtTools::AtELossCATIMA` / `AtTools::AtELossTable` / `AtTools::AtELossBetheBloch` — concrete model implementations registered via `AddModel`. + +See [energy-loss.md](energy-loss.md) for details. + +### Detector coupling + +`simTask->SetDetector(tpc)` connects the transport engine to the `AtTpc` detector so that steps go through the same entering/accumulate/react pipeline used by Geant4 (`AtTpc::ProcessStep`). This is required for correct output. + +### Magnetic field + +If the experiment uses a magnetic field, set it on the engine: + +```cpp +sim->SetMagneticField(ROOT::Math::XYZVector(Bx, By, Bz)); // Tesla +``` + +This enables curved-track propagation via RK4. Without a field, particles propagate in straight lines. + +### Step size + +`sim->SetMaxStep(stepMm)` sets the maximum distance per step (mm). It applies to both the straight-line and curved paths — whichever is active based on field settings. The legacy name `SetDistanceStep` is kept as an alias. + +### Geometry + +`AtSimTransport()` (default constructor) uses the geometry that `FairRunSim` loads. No separate geometry file is needed. If you need a standalone geometry file (e.g., for testing outside FairRunSim), pass it to the constructor: + +```cpp +auto sim = std::make_unique("path/to/geomanager.root", manager); +``` + +## Replay mode + +`AtSimTransportReplayTask` re-transports primary tracks from a prior Geant4 run through SimpleSim. This enables direct A/B comparison with identical kinematics: + +```cpp +auto *simTask = new AtSimTransportReplayTask(std::move(sim)); +simTask->SetPrimaryTrackSource("geant4_output.root"); // must have MCTrack branch on "cbmsim" tree +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` + +The source file must contain a `cbmsim` TTree with an `MCTrack` branch from a prior Geant4 run. + +## Validation macros + +Working examples are in `macro/Simulation/AtSimValidation/`: + +- `simpleSim_fixed.C` / `geant4_fixed.C` — fixed-angle comparison (manual model registration) +- `simpleSim_kinematic.C` / `geant4_kinematic.C` — full kinematic sweep (manual model registration) +- `simpleSim_fixed_factory.C` / `simpleSim_kinematic_factory.C` — auto-generating CATIMA manager variants +- `simpleSim_fixed_bethebloch.C` — auto-generating Bethe-Bloch manager variant +- `compareFixed.C`, `compareKinematic.C` — automated comparison plots (accept configurable file paths) diff --git a/docs/subsystems/simulation-pipeline.md b/docs/subsystems/simulation-pipeline.md index e808e922c..42cc19154 100644 --- a/docs/subsystems/simulation-pipeline.md +++ b/docs/subsystems/simulation-pipeline.md @@ -2,41 +2,52 @@ The simulation pipeline converts a generated reaction into MC truth, simulated detector responses, and finally `AtRawEvent` output that can be passed into reconstruction. -There are currently two ways to generate the simulation-side `AtMCPoint` input: +There are two transport engines for generating `AtMCPoint` data: -- the full FairRoot/VMC transport path used by most detector simulations -- a lighter-weight model-driven path built around `AtSimpleSimulation` +- **Geant4/VMC** -- full Monte Carlo transport through detector geometry +- **SimpleSim** -- model-driven propagation with user-configured energy loss models, running as a FairTask inside the same FairRunSim event loop -## Flow +Both produce the same `AtMCPoint` and `MCTrack` output format, so the downstream digitization chain (`AtClusterizeTask` -> `AtPulseTask`) works unchanged with either. -The overall simulation flow is shared downstream of `AtMCPoint` generation: +## Flow ``` -FairPrimaryGenerator + AtReactionGenerator AtSimpleSimulation - │ │ - ▼ ▼ - Geant4 / VMC transport direct AtMCPoint generation - │ │ - └───────────────┬──────────────────┘ - ▼ - AtMCPoint - ▼ -AtClusterizeTask - │ converts MC-point deposits -> ionization electron clusters - ▼ -AtPulseTask - │ drifts electrons and produces simulated pad traces - └─ output branch: AtRawEvent -> TClonesArray[AtRawEvent] +FairPrimaryGenerator + AtReactionGenerator + │ + ┌──────┴──────────────┐ + ▼ ▼ + Geant4/VMC transport SimpleSim FairTask + (AtTpc::ProcessHits) (AtSimTransportTask) + │ │ + └──────────┬──────────┘ + ▼ + AtMCPoint + MCTrack + ▼ + AtClusterizeTask + ▼ + AtPulseTask + ▼ + AtRawEvent ``` -The only difference between the two paths is how `AtMCPoint` objects are generated. After that, the downstream digitization flow is the same. +### Geant4/VMC Path + +The standard path. `FairPrimaryGenerator` pushes particles onto `AtStack`; Geant4 transports them through the detector geometry; `AtTpc::ProcessHits()` records `AtMCPoint` entries. + +### SimpleSim Path -### AtMCPoint Generation +SimpleSim runs as a `FairTask` inside the same `FairRunSim` event loop. It uses the `AtSimTransport` engine (owned by `AtSimTransportTask`) to propagate particles through the geometry with energy-loss models served by an `AtELossManager`, supporting both straight-line (no field) and curved-track (magnetic field via RK4) propagation. Steps are fed through `AtTpc::ProcessStep()` -- the same detector logic used by Geant4 -- so reaction triggers, vertex propagation, and hit recording work identically. -- **Primary path:** Geant4/VMC transport through the detector geometry produces `AtMCPoint` objects from the generated particles. -- **Secondary path:** `AtDigitization/AtSimpleSimulation.h` advances particles through the active volume in fixed steps, applies an `AtTools::AtELossModel`, and writes `AtMCPoint` objects directly. +The standalone hit-recording class `AtSimpleSimulation` wraps `AtSimTransport` with a thread-local `TClonesArray` of `AtMCPoint` and is used by `AtMCFitter`, `AtMCFission`, and analysis macros that manage their own event loop. -In this tree, `AtSimpleSimulation` uses straight-line propagation. Future versions may extend this step to non-linear tracks and more general transport models. +Two task classes are provided for the FairRoot path: + +- **`AtSimTransportGeneratorTask`** -- generates events live via a `FairPrimaryGenerator`, using the same generator chain as the Geant4 path. This is the primary task for production use. +- **`AtSimTransportReplayTask`** -- reads primary MCTracks from a prior Geant4 run and re-transports them through SimpleSim. Useful for A/B validation with identical kinematics. + +Both write `AtMCPoint` and `MCTrack` branches in the same format as Geant4, so downstream tasks work unchanged. + +See [simplesim-migration.md](simplesim-migration.md) for a step-by-step guide to converting a Geant4 macro. ### Shared Downstream Stages @@ -52,7 +63,7 @@ Once `AtMCPoint` objects exist, the remaining stages are shared: - `AtMCTrack` simulated particle tracks - `AtMCPoint` - MC-point type used by digitization logic; also the concrete hit type written by `AtSimpleSimulation` + MC-point type used by digitization logic - `AtRawEvent` final simulated raw traces used by reconstruction @@ -60,10 +71,10 @@ See [data-model.md](../reference/data-model.md) for the object-level view and [b ## Event Structure -The FairRoot/VMC generator path represents each physical beam-induced event as two consecutive FairRoot events: +Both transport paths represent each physical beam-induced event as two consecutive FairRoot events: -- Even-indexed event (0, 2, 4, …): beam phase — the beam particle traverses the detector -- Odd-indexed event (1, 3, 5, …): reaction phase — the reaction products are transported +- Even-indexed event (0, 2, 4, ...): beam phase -- the beam particle traverses the detector +- Odd-indexed event (1, 3, 5, ...): reaction phase -- the reaction products are transported Events 0+1 form one complete beam-induced event; events 2+3 form the next, and so on. Code that loops over events or checks event indices must account for this pairing. @@ -71,17 +82,19 @@ Events 0+1 form one complete beam-induced event; events 2+3 form the next, and s ## Required Pieces -For the FairRoot/VMC path, a simulation run needs: +For the **Geant4/VMC** path, a simulation run needs: - detector geometry - a configured `FairPrimaryGenerator` with one or more `AtReactionGenerator` subclasses - the digitization stages `AtClusterizeTask` and `AtPulseTask` - the experiment parameter set used by digitization -For the `AtSimpleSimulation` path, the run replaces VMC transport with: +For the **SimpleSim** path, the run additionally needs: + +- an `AtSimTransport` instance (uses the FairRunSim geometry automatically) +- an `AtELossManager` (or a subclass such as `AtELossManagerCATIMA` / `AtELossManagerBetheBloch`) carrying the energy-loss models for each particle species +- the detector set via `SetDetector(tpc)` on the SimpleSim task -- an `AtSimpleSimulation` instance -- one or more configured `AtTools::AtELossModel` instances -- the same downstream digitization stages if `AtRawEvent` output is needed +Energy loss models must be available for every (Z, A) pair that will be transported in every material they traverse. Models can be registered manually via `manager->AddModel(...)`, or an auto-generating `AtELossManager` subclass can create them from the geometry on demand. If no matching model is registered or generated, the transport stops for that track. -See [generators.md](generators.md) for generator behavior and [energy-loss.md](energy-loss.md) for the model layer used by `AtSimpleSimulation`. +See [generators.md](generators.md) for generator behavior, [energy-loss.md](energy-loss.md) for the model layer, and [simplesim-migration.md](simplesim-migration.md) for the migration guide. diff --git a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md new file mode 100644 index 000000000..9923ffc2d --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -0,0 +1,167 @@ +# Plan: AtSimpleSim Validation and Migration Work + +## Summary + +This directory now has Geant transport macros, comparison macros, and a first pass at `AtSimpleSim` transport for the same validation campaign. The next step is not to update framework-wide documentation yet. The next step is to make the local migration story real, test it against the current macros, and iterate until there is a clean path from an existing Geant-style simulation macro to `AtSimpleSim`. + +This document records the current validated state of the local migration and validation effort, how the `AtSimpleSim` hook works in practice, and which physics checks matter when comparing the SimpleSim and Geant transport paths. + +## Current State of the Code + +### Framework pieces that already exist + +- `AtDigitization/AtSimpleSimulation.{h,cxx}` + direct `AtMCPoint` generation using configured energy-loss models; supports straight-line transport in zero field and curved transport through `AtPropagator` when E/B fields are set. +- `AtDigitization/AtSimParticleCollector.{h,cxx}` + a `FairGenericStack` stub that captures primaries pushed by `FairPrimaryGenerator::GenerateEvent()`. +- `AtDigitization/AtTestSimulation.{h,cxx}` + a `FairTask` bridge that: + - owns an `AtSimpleSimulation` instance, + - drives a `FairPrimaryGenerator`, + - collects generated particles through `AtSimParticleCollector`, + - converts FairRoot units to `AtSimpleSimulation` units, + - forwards each particle to `AtSimpleSimulation::SimulateParticle()`, + - registers the `AtTpcPoint` branch. +- `AtDigitization/AtSimTest.cxx` + compiled unit coverage for `AtSimpleSimulation` transport behavior. +- `AtDetectors/AtTpc/AtTpc.{h,cxx}` + now has a transport-neutral step-processing entrypoint so detector-owned reaction logic can be + exercised outside VMC. + +### Validation work that already exists in this directory + +- Geant macros run in `macro/Simulation/AtSimValidation/`. +- Visualization and comparison macros exist. +- SimpleSim validation macros now run through `AtTestSimulation` and write `AtTpcPoint` output. +- The fixed SimpleSim validation macro now uses the detector-coupled adapter path instead of the + collector-only branch writer. + +### What was wrong and is now fixed + +- The SimpleSim validation macros originally introduced their own macro-local `SimpleSimTask` class instead of using `AtTestSimulation`. +- That duplication has been removed. +- The Geant detector path allowed transport to continue after active-volume exit, which is not the + intended physics for this validation geometry. +- The comparison macros previously dropped zero-point events before truth matching and used an + unstable truth key based on proton start-state coordinates. +- The fixed and kinematic validation macros now run the same `16C + p` configuration on both sides + and compare truth-matched reaction events by reaction index. + +## How the AtSimpleSim Hook Was Added + +The existing bridge was added in the framework, not in this macro directory. + +The transport substitution works like this: + +1. Build the normal FairRoot generator chain with `FairPrimaryGenerator` plus existing ATTPC generators such as `AtTPCIonGenerator` and `AtTPC2Body`. +2. Instead of sending those generated primaries into Geant/VMC transport, call `FairPrimaryGenerator::GenerateEvent()` with `AtSimParticleCollector`. +3. `AtSimParticleCollector` records the generated primaries with FairRoot units: + - position in cm + - momentum and energy in GeV +4. `AtTestSimulation` converts each collected particle to `AtSimpleSimulation` units: + - cm to mm + - GeV to MeV +5. `AtTestSimulation` calls `AtSimpleSimulation::SimulateParticle(...)` for each primary. +6. `AtSimpleSimulation` writes `AtMCPoint` objects directly to the standard simulation branch. + +One important implementation detail uncovered during this migration work: the geometry file handed to `AtTpc` for the run is not automatically the right file for `AtSimpleSimulation`. In this directory the run geometry uses `ATTPC_He1bar.root`, while `AtSimpleSimulation` must be constructed with the importable `ATTPC_He1bar_geomanager.root`. + +This means the intended migration is to preserve the generator physics and replace only the transport mechanism. + +## Working Migration Path + +### 1. Keep the validation macros on the framework bridge + +The validation macros in this directory now use `AtTestSimulation` directly. Any future migration attempt in this area should keep that pattern. Reintroducing a private transport task in a macro body would be a regression back to ad hoc glue. + +### 2. Write and maintain local draft documentation + +Keep draft documentation local to this directory until behavior is verified. + +Required local documents: + +- `AtSimValidationPlan.md` + this status-and-work document. +- `AtSimpleSimMigrationDraft.md` + the working migration guide for converting an existing Geant-style macro to `AtSimpleSim`. + +### 3. Attempt a real macro transition + +Use the Geant validation macro shape already present in this directory as the first migration target. + +The migration attempt should answer: + +- what can stay identical, +- what must change, +- whether `AtTestSimulation` is sufficient without additional framework work, +- whether the remaining differences are small enough to describe as a repeatable checklist. + +### 4. Only then identify framework follow-up + +If the migration is still awkward after using `AtTestSimulation`, record the missing framework work as concrete follow-up items tied to observed problems. Do not invent a second local harness and do not promote any behavior to main docs before it has been exercised here. + +## Actual Validation Procedure + +1. Regenerate the Geant reference output with the current detector code. +2. Regenerate the SimpleSim output with the same generator and geometry choices. +3. Compare only truth-matched reaction events. +4. Report usable pairs separately from generator-only and incomplete events. +5. Inspect both kinematics and path-length distributions, not just whether the macro ran. + +## Acceptance Criteria for a Viable Migration Strategy + +A migration strategy is viable only if all of the following are true: + +- an existing Geant-style macro can be adapted without introducing new macro-local transport glue, +- the adaptation preserves the original generator block and detector setup as much as possible, +- the SimpleSim path produces the expected `AtTpcPoint` output branch for downstream tasks, +- the resulting tracks and stopping behavior are physically credible for the chosen validation case, +- the required edits are small and stable enough to document as a repeatable procedure. + +Until those conditions are met, this work remains a local validation and design iteration effort. + +## Current Verified Result + +- `AtTpc` now owns a shared step-processing path used by both VMC and the SimpleSim adapter. +- Focused unit coverage now exists for: + - detector reaction triggering, + - detector-side `AtVertexPropagator` writes and resets, + - non-beam metadata lookup, + - transport stop on active-volume exit. +- `simpleSim_fixed.C` now produces non-empty beam-event `AtTpcPoint` output through the shared + detector path. +- `simpleSim_fixed.C` now triggers detector-side `AtVertexPropagator` handoff and produces + non-empty reaction-event `AtTpcPoint` output in the fixed validation case. +- `simpleSim_kinematic.C` now preserves the canonical track-ID contract: + - beam remains `trackID == 0`, + - the reaction-event scattered ion remains `trackID == 0`, + - the recoil proton is written as `trackID == 1`, + - the standard `MCTrack` branch is populated without duplicate `MCTrack_*` aliases. +- The detector-side beam-only semantics are now driven by an explicit beam-phase flag carried in + the shared `AtTpc` step payload. That avoids the earlier adapter bug where reaction-event + `trackID == 0` was mistaken for the beam after `AtReactionGenerator` had already toggled + `AtVertexPropagator` to the next event phase. +- `AtTpc` now stops transport when a particle exits the active reaction volume. For this local + validation geometry that is the intended wall boundary condition. +- `visualizeKinematic.C("./data/simpleSim_kinematic.root", 2, 4)` now finds the recoil-proton + truth track. For the current 2-event spot check it reports `Drew 1 trajectories and 1 event + points`; for the earlier 10-event run it reported `Drew 4 trajectories and 5 event points`. +- The long-run kinematic validation no longer stalls around event 190. The failure was a curved- + transport stopping tail for very low-energy recoil protons in the detector-coupled SimpleSim + path. `AtSimpleSimulation` now stops curved tracks below a configurable `0.1 MeV` tolerance by + default, which avoids spending pathological CPU time on the last few millimeters of sub-100 keV + proton range. After that change: + - `simpleSim_kinematic.C(190, 42)` completes in about `2.94 s` + - `simpleSim_kinematic.C(200, 42)` completes in about `2.98 s` + - `visualizeKinematic.C("./data/simpleSim_kinematic.root", 2, 4)` still reports + `Drew 4 trajectories and 100 event points` on the 200-event output. +- The validation comparison macros now match by `reactionIndex` and keep zero-point events in the + truth bookkeeping. +- On regenerated outputs: + - `compareFixed.C` reports `50` usable truth-matched pairs with `0/0` generator-only and `0/0` + incomplete events. + - `compareKinematic.C` reports `100` usable truth-matched pairs with `0/0` generator-only and + `0/0` incomplete events for the reduced 200-event spot check. +- The previous Geant path-length inflation disappeared once the detector-side active-volume stop was + enforced. Residual Geant vs SimpleSim path differences are now at the mm-to-tens-of-mm scale + instead of the earlier hundreds-to-thousands-of-mm scale. diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md new file mode 100644 index 000000000..19f188b0e --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md @@ -0,0 +1,459 @@ +# Plan: Integrating AtSimpleSim as a Natural Simulation Hook + +## Purpose + +This document is a standalone implementation plan for adapting `AtSimpleSimulation` into the main ATTPC simulation pathway without breaking the existing simulation contract. + +The goal is not to create a parallel ad hoc pathway that happens to produce `AtTpcPoint`. The goal is to make `AtSimpleSimulation` act as a transport replacement while preserving the detector-side logic and shared simulation state that the current Geant/VMC path relies on. + +This document is intended to be handed to a new agent with no prior conversation context. + +## Current Problem + +The existing SimpleSim bridge captures particles too early. + +Current hook: + +- `AtDigitization/AtTestSimulation.{h,cxx}` +- `AtDigitization/AtSimParticleCollector.{h,cxx}` + +Current behavior: + +1. `FairPrimaryGenerator::GenerateEvent()` is called against `AtSimParticleCollector` +2. `AtSimParticleCollector` stores the generated primaries at `PushTrack(...)` time +3. `AtTestSimulation` converts those particles to SimpleSim units +4. `AtTestSimulation` calls `AtSimpleSimulation::SimulateParticle(...)` + +This preserves generator logic, but it bypasses the detector stepping logic that the Geant/VMC path uses to: + +- decide where a reaction occurs +- update `AtVertexPropagator` +- populate track-angle and track-energy metadata for reaction products +- apply detector-specific hit logic inside `AtTpc` + +As a result, the current SimpleSim hook is not a drop-in replacement for the transport layer. + +## Key Conclusion + +`AtSimpleSimulation` should not be integrated primarily by intercepting particles at the custom stack level. + +Why: + +- `AtStack` and `AtSimParticleCollector` only see particles when they are pushed by the generator +- they do not know about detector volume entry, energy-loss accumulation, stopping condition, or reaction position +- those semantics currently live in `AtDetectors/AtTpc/AtTpc.cxx` + +Therefore the correct integration point is not “generator to stack.” + +The correct integration point is the transport-to-detector boundary. + +## Compatibility Constraint + +Any integration work proposed here must preserve the existing behavior of `AtSimpleSimulation` for other code paths that already use it outside the main simulation flow. + +Known existing users include: + +- MC fitting and reconstruction-side tools that depend on `AtSimpleSimulation` directly +- existing analysis or visualization macros that construct `AtSimpleSimulation` without the full detector simulation stack + +Relevant files to inspect before refactoring: + +- `AtReconstruction/AtFitter/AtMCFitter.cxx` +- `AtReconstruction/AtFitter/AtMCFission.cxx` +- `macro/e12014/adam/simulation/simpleSim.C` +- `macro/e12014/adam/determineZ/run_eve_sim.C` + +Implementation implication: + +- do not repurpose `AtSimpleSimulation` itself into something that requires `AtTpc`, `FairRunSim`, or `AtVertexPropagator` to function in its existing standalone uses +- keep the new hook logic in an adapter layer around `AtSimpleSimulation`, not by breaking the current direct-usage API +- any shared detector-side path introduced for the main simulation flow must be additive and must not regress current SimpleSim-based fitting or analysis workflows + +## Existing Contract in the Geant/VMC Path + +### Relevant files + +- `AtDetectors/AtTpc/AtTpc.cxx` +- `AtSimulationData/AtVertexPropagator.{h,cxx}` +- `AtGenerators/AtReactionGenerator.{h,cxx}` +- `AtGenerators/AtTPCIonGenerator.cxx` +- `AtGenerators/AtTPC2Body.cxx` +- `AtSimulationData/AtStack.{h,cxx}` + +### Current data flow + +1. `FairPrimaryGenerator` runs the generator chain. +2. `AtStack` stores pushed particles for VMC transport. +3. VMC transports the beam particle through `AtTpc`. +4. `AtTpc::ProcessHits()` is called on detector steps. +5. `AtTpc` accumulates energy loss and tracks volume entry/exit. +6. `AtTpc::reactionOccursHere()` checks whether the sampled reaction point has been reached. +7. `AtTpc::startReactionEvent()` writes the reaction state into `AtVertexPropagator`. +8. On the following reaction event, `AtTPC2Body` reads `AtVertexPropagator` and generates the reaction products from the correct residual beam state. + +### Critical detector-side logic + +The core contract currently lives in `AtDetectors/AtTpc/AtTpc.cxx`. + +Important methods: + +- `trackEnteringVolume()` +- `getTrackParametersFromMC()` +- `getTrackParametersWhileExiting()` +- `reactionOccursHere()` +- `startReactionEvent()` +- `addHit()` +- `resetVertex()` + +Most important line of contract: + +- `startReactionEvent()` calls `AtVertexPropagator::SetVertex(...)` + +That write includes: + +- reaction vertex position +- beam entrance position into the TPC +- beam momentum at the reaction point +- residual beam energy at the reaction point + +This is the state that the reaction generators need in active-target mode. + +### Generator dependence on that contract + +`AtGenerators/AtTPC2Body.cxx` uses: + +- `AtVertexPropagator::GetEnergy()` +- `AtVertexPropagator::GetVx()/GetVy()/GetVz()` +- `AtVertexPropagator::GetPx()/GetPy()/GetPz()` + +If that state is not populated by the beam transport phase, the reaction generator does not reproduce the Geant pathway. + +## Why the Current SimpleSim Hook Is Insufficient + +The current SimpleSim bridge bypasses `AtTpc::ProcessHits()`. + +That means it bypasses: + +- detector-side reaction trigger logic +- the handoff into `AtVertexPropagator` +- detector-side hit population semantics + +Even if the same `FairPrimaryGenerator` object is reused, that is not enough. The Geant pathway depends on detector-produced state, not just generator-produced state. + +So the current bridge is preserving generator syntax, but not the simulation contract. + +## Verified on This Branch + +The shared detector-path refactor is now in place and the current local status is: + +- `AtTpc` owns the shared step-processing logic for both VMC and the SimpleSim adapter. +- `AtTestSimulation` now preserves the canonical track-ID convention: + - beam is `trackID == 0`, + - the reaction-event scattered ion also remains `trackID == 0`, + - the recoil proton is `trackID == 1`, + - the standard `MCTrack` branch is filled through the existing stack-owned branch name. +- The shared detector step now carries explicit beam-phase information from the transport adapter. + This is required because `AtReactionGenerator::ReadEvent()` toggles + `AtVertexPropagator::IsBeamEvent()` before transport starts, so detector stepping cannot infer + the current transport phase from the singleton alone. +- `simpleSim_kinematic.C` now produces visible reaction tracks in + `visualizeKinematic.C`; with `simpleSim_kinematic.C(10, 42)` the viewer reports + `Drew 4 trajectories and 5 event points`. +- The curved SimpleSim transport now uses a configurable low-energy stop tolerance in field mode. + This was required because the detector-coupled kinematic validation entered a pathological + stopping tail for the event-190 recoil proton. With the default `0.1 MeV` curved stop tolerance: + - `simpleSim_kinematic.C(190, 42)` completes in about `2.94 s` + - `simpleSim_kinematic.C(200, 42)` completes in about `2.98 s` + - the 200-event output still visualizes correctly in `visualizeKinematic.C` +- The earlier failure mode was adapter-side: + reaction-event `trackID == 0` was first handled by shifting products away from slot `0`, but + that diverged from the Geant truth contract. The current fix keeps Geant-style IDs and instead + prevents reaction-event `trackID == 0` from tripping beam-only detector semantics by carrying the + beam/reaction phase explicitly into the shared detector-step path. A duplicate `MCTrack` + registration had also caused ROOT to rename the truth branches away from the canonical + `MCTrack` name expected by the visualizer. + +## Proposed Integration Strategy + +### Design principle + +Treat `AtSimpleSimulation` as a transport replacement, not as a detector replacement. + +The detector logic should remain the owner of: + +- reaction triggering +- `AtVertexPropagator` updates +- detector hit semantics + +### Primary refactor target + +Refactor `AtDetectors/AtTpc/AtTpc.cxx` so the transport-specific VMC reads are separated from the detector’s core logic. + +The intended structure is: + +1. a thin VMC adapter layer in `AtTpc::ProcessHits()` +2. a transport-neutral internal interface that consumes step state + +Conceptually: + +- keep `AtTpc::ProcessHits(FairVolume *vol)` for Geant/VMC +- add a new internal method that processes one transport step from plain data + +Example conceptual shape: + +```cpp +struct AtTpcStepState { + int trackID; + int pdg; + std::string volumeName; + int volumeID; + int detCopyID; + bool isEntering; + bool isExiting; + bool isStopping; + bool isDisappeared; + double eDep; + double trackTimeNs; + double trackLength; + TLorentzVector posIn; + TLorentzVector posOut; + TLorentzVector momIn; + TLorentzVector momOut; + double etotGeV; + double trackMassGeV; +}; +``` + +The exact type names can differ, but the intent should be this: + +- Geant/VMC fills this from `gMC` +- SimpleSim fills this from its own propagation state +- the detector core uses the same path in both cases + +### Target shared logic inside AtTpc + +These behaviors should move under the shared transport-neutral path: + +- beam entering-volume bookkeeping +- energy-loss accumulation +- reaction-point decision +- `AtVertexPropagator::SetVertex(...)` +- detector hit creation +- track-angle and track-energy lookup for non-beam products + +### Role of AtSimpleSimulation after refactor + +`AtSimpleSimulation` should provide transport evolution and step-state generation, not the top-level detector semantics. + +That means: + +- it can still be responsible for energy loss and curved/straight propagation +- but it should not be the sole owner of writing final `AtTpcPoint` objects for the main simulation path if that bypasses `AtTpc` + +Instead, it should feed a detector-facing adapter that calls the shared `AtTpc` step-processing code. + +## Concrete Hook Points + +### Hook point 1: AtTpc internal refactor + +File: + +- `AtDetectors/AtTpc/AtTpc.cxx` + +Work: + +- isolate all direct `gMC` reads behind a thin adapter +- introduce a transport-neutral step-processing path +- move `reactionOccursHere()` and `startReactionEvent()` under that shared path + +Why: + +- this is where the real Geant contract currently lives + +### Hook point 2: SimpleSim transport adapter + +Likely files: + +- `AtDigitization/AtSimpleSimulation.{h,cxx}` +- new adapter file in `AtDigitization/` +- possibly refactor or replace `AtTestSimulation` + +Work: + +- drive the same generator chain +- consume generated beam/reaction tracks +- propagate them with SimpleSim +- emit transport step-state objects compatible with the new `AtTpc` shared interface + +Why: + +- this keeps SimpleSim as a transport engine while preserving detector semantics + +### Hook point 3: Stack and generator flow + +Files: + +- `AtSimulationData/AtStack.{h,cxx}` +- `AtDigitization/AtSimParticleCollector.{h,cxx}` +- `AtDigitization/AtTestSimulation.{h,cxx}` + +Current status: + +- useful for studying generator output +- not sufficient as the primary integration layer + +Expected future role: + +- either reduced to a helper for capturing generated primaries +- or replaced by a more natural transport runner once the detector-side shared path exists + +Important note: + +- do not put `AtVertexPropagator` contract logic into the collector +- do not reimplement detector reaction semantics inside a custom stack + +## Expected Data Flow After Refactor + +### Geant/VMC path + +1. Generator chain runs as it does now. +2. `AtStack` stores primaries. +3. VMC transports tracks. +4. `AtTpc::ProcessHits()` builds step state from `gMC`. +5. Shared `AtTpc` transport-neutral logic processes that state. +6. `AtVertexPropagator` is updated by detector logic. +7. Reaction generators consume that state as they do now. + +### SimpleSim path + +1. Same generator chain runs. +2. Transport runner obtains the generated primaries. +3. `AtSimpleSimulation` propagates the tracks. +4. SimpleSim transport emits step-state objects equivalent to the Geant detector-facing information. +5. Shared `AtTpc` transport-neutral logic processes that state. +6. `AtVertexPropagator` is updated by detector logic, not by macro glue. +7. Reaction generators consume that state without special-case changes. + +This is the required shape for “drop-in replacement” to be credible. + +## What Must Not Be Done + +- Do not solve this by setting fixed target positions in macros. +- Do not solve this by bypassing `AtVertexPropagator`. +- Do not encode reaction-trigger semantics directly into `AtTestSimulation`. +- Do not document a user-facing migration path until the active-target contract is actually preserved. +- Do not treat non-empty `AtTpcPoint` output alone as proof that the contract is correct. + +## Test Plan + +The test plan must validate both structure and physics. + +### 1. Unit tests for transport-neutral detector logic + +Target: + +- new tests around the shared `AtTpc` step-processing logic + +What to validate: + +- entering-volume state is tracked correctly +- energy-loss accumulation matches expected behavior +- reaction trigger fires only for beam track in the right volume +- `AtVertexPropagator::SetVertex(...)` receives the correct position, momentum, and residual energy +- non-beam tracks receive angle/energy metadata consistently + +Why: + +- this is the core contract that must be shared between Geant and SimpleSim + +### 2. Unit tests for VertexPropagator handoff + +Files: + +- extend `AtSimulationData/AtVertexPropagatorTest.cxx` or add adjacent tests + +What to validate: + +- detector-side update path writes the expected state +- reaction generators can read the written state and produce non-zero kinematics +- event alternation still behaves as expected + +### 3. Generator compatibility tests + +Targets: + +- `AtTPCIonGenerator` +- `AtTPC2Body` +- `AtReactionGenerator` + +What to validate: + +- the same generator chain works with detector-produced `AtVertexPropagator` state +- no macro-level fixed-target workaround is required for the active-target case + +### 4. Integration test for the current fixed validation case + +Macro: + +- `macro/Simulation/AtSimValidation/simpleSim_fixed.C` + +Success criteria: + +- macro runs from a clean output directory +- beam event produces the detector-side reaction-state handoff +- reaction event produces non-empty `AtTpcPoint` +- `AtVertexPropagator` state is populated through the shared detector logic, not macro glue + +### 5. Side-by-side contract checks against Geant + +Macros: + +- `macro/Simulation/AtSimValidation/geant4_fixed.C` +- `macro/Simulation/AtSimValidation/simpleSim_fixed.C` + +What to compare: + +- event pairing structure +- reaction vertex location distribution +- residual beam energy at reaction +- non-zero reaction product generation +- `AtTpcPoint` occupancy and track IDs + +This should happen before any final physics overlay claims. + +### 6. Physics validation after contract validation + +Only after the shared contract is working: + +- compare track topology +- compare stopping behavior +- compare kinematic loci +- compare Bragg-like observables + +Do not use physics plots to hide a broken contract. + +## Immediate Implementation Order + +1. Refactor `AtTpc` to expose a transport-neutral step-processing path. +2. Write unit tests around the shared detector logic and `AtVertexPropagator` handoff. +3. Build a SimpleSim-side transport adapter that feeds that shared path. +4. Replace the current validation macro’s ad hoc assumptions with the shared adapter. +5. Re-run the fixed validation case and confirm non-empty reaction-event output. +6. Only after that revisit documentation of macro migration. + +## Current Status Summary + +Current verified state: + +- the custom collector can reuse generator syntax +- `AtTpc` now exposes a transport-neutral detector-side step path +- unit tests cover detector-trigger logic, vertex handoff state writes, and the new SimpleSim callback transport +- the fixed SimpleSim macro can be made to run with `TGeant3` +- the detector-coupled SimpleSim adapter now produces non-empty beam-event `AtTpcPoint` output through shared detector logic +- the fixed validation macro now triggers detector-side reaction handoff and reaches `AtTPC2Body` with non-zero residual beam energy +- the fixed validation macro now produces non-empty reaction-event `AtTpcPoint` output through the shared path + +Current blocker: + +- end-to-end detector contract is now working in the fixed validation case +- the remaining follow-up is physics parity and broader comparison against the Geant validation macros diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md new file mode 100644 index 000000000..74f1b2501 --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -0,0 +1,251 @@ +# User Guide: Migrating a Simulation Macro from Geant to SimpleSim + +## Purpose + +This guide is for users who already have a working simulation macro and want to swap the transport from Geant/VMC to SimpleSim without changing the physics setup. + +The goal is simple: + +- keep the same macro structure +- keep the same generator setup +- keep the same detector setup +- replace only the transport hookup + +## What You Usually Start With + +A typical user macro already has: + +- a `FairRunSim` +- detector modules such as `AtCave` and `AtTpc` +- an inline `FairPrimaryGenerator` setup +- a line like `run->SetGenerator(primGen)` +- `run->Init()` and `run->Run(nEvents)` + +That is enough. You do not need to restructure the macro into helper functions to use SimpleSim. + +## What Stays the Same + +Keep these parts unchanged unless you have a physics reason to change them: + +- the beam definition +- the reaction-generator configuration +- the detector modules +- the run geometry +- the magnetic field +- the random seed handling +- the output file and parameter-file setup + +If the Geant macro already produces the right physics input, preserve that input. + +## What Actually Changes + +You replace the transport hookup. + +In a Geant macro, the physics generator is usually connected directly to the run: + +```cpp +run->SetGenerator(primGen); +``` + +In a SimpleSim macro, the run gets a dummy event-loop generator, and the real physics generator is passed to `AtSimpleSimulationGeneratorTask`: + +```cpp +run->SetGenerator(new FairPrimaryGenerator()); + +auto *simTask = new AtSimpleSimulationGeneratorTask(BuildSimpleSimulation()); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` + +That is the main migration step. + +## Minimal Migration Procedure + +### 1. Copy the working Geant macro + +Start from the macro that already works for your case. + +Do not refactor the macro at the same time. First make the transport swap only. + +### 2. Keep the generator block as it is + +If your macro builds the generator inline, keep it inline. + +For example, if you already have: + +```cpp +auto *primGen = new FairPrimaryGenerator(); + +auto *ionGen = new AtTPCIonGenerator(...); +primGen->AddGenerator(ionGen); + +auto *twoBody = new AtTPC2Body(...); +primGen->AddGenerator(twoBody); +``` + +leave that section alone. + +The migration should not require users to move their generator code into helper functions. + +### 3. Add a SimpleSim configuration block + +You need one place where `AtSimpleSimulation` is configured. + +This can be: + +- a helper function such as `BuildSimpleSimulation(...)` +- or inline setup code if you prefer + +What matters is that you configure: + +- the geometry file for SimpleSim +- the transported species +- the energy-loss models +- the field, if needed +- the step settings, if needed + +Minimal example: + +```cpp +std::unique_ptr BuildSimpleSimulation() +{ + auto sim = std::make_unique(); // uses FairRunSim geometry + + constexpr double gasDensity = 1.664e-4; + std::vector> material{{4, 2, 1}}; + + auto ionModel = std::make_shared(gasDensity, material); + ionModel->SetProjectile(16, 6, 16.014701); + sim->AddModel(6, 16, ionModel, 16.014701); + + auto protonModel = std::make_shared(gasDensity, material); + protonModel->SetProjectile(1, 1, 1.0078250322); + sim->AddModel(1, 1, protonModel, 1.0078250322); + + sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + sim->SetMaxPropagationStep(1e-3); + return sim; +} +``` + +You must add a model for every species that will be transported. + +### 4. Replace the Geant transport hookup + +Leave the run setup mostly alone and replace only the generator hookup. + +Typical Geant-style pattern: + +```cpp +run->SetGenerator(primGen); +``` + +SimpleSim pattern: + +```cpp +run->SetGenerator(new FairPrimaryGenerator()); + +auto *simTask = new AtSimpleSimulationGeneratorTask(BuildSimpleSimulation()); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` + +Important: + +- `FairRunSim` still needs a generator object for the event loop +- the real physics generator is now passed into the SimpleSim task +- `AtSimpleSimulation()` (default constructor) automatically uses the geometry loaded by `FairRunSim` -- no separate geometry file needed + +### 5. Keep the detector coupled + +Always connect the detector: + +```cpp +simTask->SetDetector(tpc); +``` + +That keeps detector-side behavior such as: + +- reaction handling +- `AtVertexPropagator` updates +- `AtTpcPoint` production +- active-volume stopping behavior + +## Copy-and-Edit Checklist + +For most user macros, the migration is: + +1. Copy the Geant macro to a new SimpleSim macro. +2. Leave the generator block unchanged. +3. Add SimpleSim configuration. +4. Replace `run->SetGenerator(primGen)` with: + - `run->SetGenerator(new FairPrimaryGenerator())` + - `AtSimpleSimulationGeneratorTask` + - `simTask->SetPrimaryGenerator(primGen)` + - `simTask->SetDetector(tpc)` + - `run->AddTask(simTask)` +5. Add energy-loss models for all transported species. +7. Run a small sample first. +8. Verify the output before scaling up. + +## What to Verify After Migration + +### Output checks + +- The output ROOT file is produced. +- The `cbmsim` tree contains `AtTpcPoint`. +- The truth branches expected by your downstream macros are present. +- Downstream analysis macros can open the file without special-case handling. + +### Physics checks + +- The beam and reaction setup are unchanged from the Geant macro. +- The geometry and field are unchanged. +- Track shapes look reasonable. +- Path lengths are physically credible. +- Stopping behavior looks right for your detector geometry. + +For the local validation geometry in this directory, particles are expected to stop when they leave the active reaction volume because that boundary corresponds to chamber material, not open vacuum. + +## How to Compare Against Geant + +Do not rely on file order alone when comparing outputs. + +Use truth-matched comparisons and keep these categories separate: + +- usable matched events +- generator-only events +- incomplete events + +Local comparison macros in this directory already do that: + +- [compareFixed.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/compareFixed.C) +- [compareKinematic.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/compareKinematic.C) + +## Practical Notes + +- `AtSimpleSimulation` needs explicit energy-loss models. +- The SimpleSim task skips particles that start outside `drift_volume`. +- `AtSimpleSimulation` uses mm and MeV internally. +- The generator side still comes from the normal FairRoot macro world, which uses cm and GeV. +- When editing ROOT macros, prefer adapting an existing working macro instead of inventing a new structure. + +## Suggested Workflow + +1. Start from your existing Geant macro. +2. Change only the transport hookup. +3. Run a very small sample. +4. Check that `AtTpcPoint` and truth output look sane. +5. Compare against the Geant output. +6. Only then scale up to larger production runs. + +## Optional Local References + +If you want concrete examples of this migration pattern, see: + +- [geant4_fixed.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/geant4_fixed.C) +- [simpleSim_fixed.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/simpleSim_fixed.C) +- [geant4_kinematic.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/geant4_kinematic.C) +- [simpleSim_kinematic.C](/home/adam/ATTPCROOTv2-Sim/macro/Simulation/AtSimValidation/simpleSim_kinematic.C) diff --git a/macro/Simulation/AtSimValidation/compareFixed.C b/macro/Simulation/AtSimValidation/compareFixed.C new file mode 100644 index 000000000..d6a9747ef --- /dev/null +++ b/macro/Simulation/AtSimValidation/compareFixed.C @@ -0,0 +1,583 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr double kProtonMassMeV = 938.27208816; + +struct PointSample { + double x{}; + double y{}; + double z{}; + double px{}; + double py{}; + double pz{}; + double eLoss{}; + double length{}; +}; + +struct TrackData { + std::vector points; +}; + +struct ReactionEvent { + int fairEvent{-1}; + int reactionIndex{-1}; + double truthKE{0.0}; + double truthTheta{0.0}; + double truthPhi{0.0}; + TrackData proton; +}; + +struct MatchSummary { + std::vector> usablePairs; + size_t geantOnly{0}; + size_t simpleOnly{0}; + size_t geantIncomplete{0}; + size_t simpleIncomplete{0}; +}; + +bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } + +void ExpandRange(double value, double &minValue, double &maxValue) +{ + minValue = std::min(minValue, value); + maxValue = std::max(maxValue, value); +} + +std::pair AddMargin(double minValue, double maxValue) +{ + const double span = std::max(1.0, maxValue - minValue); + return {minValue - 0.08 * span, maxValue + 0.08 * span}; +} + +double GetMaxStoppingPower(const TrackData &track) +{ + double maxValue = 0.; + for (size_t i = 1; i < track.points.size(); ++i) { + const auto &prev = track.points[i - 1]; + const auto &curr = track.points[i]; + const double dx = curr.x - prev.x; + const double dy = curr.y - prev.y; + const double dz = curr.z - prev.z; + const double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step <= 1e-6 || curr.eLoss <= 0.) + continue; + maxValue = std::max(maxValue, curr.eLoss / step); + } + return maxValue; +} + +std::pair GetCoordinateRange(const TrackData &lhs, const TrackData &rhs, char axis) +{ + double minValue = std::numeric_limits::max(); + double maxValue = std::numeric_limits::lowest(); + auto updateTrack = [&](const TrackData &track) { + for (const auto &point : track.points) { + double value = 0.; + switch (axis) { + case 'x': + value = point.x; + break; + case 'y': + value = point.y; + break; + case 'z': + value = point.z; + break; + default: + return; + } + ExpandRange(value, minValue, maxValue); + } + }; + updateTrack(lhs); + updateTrack(rhs); + return AddMargin(minValue, maxValue); +} + +double GetPathLength(const TrackData &track) +{ + if (track.points.size() < 2) + return 0.; + return track.points.back().length - track.points.front().length; +} + +double GetFinalZ(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + return track.points.back().z; +} + +double GetInitialThetaDeg(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + return std::atan2(std::hypot(point.px, point.py), point.pz) * TMath::RadToDeg(); +} + +double GetInitialPhiDeg(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + return std::atan2(point.py, point.px) * TMath::RadToDeg(); +} + +double GetInitialKineticEnergyMeV(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + const double momentum = std::sqrt(point.px * point.px + point.py * point.py + point.pz * point.pz); + return std::sqrt(momentum * momentum + kProtonMassMeV * kProtonMassMeV) - kProtonMassMeV; +} + +double GetTrackThetaDeg(const AtMCTrack &track) +{ + return std::atan2(std::hypot(track.GetPx(), track.GetPy()), track.GetPz()) * TMath::RadToDeg(); +} + +double GetTrackPhiDeg(const AtMCTrack &track) { return std::atan2(track.GetPy(), track.GetPx()) * TMath::RadToDeg(); } + +double GetTrackKineticEnergyMeV(const AtMCTrack &track) +{ + const double px = track.GetPx() * 1000.0; + const double py = track.GetPy() * 1000.0; + const double pz = track.GetPz() * 1000.0; + const double momentum = std::sqrt(px * px + py * py + pz * pz); + return std::sqrt(momentum * momentum + kProtonMassMeV * kProtonMassMeV) - kProtonMassMeV; +} + +TrackData BuildTrackData(int trackID, TClonesArray *points) +{ + TrackData out; + for (int i = 0; i < points->GetEntriesFast(); ++i) { + auto *pt = dynamic_cast(points->At(i)); + if (!pt || pt->GetTrackID() != trackID) + continue; + + out.points.push_back({pt->GetX() * 10., pt->GetY() * 10., pt->GetZ() * 10., pt->GetPx() * 1000., + pt->GetPy() * 1000., pt->GetPz() * 1000., pt->GetEnergyLoss() * 1000., + pt->GetLength() * 10.}); + } + std::sort(out.points.begin(), out.points.end(), SortByLength); + return out; +} + +bool IsPrimaryProton(AtMCTrack *track) +{ + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == 2212; +} + +std::vector LoadReactionEvents(const TString &fileName) +{ + std::vector events; + + auto *file = TFile::Open(fileName); + if (!file || file->IsZombie()) { + std::cerr << "Cannot open " << fileName << "\n"; + return events; + } + + auto *tree = dynamic_cast(file->Get("cbmsim")); + if (!tree) { + std::cerr << "Missing cbmsim tree in " << fileName << "\n"; + file->Close(); + return events; + } + + TClonesArray *pointArray = nullptr; + TClonesArray *trackArray = nullptr; + tree->SetBranchAddress("AtTpcPoint", &pointArray); + tree->SetBranchAddress("MCTrack", &trackArray); + + for (Long64_t iEvent = 1; iEvent < tree->GetEntries(); iEvent += 2) { + tree->GetEntry(iEvent); + if (!trackArray) + continue; + + int protonTrackID = -1; + for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(trackArray->At(i)); + if (IsPrimaryProton(track)) { + protonTrackID = i; + break; + } + } + + if (protonTrackID < 0) + continue; + + auto *protonTrack = dynamic_cast(trackArray->At(protonTrackID)); + if (protonTrack == nullptr) + continue; + + auto proton = BuildTrackData(protonTrackID, pointArray); + events.push_back({static_cast(iEvent), static_cast(iEvent / 2), GetTrackKineticEnergyMeV(*protonTrack), + GetTrackThetaDeg(*protonTrack), GetTrackPhiDeg(*protonTrack), std::move(proton)}); + } + + file->Close(); + return events; +} + +MatchSummary MatchReactionEvents(const std::vector &geant, const std::vector &simple) +{ + MatchSummary summary; + + std::map simpleByTruth; + for (const auto &event : simple) + simpleByTruth[event.reactionIndex] = &event; + + for (const auto &event : geant) { + auto it = simpleByTruth.find(event.reactionIndex); + if (it == simpleByTruth.end()) { + ++summary.geantOnly; + continue; + } + + const auto &simpleEvent = *it->second; + simpleByTruth.erase(it); + + const bool geantUsable = event.proton.points.size() >= 2; + const bool simpleUsable = simpleEvent.proton.points.size() >= 2; + if (!geantUsable) + ++summary.geantIncomplete; + if (!simpleUsable) + ++summary.simpleIncomplete; + if (!geantUsable || !simpleUsable) + continue; + + summary.usablePairs.emplace_back(event, simpleEvent); + } + + summary.simpleOnly = simpleByTruth.size(); + return summary; +} + +TGraph *MakeProjectionGraph(const TrackData &track, bool xz, const char *name, Color_t color, Style_t style) +{ + auto *graph = new TGraph(track.points.size()); + graph->SetName(name); + graph->SetLineColorAlpha(color, 0.65); + graph->SetLineWidth(3); + graph->SetLineStyle(style); + graph->SetMarkerColorAlpha(color, 0.55); + graph->SetMarkerStyle(style == 1 ? 20 : 24); + graph->SetMarkerSize(0.55); + for (size_t i = 0; i < track.points.size(); ++i) { + const auto &point = track.points[i]; + graph->SetPoint(i, xz ? point.z : point.x, xz ? point.x : point.y); + } + return graph; +} + +TPolyLine3D *MakeTrackLine3D(const TrackData &track, const char *name, Color_t color, Style_t style) +{ + auto *line = new TPolyLine3D(track.points.size()); + (void)name; + line->SetLineColorAlpha(color, 0.55); + line->SetLineWidth(4); + line->SetLineStyle(style); + for (size_t i = 0; i < track.points.size(); ++i) + line->SetPoint(i, track.points[i].x, track.points[i].y, track.points[i].z); + return line; +} + +TPolyMarker3D *MakeTrackMarkers3D(const TrackData &track, Color_t color, Style_t style) +{ + const int stride = std::max(1, track.points.size() / 60); + const int nMarkers = static_cast((track.points.size() + stride - 1) / stride); + auto *markers = new TPolyMarker3D(nMarkers); + markers->SetMarkerColorAlpha(color, 0.75); + markers->SetMarkerStyle(style == 1 ? 20 : 24); + markers->SetMarkerSize(0.6); + + int index = 0; + for (size_t i = 0; i < track.points.size(); i += stride) + markers->SetPoint(index++, track.points[i].x, track.points[i].y, track.points[i].z); + return markers; +} + +TGraph *MakeResidualRangeGraph(const TrackData &track, const char *name, Color_t color, Style_t style) +{ + std::vector> samples; + const double startLength = track.points.front().length; + const double totalPath = GetPathLength(track); + + for (size_t i = 1; i < track.points.size(); ++i) { + const auto &prev = track.points[i - 1]; + const auto &curr = track.points[i]; + const double dx = curr.x - prev.x; + const double dy = curr.y - prev.y; + const double dz = curr.z - prev.z; + const double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step <= 1e-6 || curr.eLoss <= 0.) + continue; + + const double travelled = curr.length - startLength; + const double residualRange = std::max(0.0, totalPath - travelled); + samples.emplace_back(residualRange, curr.eLoss / step); + } + + auto *graph = new TGraph(samples.size()); + graph->SetName(name); + graph->SetLineColor(color); + graph->SetLineWidth(2); + graph->SetLineStyle(style); + graph->SetMarkerColor(color); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.35); + for (size_t i = 0; i < samples.size(); ++i) + graph->SetPoint(i, samples[i].first, samples[i].second); + return graph; +} + +TGraph *MakeScatterGraph(const std::vector> &pairs, const char *name, Color_t color) +{ + auto *graph = new TGraph(pairs.size()); + graph->SetName(name); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.8); + graph->SetMarkerColor(color); + graph->SetLineColor(color); + for (size_t i = 0; i < pairs.size(); ++i) + graph->SetPoint(i, pairs[i].first, pairs[i].second); + return graph; +} + +void DrawIdentityScatter(const std::vector> &pairs, const char *graphName, Color_t color, + const TString &title, const TString &xTitle, const TString &yTitle) +{ + if (pairs.empty()) { + auto *box = new TPaveText(0.2, 0.4, 0.8, 0.6, "NDC"); + box->AddText("No matched proton events"); + box->Draw(); + return; + } + + double minValue = std::numeric_limits::max(); + double maxValue = std::numeric_limits::lowest(); + for (const auto &[x, y] : pairs) { + minValue = std::min(minValue, std::min(x, y)); + maxValue = std::max(maxValue, std::max(x, y)); + } + const double span = std::max(1.0, maxValue - minValue); + minValue -= 0.08 * span; + maxValue += 0.08 * span; + + auto *frame = gPad->DrawFrame(minValue, minValue, maxValue, maxValue, title); + frame->GetXaxis()->SetTitle(xTitle); + frame->GetYaxis()->SetTitle(yTitle); + frame->GetYaxis()->SetTitleOffset(1.3); + + auto *graph = MakeScatterGraph(pairs, graphName, color); + graph->Draw("P"); + + auto *diag = new TLine(minValue, minValue, maxValue, maxValue); + diag->SetLineStyle(2); + diag->SetLineColor(kGray + 2); + diag->Draw(); +} + +double PhiDiffDeg(double lhs, double rhs) +{ + double diff = lhs - rhs; + while (diff > 180.0) + diff -= 360.0; + while (diff < -180.0) + diff += 360.0; + return diff; +} +} // namespace + +void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simpleFile = "./data/simpleSim_fixed.root", + Int_t reactionIndex = 0) +{ + gStyle->SetOptStat(0); + + const auto geant = LoadReactionEvents(geantFile); + const auto simple = LoadReactionEvents(simpleFile); + const auto matchSummary = MatchReactionEvents(geant, simple); + const auto &matched = matchSummary.usablePairs; + const size_t matchedPairs = matched.size(); + if (matchedPairs == 0) { + std::cerr << "No truth-matched proton reaction events with >=2 active-volume points found.\n"; + return; + } + + const size_t selectedPair = std::clamp(reactionIndex, 0, static_cast(matchedPairs - 1)); + const auto &geantEvent = matched[selectedPair].first; + const auto &simpleEvent = matched[selectedPair].second; + + std::vector> kineticPairs; + std::vector> thetaPairs; + std::vector> phiPairs; + std::vector> pathPairs; + std::vector> finalZPairs; + kineticPairs.reserve(matchedPairs); + thetaPairs.reserve(matchedPairs); + phiPairs.reserve(matchedPairs); + pathPairs.reserve(matchedPairs); + finalZPairs.reserve(matchedPairs); + size_t kinematicAgreementPairs = 0; + + for (size_t i = 0; i < matchedPairs; ++i) { + const auto &geantPair = matched[i].first; + const auto &simplePair = matched[i].second; + const double geantKE = GetInitialKineticEnergyMeV(geantPair.proton); + const double simpleKE = GetInitialKineticEnergyMeV(simplePair.proton); + const double geantTheta = GetInitialThetaDeg(geantPair.proton); + const double simpleTheta = GetInitialThetaDeg(simplePair.proton); + const double geantPhi = GetInitialPhiDeg(geantPair.proton); + const double simplePhi = GetInitialPhiDeg(simplePair.proton); + const double geantPath = GetPathLength(geantPair.proton); + const double simplePath = GetPathLength(simplePair.proton); + + kineticPairs.emplace_back(geantKE, simpleKE); + thetaPairs.emplace_back(geantTheta, simpleTheta); + phiPairs.emplace_back(geantPhi, simplePhi); + pathPairs.emplace_back(geantPath, simplePath); + finalZPairs.emplace_back(GetFinalZ(geantPair.proton), GetFinalZ(simplePair.proton)); + if (std::abs(geantKE - simpleKE) < 1.0 && std::abs(geantTheta - simpleTheta) < 0.2 && + std::abs(PhiDiffDeg(geantPhi, simplePhi)) < 0.5) + ++kinematicAgreementPairs; + } + + auto *track3DG4 = MakeTrackLine3D(geantEvent.proton, "gFixed3DG4", kBlue + 1, 1); + auto *track3DSim = MakeTrackLine3D(simpleEvent.proton, "gFixed3DSim", kRed + 1, 2); + auto *mark3DG4 = MakeTrackMarkers3D(geantEvent.proton, kBlue + 1, 1); + auto *mark3DSim = MakeTrackMarkers3D(simpleEvent.proton, kRed + 1, 2); + auto *xzG4 = MakeProjectionGraph(geantEvent.proton, true, "gFixedXZG4", kBlue + 1, 1); + auto *xzSim = MakeProjectionGraph(simpleEvent.proton, true, "gFixedXZSim", kRed + 1, 2); + auto *xyG4 = MakeProjectionGraph(geantEvent.proton, false, "gFixedXYG4", kBlue + 1, 1); + auto *xySim = MakeProjectionGraph(simpleEvent.proton, false, "gFixedXYSim", kRed + 1, 2); + auto *braggG4 = MakeResidualRangeGraph(geantEvent.proton, "gFixedBraggG4", kBlue + 1, 1); + auto *braggSim = MakeResidualRangeGraph(simpleEvent.proton, "gFixedBraggSim", kRed + 1, 2); + + auto *canvas = new TCanvas("cFixedCompare", "AtSimTransport fixed-angle proton comparison", 1900, 1000); + canvas->Divide(4, 2); + + canvas->cd(1); + gPad->SetTheta(22); + gPad->SetPhi(32); + track3DG4->Draw(); + track3DSim->Draw("same"); + mark3DG4->Draw(); + mark3DSim->Draw(); + { + auto *legend = new TLegend(0.56, 0.74, 0.9, 0.9); + legend->AddEntry(track3DG4, Form("Geant4 event %d", geantEvent.fairEvent), "lp"); + legend->AddEntry(track3DSim, Form("SimpleSim event %d", simpleEvent.fairEvent), "lp"); + legend->AddEntry((TObject *)nullptr, Form("Reaction %d proton 3D", geantEvent.reactionIndex), ""); + legend->Draw(); + } + + canvas->cd(2); + const auto [zMin, zMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'z'); + const auto [xMin, xMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'x'); + auto *xzFrame = + gPad->DrawFrame(zMin, xMin, zMax, xMax, Form("Matched proton XZ track: reaction %d;Z [mm];X [mm]", + geantEvent.reactionIndex)); + xzFrame->GetYaxis()->SetTitleOffset(1.2); + xzG4->Draw("LP"); + xzSim->Draw("LP"); + { + auto *legend = new TLegend(0.58, 0.74, 0.9, 0.9); + legend->AddEntry(xzG4, Form("Geant4 event %d", geantEvent.fairEvent), "lp"); + legend->AddEntry(xzSim, Form("SimpleSim event %d", simpleEvent.fairEvent), "lp"); + legend->Draw(); + } + + canvas->cd(3); + const auto [xyXMin, xyXMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'x'); + const auto [xyYMin, xyYMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'y'); + auto *xyFrame = + gPad->DrawFrame(xyXMin, xyYMin, xyXMax, xyYMax, Form("Matched proton XY track: reaction %d;X [mm];Y [mm]", + geantEvent.reactionIndex)); + xyFrame->GetYaxis()->SetTitleOffset(1.2); + xyG4->Draw("LP"); + xySim->Draw("LP"); + { + auto *legend = new TLegend(0.58, 0.74, 0.9, 0.9); + legend->AddEntry(xyG4, "Geant4 proton", "lp"); + legend->AddEntry(xySim, "SimpleSim proton", "lp"); + legend->Draw(); + } + + canvas->cd(4); + const double maxResidualRange = std::max(GetPathLength(geantEvent.proton), GetPathLength(simpleEvent.proton)); + const double maxStoppingPower = + 1.15 * std::max(GetMaxStoppingPower(geantEvent.proton), GetMaxStoppingPower(simpleEvent.proton)); + auto *braggFrame = gPad->DrawFrame(0., 0., std::max(1.0, maxResidualRange), std::max(0.01, maxStoppingPower), + "Matched proton stopping power;Residual range [mm];dE/ds [MeV/mm]"); + braggFrame->GetYaxis()->SetTitleOffset(1.25); + braggG4->Draw("LP"); + braggSim->Draw("LP"); + { + auto *legend = new TLegend(0.55, 0.74, 0.9, 0.9); + legend->AddEntry(braggG4, "Geant4 proton", "lp"); + legend->AddEntry(braggSim, "SimpleSim proton", "lp"); + legend->Draw(); + } + + canvas->cd(5); + DrawIdentityScatter(kineticPairs, "gFixedEnergyScatter", kBlue + 1, "Matched proton kinetic energy; ; ", + "Geant4 proton KE [MeV]", "SimpleSim proton KE [MeV]"); + + canvas->cd(6); + DrawIdentityScatter(thetaPairs, "gFixedThetaScatter", kBlue + 1, "Matched proton lab angle; ; ", + "Geant4 proton #theta_{lab} [deg]", "SimpleSim proton #theta_{lab} [deg]"); + + canvas->cd(7); + DrawIdentityScatter(phiPairs, "gFixedPhiScatter", kBlue + 1, "Matched proton lab azimuth; ; ", + "Geant4 proton #phi_{lab} [deg]", "SimpleSim proton #phi_{lab} [deg]"); + + canvas->cd(8); + DrawIdentityScatter(pathPairs, "gFixedPathScatter", kBlue + 1, "Matched proton path length; ; ", + "Geant4 proton path length [mm]", "SimpleSim proton path length [mm]"); + auto *note = new TPaveText(0.14, 0.72, 0.52, 0.9, "NDC"); + note->SetFillStyle(0); + note->SetBorderSize(1); + note->AddText(Form("Truth-matched usable pairs: %zu", matchedPairs)); + note->AddText(Form("Generator-only Geant/Simple: %zu / %zu", matchSummary.geantOnly, matchSummary.simpleOnly)); + note->AddText( + Form("Incomplete Geant/Simple: %zu / %zu", matchSummary.geantIncomplete, matchSummary.simpleIncomplete)); + note->AddText(Form("Initial-state agreement pairs: %zu", kinematicAgreementPairs)); + note->AddText(Form("Selected reaction: %d", geantEvent.reactionIndex)); + note->AddText(Form("#Delta#phi selected: %.3f deg", + PhiDiffDeg(GetInitialPhiDeg(geantEvent.proton), GetInitialPhiDeg(simpleEvent.proton)))); + note->AddText(Form("Final Z pair: %.2f mm vs %.2f mm", finalZPairs[selectedPair].first, finalZPairs[selectedPair].second)); + note->Draw(); + + std::cout << "compareFixed: usable truth-matched pairs " << matchedPairs << ", generator-only Geant/Simple " + << matchSummary.geantOnly << "/" << matchSummary.simpleOnly << ", incomplete Geant/Simple " + << matchSummary.geantIncomplete << "/" << matchSummary.simpleIncomplete << ", selected reaction " + << geantEvent.reactionIndex << " (Geant4 fair event " << geantEvent.fairEvent << ", SimpleSim fair event " + << simpleEvent.fairEvent << ").\n"; + + gSystem->mkdir("data", kTRUE); + canvas->SaveAs("./data/compareFixed.pdf"); +} diff --git a/macro/Simulation/AtSimValidation/compareKinematic.C b/macro/Simulation/AtSimValidation/compareKinematic.C new file mode 100644 index 000000000..edead4807 --- /dev/null +++ b/macro/Simulation/AtSimValidation/compareKinematic.C @@ -0,0 +1,602 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr double kProtonMassMeV = 938.27208816; + +struct PointSample { + double x{}; + double y{}; + double z{}; + double px{}; + double py{}; + double pz{}; + double eLoss{}; + double length{}; +}; + +struct TrackData { + std::vector points; +}; + +struct ReactionEvent { + int fairEvent{-1}; + int reactionIndex{-1}; + double truthKE{0.0}; + double truthTheta{0.0}; + double truthPhi{0.0}; + TrackData proton; +}; + +struct MatchSummary { + std::vector> usablePairs; + size_t geantOnly{0}; + size_t simpleOnly{0}; + size_t geantIncomplete{0}; + size_t simpleIncomplete{0}; +}; + +bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } + +void ExpandRange(double value, double &minValue, double &maxValue) +{ + minValue = std::min(minValue, value); + maxValue = std::max(maxValue, value); +} + +std::pair AddMargin(double minValue, double maxValue) +{ + const double span = std::max(1.0, maxValue - minValue); + return {minValue - 0.08 * span, maxValue + 0.08 * span}; +} + +double GetMaxStoppingPower(const TrackData &track) +{ + double maxValue = 0.; + for (size_t i = 1; i < track.points.size(); ++i) { + const auto &prev = track.points[i - 1]; + const auto &curr = track.points[i]; + const double dx = curr.x - prev.x; + const double dy = curr.y - prev.y; + const double dz = curr.z - prev.z; + const double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step <= 1e-6 || curr.eLoss <= 0.) + continue; + maxValue = std::max(maxValue, curr.eLoss / step); + } + return maxValue; +} + +std::pair GetCoordinateRange(const TrackData &lhs, const TrackData &rhs, char axis) +{ + double minValue = std::numeric_limits::max(); + double maxValue = std::numeric_limits::lowest(); + auto updateTrack = [&](const TrackData &track) { + for (const auto &point : track.points) { + double value = 0.; + switch (axis) { + case 'x': + value = point.x; + break; + case 'y': + value = point.y; + break; + case 'z': + value = point.z; + break; + default: + return; + } + ExpandRange(value, minValue, maxValue); + } + }; + updateTrack(lhs); + updateTrack(rhs); + return AddMargin(minValue, maxValue); +} + +double GetPathLength(const TrackData &track) +{ + if (track.points.size() < 2) + return 0.; + return track.points.back().length - track.points.front().length; +} + +double GetFinalZ(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + return track.points.back().z; +} + +double GetInitialPt(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + return std::hypot(point.px, point.py); +} + +double GetInitialThetaDeg(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + return std::atan2(std::hypot(point.px, point.py), point.pz) * TMath::RadToDeg(); +} + +double GetInitialPhiDeg(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + return std::atan2(point.py, point.px) * TMath::RadToDeg(); +} + +double GetInitialKineticEnergyMeV(const TrackData &track) +{ + if (track.points.empty()) + return 0.; + const auto &point = track.points.front(); + const double momentum = std::sqrt(point.px * point.px + point.py * point.py + point.pz * point.pz); + return std::sqrt(momentum * momentum + kProtonMassMeV * kProtonMassMeV) - kProtonMassMeV; +} + +double GetTrackThetaDeg(const AtMCTrack &track) +{ + return std::atan2(std::hypot(track.GetPx(), track.GetPy()), track.GetPz()) * TMath::RadToDeg(); +} + +double GetTrackPhiDeg(const AtMCTrack &track) { return std::atan2(track.GetPy(), track.GetPx()) * TMath::RadToDeg(); } + +double GetTrackKineticEnergyMeV(const AtMCTrack &track) +{ + const double px = track.GetPx() * 1000.0; + const double py = track.GetPy() * 1000.0; + const double pz = track.GetPz() * 1000.0; + const double momentum = std::sqrt(px * px + py * py + pz * pz); + return std::sqrt(momentum * momentum + kProtonMassMeV * kProtonMassMeV) - kProtonMassMeV; +} + +TrackData BuildTrackData(int trackID, TClonesArray *points) +{ + TrackData out; + for (int i = 0; i < points->GetEntriesFast(); ++i) { + auto *pt = dynamic_cast(points->At(i)); + if (!pt || pt->GetTrackID() != trackID) + continue; + + out.points.push_back({pt->GetX() * 10., pt->GetY() * 10., pt->GetZ() * 10., pt->GetPx() * 1000., + pt->GetPy() * 1000., pt->GetPz() * 1000., pt->GetEnergyLoss() * 1000., + pt->GetLength() * 10.}); + } + std::sort(out.points.begin(), out.points.end(), SortByLength); + return out; +} + +bool IsPrimaryProton(AtMCTrack *track) +{ + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == 2212; +} + +std::vector LoadReactionEvents(const TString &fileName) +{ + std::vector events; + + auto *file = TFile::Open(fileName); + if (!file || file->IsZombie()) { + std::cerr << "Cannot open " << fileName << "\n"; + return events; + } + + auto *tree = dynamic_cast(file->Get("cbmsim")); + if (!tree) { + std::cerr << "Missing cbmsim tree in " << fileName << "\n"; + file->Close(); + return events; + } + + TClonesArray *pointArray = nullptr; + TClonesArray *trackArray = nullptr; + tree->SetBranchAddress("AtTpcPoint", &pointArray); + tree->SetBranchAddress("MCTrack", &trackArray); + + for (Long64_t iEvent = 1; iEvent < tree->GetEntries(); iEvent += 2) { + tree->GetEntry(iEvent); + if (!trackArray) + continue; + + int protonTrackID = -1; + for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(trackArray->At(i)); + if (IsPrimaryProton(track)) { + protonTrackID = i; + break; + } + } + + if (protonTrackID < 0) + continue; + + auto *protonTrack = dynamic_cast(trackArray->At(protonTrackID)); + if (protonTrack == nullptr) + continue; + + auto proton = BuildTrackData(protonTrackID, pointArray); + events.push_back({static_cast(iEvent), static_cast(iEvent / 2), GetTrackKineticEnergyMeV(*protonTrack), + GetTrackThetaDeg(*protonTrack), GetTrackPhiDeg(*protonTrack), std::move(proton)}); + } + + file->Close(); + return events; +} + +MatchSummary MatchReactionEvents(const std::vector &geant, const std::vector &simple) +{ + MatchSummary summary; + + std::map simpleByTruth; + for (const auto &event : simple) + simpleByTruth[event.reactionIndex] = &event; + + for (const auto &event : geant) { + auto it = simpleByTruth.find(event.reactionIndex); + if (it == simpleByTruth.end()) { + ++summary.geantOnly; + continue; + } + + const auto &simpleEvent = *it->second; + simpleByTruth.erase(it); + + const bool geantUsable = event.proton.points.size() >= 2; + const bool simpleUsable = simpleEvent.proton.points.size() >= 2; + if (!geantUsable) + ++summary.geantIncomplete; + if (!simpleUsable) + ++summary.simpleIncomplete; + if (!geantUsable || !simpleUsable) + continue; + + summary.usablePairs.emplace_back(event, simpleEvent); + } + + summary.simpleOnly = simpleByTruth.size(); + return summary; +} + +TGraph *MakeProjectionGraph(const TrackData &track, bool xz, const char *name, Color_t color, Style_t style) +{ + auto *graph = new TGraph(track.points.size()); + graph->SetName(name); + graph->SetLineColorAlpha(color, 0.65); + graph->SetLineWidth(3); + graph->SetLineStyle(style); + graph->SetMarkerColorAlpha(color, 0.55); + graph->SetMarkerStyle(style == 1 ? 20 : 24); + graph->SetMarkerSize(0.55); + + for (size_t i = 0; i < track.points.size(); ++i) { + const auto &point = track.points[i]; + graph->SetPoint(i, xz ? point.z : point.x, xz ? point.x : point.y); + } + + return graph; +} + +TPolyLine3D *MakeTrackLine3D(const TrackData &track, const char *name, Color_t color, Style_t style) +{ + auto *line = new TPolyLine3D(track.points.size()); + (void)name; + line->SetLineColorAlpha(color, 0.55); + line->SetLineWidth(4); + line->SetLineStyle(style); + for (size_t i = 0; i < track.points.size(); ++i) + line->SetPoint(i, track.points[i].x, track.points[i].y, track.points[i].z); + return line; +} + +TPolyMarker3D *MakeTrackMarkers3D(const TrackData &track, Color_t color, Style_t style) +{ + const int stride = std::max(1, track.points.size() / 60); + const int nMarkers = static_cast((track.points.size() + stride - 1) / stride); + auto *markers = new TPolyMarker3D(nMarkers); + markers->SetMarkerColorAlpha(color, 0.75); + markers->SetMarkerStyle(style == 1 ? 20 : 24); + markers->SetMarkerSize(0.6); + + int index = 0; + for (size_t i = 0; i < track.points.size(); i += stride) + markers->SetPoint(index++, track.points[i].x, track.points[i].y, track.points[i].z); + return markers; +} + +TGraph *MakeResidualRangeGraph(const TrackData &track, const char *name, Color_t color, Style_t style) +{ + std::vector> samples; + const double startLength = track.points.front().length; + const double totalPath = GetPathLength(track); + + for (size_t i = 1; i < track.points.size(); ++i) { + const auto &prev = track.points[i - 1]; + const auto &curr = track.points[i]; + const double dx = curr.x - prev.x; + const double dy = curr.y - prev.y; + const double dz = curr.z - prev.z; + const double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step <= 1e-6 || curr.eLoss <= 0.) + continue; + + const double travelled = curr.length - startLength; + const double residualRange = std::max(0.0, totalPath - travelled); + samples.emplace_back(residualRange, curr.eLoss / step); + } + + auto *graph = new TGraph(samples.size()); + graph->SetName(name); + graph->SetLineColor(color); + graph->SetLineWidth(2); + graph->SetLineStyle(style); + graph->SetMarkerColor(color); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.35); + for (size_t i = 0; i < samples.size(); ++i) + graph->SetPoint(i, samples[i].first, samples[i].second); + + return graph; +} + +TGraph *MakeScatterGraph(const std::vector> &pairs, const char *name, Color_t color) +{ + auto *graph = new TGraph(pairs.size()); + graph->SetName(name); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.8); + graph->SetMarkerColor(color); + graph->SetLineColor(color); + for (size_t i = 0; i < pairs.size(); ++i) + graph->SetPoint(i, pairs[i].first, pairs[i].second); + return graph; +} + +void DrawIdentityScatter(const std::vector> &pairs, const char *graphName, Color_t color, + const TString &title, const TString &xTitle, const TString &yTitle) +{ + if (pairs.empty()) { + auto *box = new TPaveText(0.2, 0.4, 0.8, 0.6, "NDC"); + box->AddText("No matched proton events"); + box->Draw(); + return; + } + + double minValue = std::numeric_limits::max(); + double maxValue = std::numeric_limits::lowest(); + for (const auto &[x, y] : pairs) { + minValue = std::min(minValue, std::min(x, y)); + maxValue = std::max(maxValue, std::max(x, y)); + } + const double span = std::max(1.0, maxValue - minValue); + minValue -= 0.08 * span; + maxValue += 0.08 * span; + + auto *frame = gPad->DrawFrame(minValue, minValue, maxValue, maxValue, title); + frame->GetXaxis()->SetTitle(xTitle); + frame->GetYaxis()->SetTitle(yTitle); + frame->GetYaxis()->SetTitleOffset(1.3); + + auto *graph = MakeScatterGraph(pairs, graphName, color); + graph->Draw("P"); + + auto *diag = new TLine(minValue, minValue, maxValue, maxValue); + diag->SetLineStyle(2); + diag->SetLineColor(kGray + 2); + diag->Draw(); +} + +void DrawNoData(const char *message) +{ + auto *box = new TPaveText(0.2, 0.4, 0.8, 0.6, "NDC"); + box->AddText(message); + box->Draw(); +} + +double PhiDiffDeg(double lhs, double rhs) +{ + double diff = lhs - rhs; + while (diff > 180.0) + diff -= 360.0; + while (diff < -180.0) + diff += 360.0; + return diff; +} +} // namespace + +void compareKinematic(TString geantFile = "./data/geant4_kinematic.root", + TString simpleFile = "./data/simpleSim_kinematic.root", Int_t reactionIndex = 0) +{ + gStyle->SetOptStat(0); + + const auto geant = LoadReactionEvents(geantFile); + const auto simple = LoadReactionEvents(simpleFile); + const auto matchSummary = MatchReactionEvents(geant, simple); + const auto &matched = matchSummary.usablePairs; + const size_t matchedPairs = matched.size(); + if (matchedPairs == 0) { + std::cerr << "No truth-matched proton reaction events with >=2 active-volume points found.\n"; + return; + } + + const size_t selectedPair = std::clamp(reactionIndex, 0, static_cast(matchedPairs - 1)); + const auto &geantEvent = matched[selectedPair].first; + const auto &simpleEvent = matched[selectedPair].second; + + std::vector> kineticPairs; + std::vector> thetaPairs; + std::vector> phiPairs; + std::vector> pathPairs; + std::vector> finalZPairs; + kineticPairs.reserve(matchedPairs); + thetaPairs.reserve(matchedPairs); + phiPairs.reserve(matchedPairs); + pathPairs.reserve(matchedPairs); + finalZPairs.reserve(matchedPairs); + size_t kinematicAgreementPairs = 0; + + for (size_t i = 0; i < matchedPairs; ++i) { + const auto &geantPair = matched[i].first; + const auto &simplePair = matched[i].second; + const double geantKE = GetInitialKineticEnergyMeV(geantPair.proton); + const double simpleKE = GetInitialKineticEnergyMeV(simplePair.proton); + const double geantTheta = GetInitialThetaDeg(geantPair.proton); + const double simpleTheta = GetInitialThetaDeg(simplePair.proton); + const double geantPhi = GetInitialPhiDeg(geantPair.proton); + const double simplePhi = GetInitialPhiDeg(simplePair.proton); + const double geantPath = GetPathLength(geantPair.proton); + const double simplePath = GetPathLength(simplePair.proton); + + kineticPairs.emplace_back(geantKE, simpleKE); + thetaPairs.emplace_back(geantTheta, simpleTheta); + phiPairs.emplace_back(geantPhi, simplePhi); + pathPairs.emplace_back(geantPath, simplePath); + finalZPairs.emplace_back(GetFinalZ(geantPair.proton), GetFinalZ(simplePair.proton)); + if (std::abs(geantKE - simpleKE) < 1.0 && std::abs(geantTheta - simpleTheta) < 0.2 && + std::abs(PhiDiffDeg(geantPhi, simplePhi)) < 0.5) + ++kinematicAgreementPairs; + } + + auto *track3DG4 = MakeTrackLine3D(geantEvent.proton, "gKine3DG4", kBlue + 1, 1); + auto *track3DSim = MakeTrackLine3D(simpleEvent.proton, "gKine3DSim", kRed + 1, 2); + auto *mark3DG4 = MakeTrackMarkers3D(geantEvent.proton, kBlue + 1, 1); + auto *mark3DSim = MakeTrackMarkers3D(simpleEvent.proton, kRed + 1, 2); + auto *xzG4 = MakeProjectionGraph(geantEvent.proton, true, "gKineXZG4", kBlue + 1, 1); + auto *xzSim = MakeProjectionGraph(simpleEvent.proton, true, "gKineXZSim", kRed + 1, 2); + auto *xyG4 = MakeProjectionGraph(geantEvent.proton, false, "gKineXYG4", kBlue + 1, 1); + auto *xySim = MakeProjectionGraph(simpleEvent.proton, false, "gKineXYSim", kRed + 1, 2); + auto *braggG4 = MakeResidualRangeGraph(geantEvent.proton, "gKineBraggG4", kBlue + 1, 1); + auto *braggSim = MakeResidualRangeGraph(simpleEvent.proton, "gKineBraggSim", kRed + 1, 2); + + auto *canvas = new TCanvas("cKinematicCompare", "AtSimTransport kinematic proton comparison", 1900, 1000); + canvas->Divide(4, 2); + + canvas->cd(1); + gPad->SetTheta(22); + gPad->SetPhi(32); + track3DG4->Draw(); + track3DSim->Draw("same"); + mark3DG4->Draw(); + mark3DSim->Draw(); + { + auto *legend = new TLegend(0.56, 0.74, 0.9, 0.9); + legend->AddEntry(track3DG4, Form("Geant4 event %d", geantEvent.fairEvent), "lp"); + legend->AddEntry(track3DSim, Form("SimpleSim event %d", simpleEvent.fairEvent), "lp"); + legend->AddEntry((TObject *)nullptr, Form("Reaction %d proton 3D", geantEvent.reactionIndex), ""); + legend->Draw(); + } + + canvas->cd(2); + const auto [zMin, zMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'z'); + const auto [xMin, xMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'x'); + auto *xzFrame = + gPad->DrawFrame(zMin, xMin, zMax, xMax, Form("Matched proton XZ track: reaction %d;Z [mm];X [mm]", + geantEvent.reactionIndex)); + xzFrame->GetYaxis()->SetTitleOffset(1.2); + xzG4->Draw("LP"); + xzSim->Draw("LP"); + { + auto *legend = new TLegend(0.58, 0.74, 0.9, 0.9); + legend->AddEntry(xzG4, Form("Geant4 event %d", geantEvent.fairEvent), "lp"); + legend->AddEntry(xzSim, Form("SimpleSim event %d", simpleEvent.fairEvent), "lp"); + legend->Draw(); + } + + canvas->cd(3); + const auto [xyXMin, xyXMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'x'); + const auto [xyYMin, xyYMax] = GetCoordinateRange(geantEvent.proton, simpleEvent.proton, 'y'); + auto *xyFrame = + gPad->DrawFrame(xyXMin, xyYMin, xyXMax, xyYMax, Form("Matched proton XY track: reaction %d;X [mm];Y [mm]", + geantEvent.reactionIndex)); + xyFrame->GetYaxis()->SetTitleOffset(1.2); + xyG4->Draw("LP"); + xySim->Draw("LP"); + { + auto *legend = new TLegend(0.58, 0.74, 0.9, 0.9); + legend->AddEntry(xyG4, "Geant4 proton", "lp"); + legend->AddEntry(xySim, "SimpleSim proton", "lp"); + legend->Draw(); + } + + canvas->cd(4); + const double maxResidualRange = std::max(GetPathLength(geantEvent.proton), GetPathLength(simpleEvent.proton)); + const double maxStoppingPower = + 1.15 * std::max(GetMaxStoppingPower(geantEvent.proton), GetMaxStoppingPower(simpleEvent.proton)); + auto *braggFrame = gPad->DrawFrame(0., 0., std::max(1.0, maxResidualRange), std::max(0.01, maxStoppingPower), + "Matched proton stopping power;Residual range [mm];dE/ds [MeV/mm]"); + braggFrame->GetYaxis()->SetTitleOffset(1.25); + braggG4->Draw("LP"); + braggSim->Draw("LP"); + { + auto *legend = new TLegend(0.55, 0.74, 0.9, 0.9); + legend->AddEntry(braggG4, "Geant4 proton", "lp"); + legend->AddEntry(braggSim, "SimpleSim proton", "lp"); + legend->Draw(); + } + + canvas->cd(5); + DrawIdentityScatter(kineticPairs, "gKineEnergyScatter", kBlue + 1, "Matched proton kinetic energy; ; ", + "Geant4 proton KE [MeV]", "SimpleSim proton KE [MeV]"); + + canvas->cd(6); + DrawIdentityScatter(thetaPairs, "gKineThetaScatter", kBlue + 1, "Matched proton lab angle; ; ", + "Geant4 proton #theta_{lab} [deg]", "SimpleSim proton #theta_{lab} [deg]"); + + canvas->cd(7); + DrawIdentityScatter(phiPairs, "gKinePhiScatter", kBlue + 1, "Matched proton lab azimuth; ; ", + "Geant4 proton #phi_{lab} [deg]", "SimpleSim proton #phi_{lab} [deg]"); + + canvas->cd(8); + DrawIdentityScatter(pathPairs, "gKinePathScatter", kBlue + 1, "Matched proton path length; ; ", + "Geant4 proton path length [mm]", "SimpleSim proton path length [mm]"); + auto *note = new TPaveText(0.14, 0.72, 0.52, 0.9, "NDC"); + note->SetFillStyle(0); + note->SetBorderSize(1); + note->AddText(Form("Truth-matched usable pairs: %zu", matchedPairs)); + note->AddText(Form("Generator-only Geant/Simple: %zu / %zu", matchSummary.geantOnly, matchSummary.simpleOnly)); + note->AddText( + Form("Incomplete Geant/Simple: %zu / %zu", matchSummary.geantIncomplete, matchSummary.simpleIncomplete)); + note->AddText(Form("Initial-state agreement pairs: %zu", kinematicAgreementPairs)); + note->AddText(Form("Selected reaction: %d", geantEvent.reactionIndex)); + note->AddText(Form("#Delta#phi selected: %.3f deg", + PhiDiffDeg(GetInitialPhiDeg(geantEvent.proton), GetInitialPhiDeg(simpleEvent.proton)))); + note->AddText(Form("Final Z pair: %.2f mm vs %.2f mm", finalZPairs[selectedPair].first, finalZPairs[selectedPair].second)); + note->Draw(); + + std::cout << "compareKinematic: usable truth-matched pairs " << matchedPairs << ", generator-only Geant/Simple " + << matchSummary.geantOnly << "/" << matchSummary.simpleOnly << ", incomplete Geant/Simple " + << matchSummary.geantIncomplete << "/" << matchSummary.simpleIncomplete << ", selected reaction " + << geantEvent.reactionIndex << " (Geant4 fair event " << geantEvent.fairEvent << ", SimpleSim fair event " + << simpleEvent.fairEvent << ").\n"; + + gSystem->mkdir("data", kTRUE); + canvas->SaveAs("./data/compareKinematic.pdf"); +} diff --git a/macro/Simulation/AtSimValidation/geant4_fixed.C b/macro/Simulation/AtSimValidation/geant4_fixed.C new file mode 100644 index 000000000..f4c0ada78 --- /dev/null +++ b/macro/Simulation/AtSimValidation/geant4_fixed.C @@ -0,0 +1,104 @@ + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} +} // namespace + +void geant4_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42, TString mcEngine = "TGeant4") +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outFile = "./data/geant4_fixed.root"; + TString parFile = "./data/geant4_fixed_params.root"; + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName(mcEngine); + run->SetSink(new FairRootFileSink(outFile)); + run->SetMaterials("media.geo"); + auto *rtdb = run->GetRuntimeDb(); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("cave.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName("ATTPC_He1bar.root"); + run->AddModule(tpc); + + auto *magField = new AtConstField(); + magField->SetField(0., 0., 20.); + magField->SetFieldRegion(-50., 50., -50., 50., -10., 110.); + run->SetField(magField); + auto *primGen = BuildElasticGenerator(thetaCms, thetaCms); + run->SetGenerator(primGen); + + run->SetStoreTraj(kFALSE); + LOG(INFO) << "Initializing geometry and field. Starting simulation with " << nEvents + << " events and seed " << seed; + run->Init(); + LOG(INFO) << "------ Finished intialization ------"; + + + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + rtdb->saveOutput(); + rtdb->print(); + + + LOG(INFO) << "Starting simulation with " << nEvents << " events and seed " << seed; + run->Run(nEvents); + + + timer.Stop(); + std::cout << "Wrote " << outFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/geant4_kinematic.C b/macro/Simulation/AtSimValidation/geant4_kinematic.C new file mode 100644 index 000000000..6e6bea1e3 --- /dev/null +++ b/macro/Simulation/AtSimValidation/geant4_kinematic.C @@ -0,0 +1,98 @@ +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} +} // namespace + +void geant4_kinematic(Int_t nEvents = 1000, UInt_t seed = 42, TString mcEngine = "TGeant4") +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outFile = "./data/geant4_kinematic.root"; + TString parFile = "./data/geant4_kinematic_params.root"; + gSystem->Unlink(outFile); + gSystem->Unlink(parFile); + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName(mcEngine); + run->SetSink(new FairRootFileSink(outFile)); + run->SetMaterials("media.geo"); + auto *rtdb = run->GetRuntimeDb(); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("cave.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName("ATTPC_He1bar.root"); + run->AddModule(tpc); + + auto *magField = new AtConstField(); + magField->SetField(0., 0., 20.); + magField->SetFieldRegion(-50., 50., -50., 50., -10., 110.); + run->SetField(magField); + + auto *primGen = BuildElasticGenerator(0.0, 180.0); + run->SetGenerator(primGen); + run->SetStoreTraj(kFALSE); + + run->Init(); + + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + rtdb->saveOutput(); + + run->Run(nEvents); + + timer.Stop(); + std::cout << "Wrote " << outFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C new file mode 100644 index 000000000..a86d13f80 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -0,0 +1,119 @@ + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} + +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +{ + auto manager = std::make_shared(); + + constexpr double heDensity = 1.664e-4; + std::vector> material{{4, 2, 1}}; + + auto carbonModel = std::make_shared(heDensity, material); + carbonModel->SetProjectile(16, 6, 16.014701); + manager->AddModel(6, 16, carbonModel); + + auto protonModel = std::make_shared(heDensity, material); + protonModel->SetProjectile(1, 1, 1.0078250322); + manager->AddModel(1, 1, protonModel); + + auto sim = std::make_unique(geoFile.Data(), manager); + sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + sim->SetMaxStep(1e-3); + + return sim; +} +} // namespace + +void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42, + TString geantTruthFile = "./data/geant4_fixed.root") +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outputFile = "./data/simpleSim_fixed.root"; + TString parFile = "./data/simpleSim_fixed_params.root"; + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName("TGeant3"); + run->SetSink(new FairRootFileSink(outputFile)); + run->SetMaterials("media.geo"); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("caveSmall.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName((dir + "/geometry/ATTPC_He1bar.root").Data()); + run->AddModule(tpc); + + // FairRunSim still needs a generator object to drive the event loop, but the + // actual physics generator for the SimpleSim path is owned by the SimpleSim task. + auto *eventLoopDriver = new FairPrimaryGenerator(); + run->SetGenerator(eventLoopDriver); + + auto *simTask = new AtSimTransportReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + simTask->SetPrimaryTrackSource(geantTruthFile.Data()); + simTask->SetDetector(tpc); + run->AddTask(simTask); + + run->Init(); + auto *rtdb = run->GetRuntimeDb(); + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + run->Run(nEvents); + rtdb->saveOutput(); + + timer.Stop(); + std::cout << "Wrote " << outputFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C b/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C new file mode 100644 index 000000000..b91af2f91 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C @@ -0,0 +1,112 @@ +// SimpleSim drop-in replacement for geant4_fixed.C using Bethe-Bloch factory energy loss. +// Compare the diff between this file and simpleSim_fixed_factory.C — only the factory type differs. + +#include +#include + +#include + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} +} // namespace + +void simpleSim_fixed_bethebloch(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42) +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outFile = "./data/simpleSim_fixed_bethebloch.root"; + TString parFile = "./data/simpleSim_fixed_bethebloch_params.root"; + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName("TGeant3"); + run->SetSink(new FairRootFileSink(outFile)); + run->SetMaterials("media.geo"); + auto *rtdb = run->GetRuntimeDb(); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("cave.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName("ATTPC_He1bar.root"); + run->AddModule(tpc); + + auto *magField = new AtConstField(); + magField->SetField(0., 0., 20.); + magField->SetFieldRegion(-50., 50., -50., 50., -10., 110.); + run->SetField(magField); + + // --- SimpleSim drop-in: replace Geant4 transport with Bethe-Bloch factory --- + run->SetGenerator(new FairPrimaryGenerator()); + + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); + simTask->SetPrimaryGenerator(BuildElasticGenerator(thetaCms, thetaCms)); + simTask->SetDetector(tpc); + run->AddTask(simTask); + + run->SetStoreTraj(kFALSE); + run->Init(); + + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + rtdb->saveOutput(); + + run->Run(nEvents); + + timer.Stop(); + std::cout << "Wrote " << outFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C b/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C new file mode 100644 index 000000000..a2706c7f4 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C @@ -0,0 +1,112 @@ +// SimpleSim drop-in replacement for geant4_fixed.C using factory-based energy loss. +// Compare the diff between this file and geant4_fixed.C to see what changes. + +#include +#include + +#include + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} +} // namespace + +void simpleSim_fixed_factory(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42) +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outFile = "./data/simpleSim_fixed_factory.root"; + TString parFile = "./data/simpleSim_fixed_factory_params.root"; + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName("TGeant3"); + run->SetSink(new FairRootFileSink(outFile)); + run->SetMaterials("media.geo"); + auto *rtdb = run->GetRuntimeDb(); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("cave.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName("ATTPC_He1bar.root"); + run->AddModule(tpc); + + auto *magField = new AtConstField(); + magField->SetField(0., 0., 20.); + magField->SetFieldRegion(-50., 50., -50., 50., -10., 110.); + run->SetField(magField); + + // --- SimpleSim drop-in: replace Geant4 transport with factory-based SimpleSim --- + run->SetGenerator(new FairPrimaryGenerator()); + + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); + simTask->SetPrimaryGenerator(BuildElasticGenerator(thetaCms, thetaCms)); + simTask->SetDetector(tpc); + run->AddTask(simTask); + + run->SetStoreTraj(kFALSE); + run->Init(); + + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + rtdb->saveOutput(); + + run->Run(nEvents); + + timer.Stop(); + std::cout << "Wrote " << outFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C new file mode 100644 index 000000000..2a04f4f9e --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -0,0 +1,122 @@ +#include +#include +#include +#include + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = + new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} + +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +{ + auto manager = std::make_shared(); + + constexpr double heDensity = 1.664e-4; + std::vector> material{{4, 2, 1}}; + + auto carbonModel = std::make_shared(heDensity, material); + carbonModel->SetProjectile(16, 6, 16.014701); + manager->AddModel(6, 16, carbonModel); + + auto protonModel = std::make_shared(heDensity, material); + protonModel->SetProjectile(1, 1, 1.0078250322); + manager->AddModel(1, 1, protonModel); + + auto sim = std::make_unique(geoFile.Data(), manager); + sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + sim->SetMaxStep(1e-3); + + return sim; +} +} // namespace + +void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42, + TString geantTruthFile = "./data/geant4_kinematic.root") +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outputFile = "./data/simpleSim_kinematic.root"; + TString parFile = "./data/simpleSim_kinematic_params.root"; + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName("TGeant3"); + run->SetSink(new FairRootFileSink(outputFile)); + run->SetMaterials("media.geo"); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("caveSmall.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName((dir + "/geometry/ATTPC_He1bar.root").Data()); + run->AddModule(tpc); + + run->SetGenerator(new FairPrimaryGenerator()); + + auto *simTask = + new AtSimTransportReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + simTask->SetPrimaryTrackSource(geantTruthFile.Data()); + simTask->SetDetector(tpc); + run->AddTask(simTask); + + auto *rtdb = run->GetRuntimeDb(); + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + + run->Init(); + run->Run(nEvents); + rtdb->saveOutput(); + + timer.Stop(); + std::cout << "Wrote " << outputFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C new file mode 100644 index 000000000..69df9e3c1 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C @@ -0,0 +1,113 @@ +// SimpleSim drop-in replacement for geant4_kinematic.C using factory-based energy loss. +// Compare the diff between this file and geant4_kinematic.C to see what changes. + +#include +#include +#include + +namespace { +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 6; + constexpr Int_t a = 16; + constexpr Int_t q = 0; + constexpr Int_t m = 1; + constexpr Double_t px = 0.0; + constexpr Double_t py = 0.0; + constexpr Double_t pz = 2.297 / a; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; + + auto *primGen = new FairPrimaryGenerator(); + + auto *ionGen = new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + ionGen->SetSpotRadius(0, -100, 0); + ionGen->SetDoReaction(kTRUE); + primGen->AddGenerator(ionGen); + + std::vector Zp{6, 1, 6, 1}; + std::vector Ap{16, 1, 16, 1}; + std::vector Qp{0, 0, 0, 0}; + std::vector Pxp{px, 0.0, 0.0, 0.0}; + std::vector Pyp{py, 0.0, 0.0, 0.0}; + std::vector Pzp{pz, 0.0, 0.0, 0.0}; + std::vector Mass{16.014701, 1.0078250322, 16.014701, 1.0078250322}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 40.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} +} // namespace + +void simpleSim_kinematic_factory(Int_t nEvents = 1000, UInt_t seed = 42) +{ + TString dir = gSystem->Getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "VMCWORKDIR is not set. Run 'source build/config.sh' first.\n"; + return; + } + + gSystem->mkdir("data", kTRUE); + gSystem->Setenv("GEOMPATH", (dir + "/geometry").Data()); + gRandom->SetSeed(seed); + + TString outFile = "./data/simpleSim_kinematic_factory.root"; + TString parFile = "./data/simpleSim_kinematic_factory_params.root"; + gSystem->Unlink(outFile); + gSystem->Unlink(parFile); + + TStopwatch timer; + timer.Start(); + + auto *run = new FairRunSim(); + run->SetName("TGeant3"); + run->SetSink(new FairRootFileSink(outFile)); + run->SetMaterials("media.geo"); + auto *rtdb = run->GetRuntimeDb(); + + auto *cave = new AtCave("CAVE"); + cave->SetGeometryFileName("cave.geo"); + run->AddModule(cave); + + auto *tpc = new AtTpc("ATTPC", kTRUE); + tpc->SetGeometryFileName("ATTPC_He1bar.root"); + run->AddModule(tpc); + + auto *magField = new AtConstField(); + magField->SetField(0., 0., 20.); + magField->SetFieldRegion(-50., 50., -50., 50., -10., 110.); + run->SetField(magField); + + // --- SimpleSim drop-in: replace Geant4 transport with factory-based SimpleSim --- + run->SetGenerator(new FairPrimaryGenerator()); + + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); + + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); + auto *primGen = BuildElasticGenerator(0.0, 180.0); + simTask->SetPrimaryGenerator(primGen); + simTask->SetDetector(tpc); + run->AddTask(simTask); + + run->SetStoreTraj(kFALSE); + run->Init(); + + Bool_t parameterMerged = kTRUE; + auto *parOut = new FairParRootFileIo(parameterMerged); + parOut->open(parFile.Data()); + rtdb->setOutput(parOut); + rtdb->saveOutput(); + + run->Run(nEvents); + + timer.Stop(); + std::cout << "Wrote " << outFile << "\n"; + std::cout << "Real time " << timer.RealTime() << " s, CPU time " << timer.CpuTime() << " s\n"; +} diff --git a/macro/Simulation/AtSimValidation/visualizeKinematic.C b/macro/Simulation/AtSimValidation/visualizeKinematic.C new file mode 100644 index 000000000..e0a851cef --- /dev/null +++ b/macro/Simulation/AtSimValidation/visualizeKinematic.C @@ -0,0 +1,306 @@ +#include "../../Kinematics/Decay_kinematics/TRelativisticKinematics.hh" +#include "../../Kinematics/Decay_kinematics/TRelativisticKinematics.cxx" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +struct PointSample { + double x{}; + double y{}; + double z{}; + double length{}; + double px{}; + double py{}; + double pz{}; +}; + +bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } + +const char *GetTrackLabel(Int_t trackID) +{ + if (trackID == 1) + return "scattered ion"; + if (trackID == 2) + return "recoil proton"; + return "selected track"; +} + +bool GetReactionConfig(Int_t trackID, double &massMeV, double &m3Amu, double &m4Amu) +{ + constexpr double c16MassAmu = 16.014701; + constexpr double protonMassAmu = 1.0078250322; + constexpr double u = 931.49401; + + if (trackID == 1) { + massMeV = c16MassAmu * u; + m3Amu = c16MassAmu; + m4Amu = protonMassAmu; + return true; + } + + if (trackID == 2) { + massMeV = protonMassAmu * u; + m3Amu = c16MassAmu; + m4Amu = protonMassAmu; + return true; + } + + return false; +} + +bool IsSelectedPrimaryTrack(Int_t selectedTrackID, AtMCTrack *track) +{ + if (track == nullptr || track->GetMotherId() != -1) + return false; + + if (selectedTrackID == 1) + return track->GetPdgCode() == 1000060160; + if (selectedTrackID == 2) + return track->GetPdgCode() == 2212; + + return false; +} + +TGraph *BuildTheoryGraph(Int_t trackID) +{ + constexpr double c16MassAmu = 16.014701; + constexpr double protonMassAmu = 1.0078250322; + constexpr double beamKineticEnergyMeV = 175.0; + + double massMeV = 0.0; + double m3Amu = 0.0; + double m4Amu = 0.0; + if (!GetReactionConfig(trackID, massMeV, m3Amu, m4Amu)) + return nullptr; + + TRelativisticKinematics kine; + kine.SetMassOfProjectile(c16MassAmu); + kine.SetMassOfTarget(protonMassAmu); + kine.SetMassOfScattered(m3Amu); + kine.SetMassOfRecoiled(m4Amu); + kine.SetExEnergyOfProjectile(0.0); + kine.SetExEnergyOfTarget(0.0); + kine.SetExEnergyOfScattered(0.0); + kine.SetExEnergyOfRecoiled(0.0); + kine.SetLabEnergy(beamKineticEnergyMeV); + + std::vector> theoryPoints; + theoryPoints.reserve(1801); + + std::ostringstream sink; + auto *oldBuf = std::cout.rdbuf(sink.rdbuf()); + for (double thetaCm = 0.0; thetaCm <= 180.0; thetaCm += 0.1) { + kine.SetThetaCMAngle(thetaCm); + kine.Kinematics(); + + double thetaLab = 0.0; + double energyLab = 0.0; + if (trackID == 1) { + thetaLab = kine.GetANGAs(0) * TMath::RadToDeg(); + energyLab = kine.GetANGAs(1); + } else { + thetaLab = kine.GetANGAr(0) * TMath::RadToDeg(); + energyLab = kine.GetANGAr(1); + } + + if (std::isfinite(thetaLab) && std::isfinite(energyLab)) + theoryPoints.emplace_back(thetaLab, energyLab); + } + std::cout.rdbuf(oldBuf); + + if (theoryPoints.empty()) + return nullptr; + + auto *graph = new TGraph(theoryPoints.size()); + graph->SetName("gTheoryKinematics"); + graph->SetLineColor(kRed + 1); + graph->SetLineWidth(3); + for (size_t i = 0; i < theoryPoints.size(); ++i) + graph->SetPoint(i, theoryPoints[i].first, theoryPoints[i].second); + + return graph; +} +} // namespace + +void visualizeKinematic(TString inputFile = "./data/simpleSim_kinematic.root", Int_t selectedTrackID = 2, Int_t maxEvents = 8) +{ + gStyle->SetOptStat(0); + + auto *file = TFile::Open(inputFile); + if (!file || file->IsZombie()) { + std::cerr << "Cannot open " << inputFile << "\n"; + return; + } + + auto *tree = dynamic_cast(file->Get("cbmsim")); + if (!tree) { + std::cerr << "Missing cbmsim tree in " << inputFile << "\n"; + file->Close(); + return; + } + + TClonesArray *pointArray = nullptr; + TClonesArray *trackArray = nullptr; + tree->SetBranchAddress("AtTpcPoint", &pointArray); + tree->SetBranchAddress("MCTrack", &trackArray); + + auto *xyCanvas = new TCanvas("cKinematicXY", "Kinematic XY", 900, 700); + auto *xyFrame = xyCanvas->DrawFrame(-300., -300., 300., 300., + Form("XY projection: %s;X [mm];Y [mm]", GetTrackLabel(selectedTrackID))); + xyFrame->GetYaxis()->SetTitleOffset(1.2); + + auto *trackCanvas = new TCanvas("cKinematic3D", "Kinematic 3D", 1000, 800); + trackCanvas->cd(); + + auto *kineCanvas = new TCanvas("cKinematicLine", "Kinematic line", 900, 700); + + auto *legend = new TLegend(0.68, 0.72, 0.92, 0.9); + bool first3D = true; + bool firstXY = true; + int drawnEvents = 0; + std::vector> measuredKinematics; + + for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { + tree->GetEntry(iEvent); + if (!pointArray || !trackArray || pointArray->GetEntriesFast() < 2) + continue; + + std::map> tracks; + for (int i = 0; i < pointArray->GetEntriesFast(); ++i) { + auto *pt = dynamic_cast(pointArray->At(i)); + if (!pt) + continue; + tracks[pt->GetTrackID()].push_back( + {pt->GetX() * 10., pt->GetY() * 10., pt->GetZ() * 10., pt->GetLength() * 10., pt->GetPx() * 1000., + pt->GetPy() * 1000., pt->GetPz() * 1000.}); + } + + int selectedEventTrackID = -1; + for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(trackArray->At(i)); + if (IsSelectedPrimaryTrack(selectedTrackID, track)) { + selectedEventTrackID = i; + break; + } + } + if (selectedEventTrackID < 0) + continue; + + auto it = tracks.find(selectedEventTrackID); + if (it == tracks.end()) + continue; + + auto &points = it->second; + std::sort(points.begin(), points.end(), SortByLength); + if (points.empty()) + continue; + + if (drawnEvents < maxEvents) { + int color = kBlue + drawnEvents % 6; + + auto *line = new TPolyLine3D(points.size()); + line->SetLineColor(color); + line->SetLineWidth(2); + + auto *xy = new TGraph(points.size()); + xy->SetMarkerStyle(20); + xy->SetMarkerSize(0.35); + xy->SetMarkerColor(color); + xy->SetLineColor(color); + + for (size_t i = 0; i < points.size(); ++i) { + line->SetPoint(i, points[i].x, points[i].y, points[i].z); + xy->SetPoint(i, points[i].x, points[i].y); + } + + trackCanvas->cd(); + if (first3D) { + line->Draw(); + first3D = false; + } else { + line->Draw("same"); + } + + xyCanvas->cd(); + if (firstXY) { + xy->Draw("PL"); + firstXY = false; + } else { + xy->Draw("PL same"); + } + + legend->AddEntry(line, Form("Reaction event %lld", iEvent), "l"); + ++drawnEvents; + } + + double massMeV = 0.0; + double m3Amu = 0.0; + double m4Amu = 0.0; + if (!GetReactionConfig(selectedTrackID, massMeV, m3Amu, m4Amu)) + continue; + (void)m3Amu; + (void)m4Amu; + + const auto &firstPoint = points.front(); + const double p2 = firstPoint.px * firstPoint.px + firstPoint.py * firstPoint.py + firstPoint.pz * firstPoint.pz; + const double thetaDeg = std::atan2(std::sqrt(firstPoint.px * firstPoint.px + firstPoint.py * firstPoint.py), + firstPoint.pz) * + TMath::RadToDeg(); + const double keMeV = std::sqrt(p2 + massMeV * massMeV) - massMeV; + measuredKinematics.emplace_back(thetaDeg, keMeV); + } + + auto *measured = new TGraph(measuredKinematics.size()); + measured->SetName("gMeasuredKinematics"); + measured->SetMarkerStyle(20); + measured->SetMarkerSize(0.9); + measured->SetMarkerColor(kBlue + 1); + measured->SetLineColor(kBlue + 1); + for (size_t i = 0; i < measuredKinematics.size(); ++i) + measured->SetPoint(i, measuredKinematics[i].first, measuredKinematics[i].second); + + auto *theory = BuildTheoryGraph(selectedTrackID); + + trackCanvas->cd(); + legend->Draw(); + trackCanvas->Modified(); + trackCanvas->Update(); + + xyCanvas->Modified(); + xyCanvas->Update(); + + kineCanvas->cd(); + auto *mg = new TMultiGraph(); + mg->SetTitle(Form("KE vs #theta_{lab}: %s;#theta_{lab} [deg];KE [MeV]", GetTrackLabel(selectedTrackID))); + if (theory) + mg->Add(theory, "L"); + mg->Add(measured, "P"); + mg->Draw("A"); + auto *kineLegend = new TLegend(0.62, 0.76, 0.9, 0.9); + kineLegend->AddEntry(measured, "Simulation", "p"); + if (theory) + kineLegend->AddEntry(theory, "Theory", "l"); + kineLegend->Draw(); + kineCanvas->Modified(); + kineCanvas->Update(); + + std::cout << "Visualized track " << selectedTrackID << " (" << GetTrackLabel(selectedTrackID) << ") from " + << inputFile << "\n"; + std::cout << "Drew " << drawnEvents << " trajectories and " << measuredKinematics.size() << " event points\n"; +} diff --git a/macro/e12014/adam/simulation/simpleSim.C b/macro/e12014/adam/simulation/simpleSim.C index 325e9e89a..02c2b4b3b 100644 --- a/macro/e12014/adam/simulation/simpleSim.C +++ b/macro/e12014/adam/simulation/simpleSim.C @@ -33,14 +33,16 @@ void simpleSim() // mapping->ParseInhibitMap("./data/inhibit.txt", AtMap::InhibitType::kTotal); // Create underlying simulation class - auto sim = std::make_unique(geoFile.Data()); + auto manager = std::make_shared(); // Create and load energy loss models auto eloss = std::make_shared(); eloss->LoadSrimTable("./../PbinHeFull.txt"); - sim->AddModel(82, 208, eloss); + manager->AddModel(82, 208, eloss); - AtTestSimulation *simTask = new AtTestSimulation(std::move(sim)); + auto sim = std::make_unique(geoFile.Data(), manager); + + auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); AtClusterizeLineTask *clusterizer = new AtClusterizeLineTask(); clusterizer->SetPersistence(kFALSE);