From 090b33cd80475af90a637a80fe611373f1532189 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Sun, 5 Apr 2026 23:43:07 -0400 Subject: [PATCH 01/18] Add AtPropagator and curved-track support to AtSimpleSimulation Port AtPropagator (Lorentz force + RK4/adaptive stepping) and AtKinematics helpers from OpenKF-Impl into AtTools. Extend AtSimpleSimulation with SetElectricField/SetMagneticField; when fields are non-zero the propagator replaces the straight-line loop. Zero-field fast path is preserved. Add AtSimParticleCollector, a minimal FairGenericStack stub that captures FairPrimaryGenerator output without Geant4. Wire it into AtTestSimulation via SetPrimaryGenerator() so any existing generator can drive the standalone simulation unchanged. Co-Authored-By: Claude Sonnet 4.6 --- AtDigitization/AtSimParticleCollector.cxx | 22 + AtDigitization/AtSimParticleCollector.h | 80 ++++ AtDigitization/AtSimpleSimulation.cxx | 94 ++++- AtDigitization/AtSimpleSimulation.h | 52 ++- AtDigitization/AtTestSimulation.cxx | 78 +++- AtDigitization/AtTestSimulation.h | 26 ++ AtDigitization/CMakeLists.txt | 1 + AtTools/AtKinematics.cxx | 20 + AtTools/AtKinematics.h | 17 + AtTools/AtPropagator.cxx | 492 ++++++++++++++++++++++ AtTools/AtPropagator.h | 275 ++++++++++++ AtTools/AtPropagatorTest.cxx | 368 ++++++++++++++++ AtTools/AtToolsLinkDef.h | 8 + AtTools/CMakeLists.txt | 2 + 14 files changed, 1502 insertions(+), 33 deletions(-) create mode 100644 AtDigitization/AtSimParticleCollector.cxx create mode 100644 AtDigitization/AtSimParticleCollector.h create mode 100644 AtTools/AtPropagator.cxx create mode 100644 AtTools/AtPropagator.h create mode 100644 AtTools/AtPropagatorTest.cxx diff --git a/AtDigitization/AtSimParticleCollector.cxx b/AtDigitization/AtSimParticleCollector.cxx new file mode 100644 index 000000000..d20f1c0a7 --- /dev/null +++ b/AtDigitization/AtSimParticleCollector.cxx @@ -0,0 +1,22 @@ +#include "AtSimParticleCollector.h" + +TParticle *AtSimParticleCollector::PopNextTrack(Int_t & /*itrack*/) +{ + return nullptr; +} +TParticle *AtSimParticleCollector::PopPrimaryForTracking(Int_t /*iPrim*/) +{ + return nullptr; +} + +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) { + fParticles.push_back({pdgCode, px, py, pz, e, vx, vy, vz}); + } +} diff --git a/AtDigitization/AtSimParticleCollector.h b/AtDigitization/AtSimParticleCollector.h new file mode 100644 index 000000000..5a3ed0bff --- /dev/null +++ b/AtDigitization/AtSimParticleCollector.h @@ -0,0 +1,80 @@ +#ifndef ATSIMPARTICLECOLLECTOR_H +#define ATSIMPARTICLECOLLECTOR_H + +#include + +#include // for Int_t, Double_t, TMCProcess, etc. + +#include + +class TRefArray; +class TParticle; + +/** + * @brief Particle captured from FairPrimaryGenerator::GenerateEvent(). + * + * Units follow FairRoot conventions as they come out of FairPrimaryGenerator::AddTrack(): + * - position in cm + * - momentum (px, py, pz) in GeV/c, total energy e in GeV + */ +struct AtCollectedParticle { + int pdgCode; + double px, py, pz; ///< Momentum in GeV/c + double e; ///< Total energy in GeV + double vx, vy, vz; ///< Vertex position in cm +}; + +/** + * @brief Minimal FairGenericStack stub that captures PushTrack() calls. + * + * 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. + * + * Only primary particles to be tracked (toBeDone == 1) are stored. + */ +class AtSimParticleCollector : public FairGenericStack { + std::vector fParticles; + int fCurrentTrack{-1}; + +public: + AtSimParticleCollector() = default; + + // ---- PushTrack: the only method we actually need ---- + virtual 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); + + const std::vector &GetParticles() const { return fParticles; } + void Clear() { fParticles.clear(); } + + // ---- TVirtualMCStack 18-param PushTrack (delegates to the 19-param FairGenericStack version) ---- + // FairGenericStack implements this in its .cxx (invisible to Cling), so we must provide it + // in the header to satisfy the pure-virtual requirement during dictionary generation. + virtual 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) + { + PushTrack(toBeDone, parentID, pdgCode, px, py, pz, e, vx, vy, vz, time, polx, poly, polz, proc, ntr, weight, + is, -1); + } + + // ---- TVirtualMCStack pure-virtual stubs (never called outside VMC context) ---- + virtual TParticle *PopNextTrack(Int_t &itrack); + virtual TParticle *PopPrimaryForTracking(Int_t i); + virtual void SetCurrentTrack(Int_t itrack) { fCurrentTrack = itrack; } + virtual Int_t GetNtrack() const { return static_cast(fParticles.size()); } + virtual Int_t GetNprimary() const { return static_cast(fParticles.size()); } + virtual TParticle *GetCurrentTrack() const { return nullptr; } + virtual Int_t GetCurrentTrackNumber() const { return fCurrentTrack; } + virtual Int_t GetCurrentParentTrackNumber() const { return -1; } + + // ---- FairGenericStack virtual stubs ---- + virtual void AddParticle(TParticle *) {} + virtual void FillTrackArray() {} + virtual void UpdateTrackIndex(TRefArray *) {} + virtual void Reset() { Clear(); } +}; + +#endif // ATSIMPARTICLECOLLECTOR_H diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index 73c4fc218..f004260c5 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -2,7 +2,9 @@ #include "AtSimpleSimulation.h" #include "AtELossModel.h" +#include "AtKinematics.h" #include "AtMCPoint.h" +#include "AtPropagator.h" #include "AtSpaceChargeModel.h" // for AtSpaceChargeModel #include @@ -27,6 +29,29 @@ using XYZPoint = ROOT::Math::XYZPoint; using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; +// --------------------------------------------------------------------------- +// Thin wrapper so a shared_ptr can be passed to AtPropagator +// (which requires a unique_ptr). +// --------------------------------------------------------------------------- +namespace { +class ELossModelShared : public AtTools::AtELossModel { + std::shared_ptr fImpl; + +public: + explicit ELossModelShared(std::shared_ptr impl) + : AtTools::AtELossModel(0), fImpl(std::move(impl)) + { + } + double GetdEdx(double e) const override { return fImpl->GetdEdx(e); } + double GetRange(double ei, double ef = 0) const override { return fImpl->GetRange(ei, ef); } + double GetEnergyLoss(double ei, double d) const override { return fImpl->GetEnergyLoss(ei, d); } + double GetEnergy(double ei, double d) const override { return fImpl->GetEnergy(ei, d); } + double GetElossStraggling(double ei, double ef) const override { return fImpl->GetElossStraggling(ei, ef); } + double GetdEdxStraggling(double ei, double ef) const override { return fImpl->GetdEdxStraggling(ei, ef); } + double GetRangeVariance(double e) const override { return fImpl->GetRangeVariance(e); } +}; +} // namespace + AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) { TGeoManager *geo = TGeoManager::Import(geoFile.c_str()); @@ -87,12 +112,16 @@ std::string AtSimpleSimulation::GetVolumeName(const XYZPoint &point) void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model) { - ParticleID id = { - .A = A, - .Z = Z, - }; + AddModel(Z, A, model, static_cast(A)); +} + +void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model, double massAmu) +{ + static constexpr double kEperAMU = 931.494; // MeV/c² per amu + static constexpr double kEcharge = 1.602176634e-19; // Coulombs - fModels[id] = model; + ParticleID id = {.A = A, .Z = Z}; + fModels[id] = {model, Z * kEcharge, massAmu * kEperAMU}; } std::pair @@ -109,12 +138,64 @@ AtSimpleSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const } std::pair -AtSimpleSimulation::SimulateParticle(ModelPtr model, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, +AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func) { // This is a new track fTrackID++; + // ----------------------------------------------------------------------- + // Curved-track path: use AtPropagator when E/B fields are non-zero + // ----------------------------------------------------------------------- + if (fEField.Mag2() != 0 || fBField.Mag2() != 0) { + auto wrapModel = std::make_unique(info.model); + AtTools::AtPropagator prop(info.charge, info.mass, std::move(wrapModel)); + prop.SetEField(fEField); + prop.SetBField(fBField); + prop.SetState(iniPos, iniMom.Vect()); + + AtTools::AtRK4AdaptiveStepper stepper; + double length = 0; + + while (IsInVolume("drift_volume", prop.GetPosition())) { + double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); + if (KE <= 1e-3) + break; + + auto mom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); + if (isnan(prop.GetPosition().X()) || isnan(prop.GetMomentum().X())) { + LOG(error) << "Failed to simulate a point with nan!"; + return {{0, 0, 0}, {0, 0, 0, 0}}; + } + if (!func(prop.GetPosition(), mom4)) + break; + + double KE_before = KE; + prop.PropagateOneStep(stepper); + + auto &state = prop.GetState(); + if (state.status != AtTools::AtPropagator::StepStateStatus::kSuccess) + break; + + double KE_after = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); + double eLoss = KE_before - KE_after; + if (eLoss < 0) + eLoss = 0; // magnetic field does no work + + double stepDist = (prop.GetPosition() - state.fLastPos).R(); // mm + length += stepDist; + + auto newMom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); + AddHit(eLoss, prop.GetPosition(), newMom4, length); + } + + return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; + } + + // ----------------------------------------------------------------------- + // Straight-line fast path (zero field) + // ----------------------------------------------------------------------- + auto &model = info.model; auto pos = iniPos; auto mom = iniMom; double length = 0; @@ -135,7 +216,6 @@ AtSimpleSimulation::SimulateParticle(ModelPtr model, const XYZPoint &iniPos, con // 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); diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index 1d429affa..49876783b 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -20,13 +20,17 @@ #include // for pair namespace AtTools { class AtELossModel; -} +} // namespace AtTools class TGeoVolume; class AtSpaceChargeModel; /** * Class for simulating simple events using AtELossModels. * Units in this class are MeV (energy), mm (distance) MeV/c (momentum). + * + * When E/B fields are set (non-zero), 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. */ class AtSimpleSimulation { protected: @@ -36,17 +40,27 @@ class AtSimpleSimulation { bool operator<(const ParticleID &other) const; }; + + struct ParticleInfo { + std::shared_ptr model; + double charge; ///< Particle charge in Coulombs + double mass; ///< Particle mass in MeV/c² + }; + 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; - std::map fModels; + std::map fModels; SpaceChargeModel fSCModel{nullptr}; - double fDistStep{1.}; // Distance step in mm for particles + double fDistStep{1.}; // Distance step in mm for straight-line propagation std::mutex fGeoMutex; + XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) + XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) + // Variables to across an entire event static thread_local int fTrackID; static thread_local TClonesArray fMCPoints; @@ -57,20 +71,34 @@ class AtSimpleSimulation { */ AtSimpleSimulation(std::string geoFile); AtSimpleSimulation(); - AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicity deleted because of std::mutex + AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicitly deleted because of std::mutex ~AtSimpleSimulation() = default; void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); + + /** + * Register an energy loss model for a particle species. Charge is derived as Z*e and + * mass as A * 931.494 MeV/c². Use the overload with massAmu for higher accuracy. + */ void AddModel(int Z, int A, ModelPtr model); + + /** + * Register an energy loss model with an explicit nuclear mass (in amu). + */ + void AddModel(int Z, int A, ModelPtr model, double massAmu); + void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = model; } - void SetDistanceStep(double step) { fDistStep = step; } // SimulateParticle( int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, @@ -86,14 +114,12 @@ class AtSimpleSimulation { std::string GetVolumeName(const XYZPoint &point); /** - * 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). + * Core simulation loop. Selects straight-line or curved-track path based on field settings. */ std::pair SimulateParticle( - ModelPtr model, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + const ParticleInfo &info, 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); }; diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index 9ab7b44e6..810a92901 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -1,23 +1,62 @@ #include "AtTestSimulation.h" +#include "AtSimParticleCollector.h" #include "AtSimpleSimulation.h" +#include +#include +#include #include // for InitStatus, kSUCCESS #include #include // for Math, XYZPoint -#include -#include // for XYZVector -#include // for LorentzVector +#include // for LorentzVector #include // for PxPyPzEVector +#include +#include #include +#include using namespace ROOT::Math; +// --------------------------------------------------------------------------- +// Helper: extract (Z, A) from a PDG code. +// +// Heavy ions: PDG = 1000000000 + Z*10000 + A*10 + I (I = isomer level, usually 0) +// Light particles (proton, alpha, etc.): use TDatabasePDG charge/mass. +// --------------------------------------------------------------------------- +namespace { +std::pair GetZAFromPDG(int pdg) +{ + if (pdg > 1000000000) { + int A = (pdg / 10) % 1000; + int Z = (pdg / 10000) % 1000; + return {Z, A}; + } + // Fall back to PDG database + TParticlePDG *p = TDatabasePDG::Instance()->GetParticle(pdg); + if (p) { + // Charge() returns units of |e|/3 + int Z = static_cast(std::round(p->Charge() / 3.0)); + // Mass in GeV/c² → convert to amu (1 amu ≈ 0.9315 GeV/c²) + int A = static_cast(std::round(p->Mass() / 0.9315)); + return {Z, std::max(A, 1)}; + } + return {0, 0}; +} +} // namespace + InitStatus AtTestSimulation::Init() { fSimulation->RegisterBranch(); + if (fPrimGen) { + // FairPrimaryGenerator::GenerateEvent() requires a non-null FairMCEventHeader. + fMCHeader = std::make_unique(); + fPrimGen->SetEvent(fMCHeader.get()); + fPrimGen->Init(); + } + return kSUCCESS; } @@ -25,16 +64,29 @@ 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); + if (!fPrimGen) + return; + + fCollector.Clear(); + fPrimGen->GenerateEvent(&fCollector); + + for (const auto &p : fCollector.GetParticles()) { + auto [Z, A] = GetZAFromPDG(p.pdgCode); + if (Z == 0 && A == 0) { + continue; // skip unknown particles + } + + // FairRoot uses GeV/c for momentum and cm for position; AtSimpleSimulation uses MeV/c and mm. + XYZPoint pos(p.vx * 10., p.vy * 10., p.vz * 10.); // cm → mm + PxPyPzEVector mom(p.px * 1000., p.py * 1000., p.pz * 1000., p.e * 1000.); // GeV → MeV + + try { + fSimulation->SimulateParticle(Z, A, pos, mom); + } catch (const std::invalid_argument &ex) { + // Particle may start outside the drift volume (e.g. beam upstream) — skip silently + LOG(debug) << "AtTestSimulation: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); + } + } } ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h index bb8efa9f7..3f2727968 100644 --- a/AtDigitization/AtTestSimulation.h +++ b/AtDigitization/AtTestSimulation.h @@ -1,6 +1,7 @@ #ifndef AtTestSimulation_h #define AtTestSimulation_h +#include "AtSimParticleCollector.h" #include "AtSimpleSimulation.h" // for AtSimpleSimulation #include // for THashConsistencyHolder, ClassDefOver... @@ -9,18 +10,43 @@ #include // for unique_ptr #include // for move +#include + +class FairPrimaryGenerator; class TBuffer; class TClass; class TMemberInspector; +/** + * @brief FairTask wrapper for AtSimpleSimulation. + * + * When a FairPrimaryGenerator is provided via SetPrimaryGenerator(), it is called each event to + * generate particles. The generated particles are collected by AtSimParticleCollector (bypassing + * the Geant4/VMC stack entirely) and forwarded to AtSimpleSimulation::SimulateParticle(). + * + * When no generator is set, the task does nothing (no hardcoded particles). + */ class AtTestSimulation : public FairTask { protected: std::unique_ptr fSimulation{nullptr}; //! + FairPrimaryGenerator *fPrimGen{nullptr}; //! + AtSimParticleCollector fCollector; //! + + // Owned MCEventHeader required by FairPrimaryGenerator::GenerateEvent() + std::unique_ptr fMCHeader; //! public: AtTestSimulation(std::unique_ptr sim) : fSimulation(std::move(sim)) {} virtual ~AtTestSimulation() = default; + /** + * @brief Set the primary generator used to populate particles each event. + * + * The generator is NOT owned by this task — the caller retains ownership. + * It must remain valid for the lifetime of the task. + */ + void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } + virtual InitStatus Init() override; virtual void Exec(Option_t *option) override; virtual void Finish() override {} diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index 670ff9bb9..33813c446 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -37,6 +37,7 @@ AtVectorResponse.cxx AtSimpleSimulation.cxx AtTestSimulation.cxx +AtSimParticleCollector.cxx ) generate_target_and_root_library(${LIBRARY_NAME} 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..2a4e6050b --- /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 * 1e-3, 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, p0, 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 = hNew; // 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..56c3ce947 --- /dev/null +++ b/AtTools/AtPropagator.h @@ -0,0 +1,275 @@ +#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 + std::unique_ptr fELossModel; // Energy loss model + + // 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, std::unique_ptr elossModel) + : fELossModel(std::move(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.get(); } + + 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..10c0ab2e1 --- /dev/null +++ b/AtTools/AtPropagatorTest.cxx @@ -0,0 +1,368 @@ +#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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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, std::move(elossModel)); + 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; +} diff --git a/AtTools/AtToolsLinkDef.h b/AtTools/AtToolsLinkDef.h index e2337413e..55d4a74a2 100644 --- a/AtTools/AtToolsLinkDef.h +++ b/AtTools/AtToolsLinkDef.h @@ -58,6 +58,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..7324f2e1d 100644 --- a/AtTools/CMakeLists.txt +++ b/AtTools/CMakeLists.txt @@ -40,6 +40,7 @@ set(SRCS DataCleaning/AtkNN.cxx AtELossBetheBloch.cxx + AtPropagator.cxx ) Set(DEPENDENCIES @@ -80,6 +81,7 @@ set(TEST_SRCS DataCleaning/AtkNNTest.cxx AtELossTableTest.cxx AtELossBetheBlochTest.cxx + AtPropagatorTest.cxx ) if(CATIMA_FOUND) set(TEST_SRCS ${TEST_SRCS} From 857b45e00b08170c07dbd7214d71d188462590d0 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 00:27:49 -0400 Subject: [PATCH 02/18] Add curved-track B/E-field propagation and physics unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire AtPropagator into AtSimpleSimulation: when fEField or fBField is non-zero, use RK4 adaptive stepping (Lorentz force) instead of the straight-line fast path. Both fields default to zero, so existing callers are unaffected. - Add SetMagneticField / SetElectricField / SetMaxPropagationStep API. - Fix AtRK4AdaptiveStepper: clamp hNew to [fMinStep, fMaxStep] on the ACCEPT branch (previously only clamped on reject, allowing unbounded step growth with zero energy loss). - Add two GTest physics tests (AtSimTest.cxx / AtDigitizationTests): ZeroFieldStraightLine — proton stops at expected range, X/Y = 0 MagneticFieldLarmorRadius — curved path matches r = p⊥/(qB) ±5 % - Add simpleSim_Bfield.C macro: end-to-end FairRunAna example with 2 T solenoid field and AtTPCIonGenerator proton beam. - Add compareSimVsGeant.C macro: overlays SimpleSim vs Geant4 MCPoints on Bragg curve, track-length, total eLoss, XY-map, and Z distributions. Co-Authored-By: Claude Sonnet 4.6 --- AtDigitization/AtSimTest.cxx | 211 +++++++++++++++++++++ AtDigitization/AtSimpleSimulation.cxx | 1 + AtDigitization/AtSimpleSimulation.h | 3 + AtDigitization/CMakeLists.txt | 9 + AtTools/AtPropagator.cxx | 2 +- macro/Simulation/compareSimVsGeant.C | 261 ++++++++++++++++++++++++++ macro/Simulation/simpleSim_Bfield.C | 100 ++++++++++ 7 files changed, 586 insertions(+), 1 deletion(-) create mode 100644 AtDigitization/AtSimTest.cxx create mode 100644 macro/Simulation/compareSimVsGeant.C create mode 100644 macro/Simulation/simpleSim_Bfield.C diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx new file mode 100644 index 000000000..170089afd --- /dev/null +++ b/AtDigitization/AtSimTest.cxx @@ -0,0 +1,211 @@ +/** + * Unit tests for AtSimpleSimulation. + * + * 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 "AtMCPoint.h" +#include "AtSimpleSimulation.h" + +#include "AtELossModel.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +// --------------------------------------------------------------------------- +// 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. AtSimpleSimulation 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 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) +{ + AtSimpleSimulation sim; + sim.AddModel(1, 1, std::make_shared(1.0 /*MeV/mm*/)); + + // Proton: KE = 50 MeV → p_z ≈ 310.5 MeV/c, E ≈ 988.3 MeV + const double mass_p = 938.272; // MeV/c² + 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) +{ + AtSimpleSimulation sim; + // Tiny energy-loss rate keeps KE almost constant (avoids infinite loop in + // straight-line path, irrelevant here since B≠0 uses AtPropagator). + sim.AddModel(1, 1, std::make_shared(0.0 /*MeV/mm — no drag*/)); + // Use explicit XYZVector construction to ensure the B field is recognised as non-zero. + sim.SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); // 2 T along Z + + 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"; +} diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index f004260c5..cd5be8291 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -155,6 +155,7 @@ AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &i prop.SetState(iniPos, iniMom.Vect()); AtTools::AtRK4AdaptiveStepper stepper; + stepper.fMaxStep = fMaxPropStep; double length = 0; while (IsInVolume("drift_volume", prop.GetPosition())) { diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index 49876783b..7d005b448 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -60,6 +60,7 @@ class AtSimpleSimulation { XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) + double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) // Variables to across an entire event static thread_local int fTrackID; @@ -92,6 +93,8 @@ class AtSimpleSimulation { void SetElectricField(XYZVector eField) { fEField = eField; } ///< Electric field in V/m void SetMagneticField(XYZVector bField) { fBField = bField; } ///< Magnetic field in T + /// Maximum step size (m) for the RK4 adaptive stepper in curved-track mode (default: 1e-3 m = 1 mm). + void SetMaxPropagationStep(double stepM) { fMaxPropStep = stepM; } void NewEvent(); diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index 33813c446..ac395f480 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -45,3 +45,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/AtTools/AtPropagator.cxx b/AtTools/AtPropagator.cxx index 2a4e6050b..98f43d302 100644 --- a/AtTools/AtPropagator.cxx +++ b/AtTools/AtPropagator.cxx @@ -444,7 +444,7 @@ AtPropagator::StepState AtRK4AdaptiveStepper::Step(const AtPropagator::StepState 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 = hNew; // Adjust the step size for the next iteration + 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; diff --git a/macro/Simulation/compareSimVsGeant.C b/macro/Simulation/compareSimVsGeant.C new file mode 100644 index 000000000..8af8a5687 --- /dev/null +++ b/macro/Simulation/compareSimVsGeant.C @@ -0,0 +1,261 @@ +/** + * compareSimVsGeant.C + * + * Overlay AtTestSimulation (SimpleSim) and Geant4 simulation output on the + * same plots for visual comparison. Both produce "AtTpcPoint" branches + * (TClonesArray of AtMCPoint / AtTpcPoint) in a "cbmsim" TTree. + * + * Comparison plots produced (saved to ./data/compareSimVsGeant.pdf): + * 1. Bragg curve: mean dE/dx [MeV/mm] vs Z position [mm] — per-event tracks + * averaged across all events. + * 2. Track-length distribution [mm]. + * 3. Total energy loss per track [MeV]. + * 4. XY hit projection (shows Larmor spirals when B ≠ 0). + * 5. Z-position distribution of all hits. + * + * Usage: + * source build/config.sh + * root -l -q 'macro/Simulation/compareSimVsGeant.C("geant_output.root","simpleSim_output.root")' + * + * Arguments: + * geantFile — output ROOT file from a standard FairRunSim Geant4 macro + * simpleFile — output ROOT file from simpleSim_Bfield.C (or similar AtTestSimulation macro) + * branchName — MCPoint branch name (default "AtTpcPoint"; Geant4 may use "AtTpcPoint") + * nEventsMax — maximum events to read from each file (0 = all) + */ + +#include "AtMCPoint.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Helper: fill histograms from one file +// --------------------------------------------------------------------------- +struct SimData { + TH1D *hTrackLength; // track length [mm] + TH1D *hTotalELoss; // total energy loss per track [MeV] + TH1D *hHitZ; // Z position of all hits [mm] + TH2D *hXY; // XY projection of all hits [mm] + TProfile *hBragg; // mean dE/dx [MeV/mm] vs Z [mm] + int nEvents; + int nHits; +}; + +SimData FillHistograms(const TString &fileName, const TString &branchName, int nEventsMax, + const TString &suffix) +{ + SimData d; + d.hTrackLength = new TH1D("hLen_" + suffix, ";Track length [mm];Events", 200, 0, 600); + d.hTotalELoss = new TH1D("hELoss_" + suffix, ";Total #DeltaE [MeV];Events", 200, 0, 100); + d.hHitZ = new TH1D("hZ_" + suffix, ";Z [mm];Hits", 200, -100, 1100); + d.hXY = new TH2D("hXY_" + suffix, ";X [mm];Y [mm]", 200, -300, 300, 200, -300, 300); + d.hBragg = new TProfile("hBragg_" + suffix, ";Z [mm];#LTdE/dx#GT [MeV/mm]", 200, -100, 1100); + d.nEvents = 0; + d.nHits = 0; + + TFile *f = TFile::Open(fileName); + if (!f || f->IsZombie()) { + std::cerr << "ERROR: cannot open " << fileName << "\n"; + return d; + } + + TTree *tree = dynamic_cast(f->Get("cbmsim")); + if (!tree) { + std::cerr << "ERROR: no 'cbmsim' tree in " << fileName << "\n"; + f->Close(); + return d; + } + + TClonesArray *pointArray = nullptr; + tree->SetBranchAddress(branchName, &pointArray); + + int nEvents = (nEventsMax > 0) ? std::min((int)tree->GetEntriesFast(), nEventsMax) + : (int)tree->GetEntriesFast(); + + for (int iEv = 0; iEv < nEvents; ++iEv) { + tree->GetEntry(iEv); + if (!pointArray) + continue; + + // Group points by track ID, accumulate per-track quantities + std::map trackELoss; + std::map trackLength; + + int nPts = pointArray->GetEntriesFast(); + for (int i = 0; i < nPts; ++i) { + auto *pt = dynamic_cast(pointArray->At(i)); + if (!pt) + continue; + + double x_mm = pt->GetX() * 10.; + double y_mm = pt->GetY() * 10.; + double z_mm = pt->GetZ() * 10.; + double eLoss_MeV = pt->GetEnergyLoss() * 1000.; // GeV → MeV + double len_mm = pt->GetLength() * 10.; // cm → mm + + d.hHitZ->Fill(z_mm); + d.hXY->Fill(x_mm, y_mm); + + // dE/dx = energy loss / step length. Use difference between consecutive + // lengths on the same track as the step size. + int tid = pt->GetTrackID(); + double prevLen = trackLength.count(tid) ? trackLength[tid] : 0; + double stepLen = len_mm - prevLen; + if (stepLen > 0) + d.hBragg->Fill(z_mm, eLoss_MeV / stepLen); + + trackELoss[tid] += eLoss_MeV; + trackLength[tid] = len_mm; + d.nHits++; + } + + for (auto &[tid, eLoss] : trackELoss) { + d.hTotalELoss->Fill(eLoss); + d.hTrackLength->Fill(trackLength[tid]); + } + d.nEvents++; + } + + f->Close(); + std::cout << suffix << ": read " << d.nEvents << " events, " << d.nHits << " hits\n"; + return d; +} + +// --------------------------------------------------------------------------- +// Main macro +// --------------------------------------------------------------------------- +void compareSimVsGeant(TString geantFile = "./data/geant_output.root", + TString simpleFile = "./data/simpleSim_Bfield.root", + TString branchName = "AtTpcPoint", int nEventsMax = 0) +{ + gStyle->SetOptStat(0); + gStyle->SetOptTitle(0); + + SimData geant = FillHistograms(geantFile, branchName, nEventsMax, "G4"); + SimData simple = FillHistograms(simpleFile, branchName, nEventsMax, "Sim"); + + if (geant.nHits == 0 && simple.nHits == 0) { + std::cerr << "ERROR: no hits in either file. Check file paths and branch name.\n"; + return; + } + + // ---- Style ----------------------------------------------------------- + auto styleG4 = [](TH1 *h) { + h->SetLineColor(kBlue + 1); + h->SetLineWidth(2); + }; + auto styleSim = [](TH1 *h) { + h->SetLineColor(kRed + 1); + h->SetLineWidth(2); + h->SetLineStyle(2); + }; + + styleG4(geant.hTrackLength); + styleG4(geant.hTotalELoss); + styleG4(geant.hHitZ); + styleG4(geant.hBragg); + styleSim(simple.hTrackLength); + styleSim(simple.hTotalELoss); + styleSim(simple.hHitZ); + styleSim(simple.hBragg); + + // Normalize to events so shapes compare regardless of statistics + auto normalize = [](TH1 *h, double n) { + if (n > 0 && h->Integral() > 0) + h->Scale(1.0 / h->Integral()); + }; + normalize(geant.hTrackLength, geant.nEvents); + normalize(geant.hTotalELoss, geant.nEvents); + normalize(geant.hHitZ, geant.nHits); + normalize(simple.hTrackLength, simple.nEvents); + normalize(simple.hTotalELoss, simple.nEvents); + normalize(simple.hHitZ, simple.nHits); + + // ---- Canvas layout --------------------------------------------------- + TCanvas *c = new TCanvas("cCompare", "Geant4 vs SimpleSim comparison", 1400, 900); + c->Divide(3, 2); + + auto makeLegend = [&](TVirtualPad *pad) { + pad->cd(); + auto *leg = new TLegend(0.55, 0.72, 0.92, 0.88); + leg->SetBorderSize(0); + leg->AddEntry(geant.hBragg, "Geant4", "l"); + leg->AddEntry(simple.hBragg, "SimpleSim", "l"); + leg->Draw(); + }; + + // 1 — Bragg curve + c->cd(1); + gPad->SetLeftMargin(0.15); + auto *braggTitle = new TH1D("braggFrame", ";Z [mm];#LTdE/dx#GT [MeV/mm]", 1, -100, 1100); + braggTitle->SetMaximum(std::max(geant.hBragg->GetMaximum(), simple.hBragg->GetMaximum()) * 1.2); + braggTitle->Draw(); + geant.hBragg->Draw("same"); + simple.hBragg->Draw("same"); + makeLegend(gPad); + + // 2 — Track length + c->cd(2); + gPad->SetLeftMargin(0.15); + geant.hTrackLength->GetYaxis()->SetTitle("Normalised entries"); + geant.hTrackLength->SetMaximum( + std::max(geant.hTrackLength->GetMaximum(), simple.hTrackLength->GetMaximum()) * 1.3); + geant.hTrackLength->Draw("hist"); + simple.hTrackLength->Draw("hist same"); + makeLegend(gPad); + + // 3 — Total energy loss + c->cd(3); + gPad->SetLeftMargin(0.15); + geant.hTotalELoss->GetYaxis()->SetTitle("Normalised entries"); + geant.hTotalELoss->SetMaximum( + std::max(geant.hTotalELoss->GetMaximum(), simple.hTotalELoss->GetMaximum()) * 1.3); + geant.hTotalELoss->Draw("hist"); + simple.hTotalELoss->Draw("hist same"); + makeLegend(gPad); + + // 4 — XY projection (Geant4) + c->cd(4); + gPad->SetLeftMargin(0.15); + geant.hXY->GetZaxis()->SetTitle("Hits"); + geant.hXY->SetTitle("Geant4 XY hits"); + geant.hXY->Draw("colz"); + + // 5 — XY projection (SimpleSim) + c->cd(5); + gPad->SetLeftMargin(0.15); + simple.hXY->GetZaxis()->SetTitle("Hits"); + simple.hXY->SetTitle("SimpleSim XY hits"); + simple.hXY->Draw("colz"); + + // 6 — Z hit distribution + c->cd(6); + gPad->SetLeftMargin(0.15); + geant.hHitZ->GetYaxis()->SetTitle("Normalised entries"); + geant.hHitZ->SetMaximum( + std::max(geant.hHitZ->GetMaximum(), simple.hHitZ->GetMaximum()) * 1.3); + geant.hHitZ->Draw("hist"); + simple.hHitZ->Draw("hist same"); + makeLegend(gPad); + + // ---- Save ------------------------------------------------------------ + c->SaveAs("./data/compareSimVsGeant.pdf"); + std::cout << "Comparison plot saved to ./data/compareSimVsGeant.pdf\n"; +} diff --git a/macro/Simulation/simpleSim_Bfield.C b/macro/Simulation/simpleSim_Bfield.C new file mode 100644 index 000000000..04af4bee7 --- /dev/null +++ b/macro/Simulation/simpleSim_Bfield.C @@ -0,0 +1,100 @@ +/** + * simpleSim_Bfield.C + * + * Standalone AT-TPC simulation using AtTestSimulation (no Geant4). + * Fires 50 MeV protons into a 2 T solenoid field along Z. + * + * Expected physics: + * Proton with p_z ≈ 310.5 MeV/c in B = 2 T along Z will spiral around Z with + * Larmor radius r = p⊥ / (q B). Because the initial momentum is purely along Z + * (parallel to B), p⊥ = 0 and there is no Larmor bending — the proton travels in a + * straight line along Z while decelerating. To see curvature, use a reaction + * generator (e.g. AtTPC2Body) that produces particles with transverse momentum, or + * tilt the beam angle via ionGen->SetBeamAngle(). + * + * Output: ./data/simpleSim_Bfield.root + * Tree "cbmsim", branch "AtTpcPoint" (TClonesArray of AtMCPoint). + * Open with ROOT browser or compareSimVsGeant.C. + * + * Usage: + * source build/config.sh + * root -l -q 'macro/Simulation/simpleSim_Bfield.C(100)' + */ + +#include "AtSimpleSimulation.h" +#include "AtTestSimulation.h" +#include "AtTPCIonGenerator.h" + +#include +#include +#include +#include + +#include +#include + +#include +#include + +void simpleSim_Bfield(Int_t nEvents = 100) +{ + TString dir = getenv("VMCWORKDIR"); + if (dir.IsNull()) { + std::cerr << "ERROR: VMCWORKDIR not set. Run 'source build/config.sh' first.\n"; + return; + } + + TString geoFile = dir + "/geometry/ATTPC_He1bar_geomanager.root"; + TString outputFile = "./data/simpleSim_Bfield.root"; + TString elossFile = dir + "/resources/energy_loss/HinH.txt"; + + // ---- FairRunAna (no Geant4) ------------------------------------------ + FairRunAna *run = new FairRunAna(); + run->SetOutputFile(outputFile); + + // ---- Build the simulation -------------------------------------------- + auto sim = std::make_unique(geoFile.Data()); + + // Proton energy-loss in He gas (SRIM table, H in H approximation for He) + auto eloss = std::make_shared(); + eloss->LoadSrimTable(elossFile.Data()); + sim->AddModel(1, 1, eloss); + + // 2 T solenoidal field along Z (beam axis). + // Particles with transverse momentum will spiral; a purely longitudinal beam + // proton travels straight. Use a reaction generator for curved tracks. + sim->SetMagneticField({0., 0., 2.0}); // T + + // ---- FairTask wrapper ------------------------------------------------ + auto *simTask = new AtTestSimulation(std::move(sim)); + + // ---- Generator: 50 MeV proton along Z -------------------------------- + // Momentum components are in GeV/c PER NUCLEON. + // p_z for KE = 50 MeV proton: + // E = m + KE = 938.272 + 50 = 988.272 MeV + // p = sqrt(E² - m²) ≈ 310.5 MeV/c → 0.3105 GeV/c per nucleon (A=1) + const Double_t pz_GeV = 0.3105; + const Double_t mass_GeV = 0.938272; + const Double_t ener_MeV = 50.0; + + auto *primGen = new FairPrimaryGenerator(); + auto *ionGen = new AtTPCIonGenerator("proton", /*z=*/1, /*a=*/1, /*q=*/1, /*mult=*/1, + /*px=*/0.0, /*py=*/0.0, /*pz=*/pz_GeV, + /*Ex=*/0.0, /*m=*/mass_GeV, /*ener=*/ener_MeV); + // Start beam at upstream face of the detector (z = -50 cm in detector coords) + ionGen->SetSpotRadius(0, -50., 0.); + primGen->AddGenerator(ionGen); + simTask->SetPrimaryGenerator(primGen); + + run->AddTask(simTask); + + // ---- Run --------------------------------------------------------------- + TStopwatch timer; + run->Init(); + timer.Start(); + run->Run(0, nEvents); + timer.Stop(); + + std::cout << "\nMacro finished. Output: " << outputFile << "\n"; + std::cout << "Real time: " << timer.RealTime() << " s, CPU time: " << timer.CpuTime() << " s\n"; +} From 9c33d916eab8f728aa1335c76ac2e38da1db7e33 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 00:47:02 -0400 Subject: [PATCH 03/18] Fix macros: remove framework includes, use FairBoxGenerator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all framework #include directives from simpleSim_Bfield.C and compareSimVsGeant.C; Cling autoloads these via rootmap and explicit includes cause FairMCPoint incomplete-type errors. - Replace AtTPCIonGenerator with FairBoxGenerator: AtTPCIonGenerator requires FairRunSim in its constructor and cannot be used with FairRunAna. - Fix beam start position to (0, 6.079, 1) cm — inside the drift_volume cylinder (r=25 cm, z=0–100 cm, y-offset 6.079 cm in ATTPC_He1bar). Co-Authored-By: Claude Sonnet 4.6 --- macro/Simulation/compareSimVsGeant.C | 18 +---------- macro/Simulation/simpleSim_Bfield.C | 46 +++++++++------------------- 2 files changed, 15 insertions(+), 49 deletions(-) diff --git a/macro/Simulation/compareSimVsGeant.C b/macro/Simulation/compareSimVsGeant.C index 8af8a5687..54fd8f399 100644 --- a/macro/Simulation/compareSimVsGeant.C +++ b/macro/Simulation/compareSimVsGeant.C @@ -24,26 +24,10 @@ * nEventsMax — maximum events to read from each file (0 = all) */ -#include "AtMCPoint.h" - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - #include #include +#include #include -#include // --------------------------------------------------------------------------- // Helper: fill histograms from one file diff --git a/macro/Simulation/simpleSim_Bfield.C b/macro/Simulation/simpleSim_Bfield.C index 04af4bee7..93f18b988 100644 --- a/macro/Simulation/simpleSim_Bfield.C +++ b/macro/Simulation/simpleSim_Bfield.C @@ -5,12 +5,10 @@ * Fires 50 MeV protons into a 2 T solenoid field along Z. * * Expected physics: - * Proton with p_z ≈ 310.5 MeV/c in B = 2 T along Z will spiral around Z with - * Larmor radius r = p⊥ / (q B). Because the initial momentum is purely along Z - * (parallel to B), p⊥ = 0 and there is no Larmor bending — the proton travels in a - * straight line along Z while decelerating. To see curvature, use a reaction - * generator (e.g. AtTPC2Body) that produces particles with transverse momentum, or - * tilt the beam angle via ionGen->SetBeamAngle(). + * Proton with p_z ≈ 310.5 MeV/c in B = 2 T along Z will travel in a straight line + * because p⊥ = 0 (momentum parallel to B). To see Larmor curvature, use a reaction + * generator that produces particles with transverse momentum, or set a non-zero + * theta in FairBoxGenerator. * * Output: ./data/simpleSim_Bfield.root * Tree "cbmsim", branch "AtTpcPoint" (TClonesArray of AtMCPoint). @@ -21,18 +19,6 @@ * root -l -q 'macro/Simulation/simpleSim_Bfield.C(100)' */ -#include "AtSimpleSimulation.h" -#include "AtTestSimulation.h" -#include "AtTPCIonGenerator.h" - -#include -#include -#include -#include - -#include -#include - #include #include @@ -69,21 +55,17 @@ void simpleSim_Bfield(Int_t nEvents = 100) auto *simTask = new AtTestSimulation(std::move(sim)); // ---- Generator: 50 MeV proton along Z -------------------------------- - // Momentum components are in GeV/c PER NUCLEON. - // p_z for KE = 50 MeV proton: - // E = m + KE = 938.272 + 50 = 988.272 MeV - // p = sqrt(E² - m²) ≈ 310.5 MeV/c → 0.3105 GeV/c per nucleon (A=1) - const Double_t pz_GeV = 0.3105; - const Double_t mass_GeV = 0.938272; - const Double_t ener_MeV = 50.0; - + // FairBoxGenerator works with FairRunAna (no Geant4/FairRunSim required). + // PDG 2212 = proton. Momentum is given in GeV/c. + // KE = 50 MeV proton: E = m + KE = 988.272 MeV, p ≈ 310.5 MeV/c = 0.3105 GeV/c auto *primGen = new FairPrimaryGenerator(); - auto *ionGen = new AtTPCIonGenerator("proton", /*z=*/1, /*a=*/1, /*q=*/1, /*mult=*/1, - /*px=*/0.0, /*py=*/0.0, /*pz=*/pz_GeV, - /*Ex=*/0.0, /*m=*/mass_GeV, /*ener=*/ener_MeV); - // Start beam at upstream face of the detector (z = -50 cm in detector coords) - ionGen->SetSpotRadius(0, -50., 0.); - primGen->AddGenerator(ionGen); + auto *boxGen = new FairBoxGenerator(2212 /*proton PDG*/, 1 /*multiplicity*/); + boxGen->SetPRange(0.3105, 0.3105); // fixed |p| in GeV/c + boxGen->SetPhiRange(0., 0.); // phi = 0 → momentum in XZ plane + boxGen->SetThetaRange(0., 0.); // theta = 0 → along +Z + // drift_volume is a tube at (0, 6.079, 50) cm with r=25 cm, half-length=50 cm → z: 0–100 cm + boxGen->SetXYZ(0., 6.079, 1.); // start 1 cm inside the window at beam axis + primGen->AddGenerator(boxGen); simTask->SetPrimaryGenerator(primGen); run->AddTask(simTask); From 3f3697f4438c15a03faae8bf3f8e0690f425a4f7 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 01:03:18 -0400 Subject: [PATCH 04/18] Fix macro physics and Bragg-curve dEdx calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simpleSim_Bfield.C: - Reduce proton energy from 50 MeV to 1 MeV (p=43.33 MeV/c). HinH.txt covers 0–11 MeV; a 50 MeV proton is outside the table range and produces eLoss=0 throughout. A 1 MeV proton has range ~200 mm and stops cleanly inside the 1000 mm drift volume. compareSimVsGeant.C: - Replace cumulative-GetLength()-difference step size with 3-D position difference between consecutive hits. GetLength() is the cumulative track length from the particle origin, which for Geant4 tracks includes path outside the active volume; the old approach gave a spuriously large first-step dEdx. - Fix histogram ranges (Z: 0–1100 mm, eLoss: 0–15 MeV) and add guard against zero-length / zero-loss steps in the Bragg profile. - Skip missing files gracefully instead of crashing. - Drop spurious normalisation argument (n) that was unused. Co-Authored-By: Claude Sonnet 4.6 --- macro/Simulation/compareSimVsGeant.C | 267 ++++++++++++++------------- macro/Simulation/simpleSim_Bfield.C | 23 ++- 2 files changed, 152 insertions(+), 138 deletions(-) diff --git a/macro/Simulation/compareSimVsGeant.C b/macro/Simulation/compareSimVsGeant.C index 54fd8f399..8bc6f4e30 100644 --- a/macro/Simulation/compareSimVsGeant.C +++ b/macro/Simulation/compareSimVsGeant.C @@ -3,66 +3,67 @@ * * Overlay AtTestSimulation (SimpleSim) and Geant4 simulation output on the * same plots for visual comparison. Both produce "AtTpcPoint" branches - * (TClonesArray of AtMCPoint / AtTpcPoint) in a "cbmsim" TTree. + * (TClonesArray of AtMCPoint / FairMCPoint subclass) in a "cbmsim" TTree. * * Comparison plots produced (saved to ./data/compareSimVsGeant.pdf): - * 1. Bragg curve: mean dE/dx [MeV/mm] vs Z position [mm] — per-event tracks - * averaged across all events. + * 1. Bragg curve: mean dE/dx [MeV/mm] vs Z position [mm]. * 2. Track-length distribution [mm]. * 3. Total energy loss per track [MeV]. - * 4. XY hit projection (shows Larmor spirals when B ≠ 0). + * 4. XY hit projection. * 5. Z-position distribution of all hits. * + * dE/dx is computed as eLoss_step / step_length, where step_length is the + * 3-D distance between consecutive hits on the same track. This avoids the + * bias that arises when using GetLength() (cumulative from track origin), + * which for Geant4 includes path outside the active volume. + * * Usage: * source build/config.sh - * root -l -q 'macro/Simulation/compareSimVsGeant.C("geant_output.root","simpleSim_output.root")' + * root -l -q 'macro/Simulation/compareSimVsGeant.C("geant.root","simple.root")' * * Arguments: - * geantFile — output ROOT file from a standard FairRunSim Geant4 macro - * simpleFile — output ROOT file from simpleSim_Bfield.C (or similar AtTestSimulation macro) - * branchName — MCPoint branch name (default "AtTpcPoint"; Geant4 may use "AtTpcPoint") - * nEventsMax — maximum events to read from each file (0 = all) + * geantFile — output ROOT file from a FairRunSim Geant4 macro + * simpleFile — output ROOT file from simpleSim_Bfield.C + * branchName — MCPoint branch name (default "AtTpcPoint") + * nEventsMax — max events to read per file (0 = all) */ #include +#include #include #include #include -// --------------------------------------------------------------------------- -// Helper: fill histograms from one file // --------------------------------------------------------------------------- struct SimData { - TH1D *hTrackLength; // track length [mm] - TH1D *hTotalELoss; // total energy loss per track [MeV] - TH1D *hHitZ; // Z position of all hits [mm] - TH2D *hXY; // XY projection of all hits [mm] - TProfile *hBragg; // mean dE/dx [MeV/mm] vs Z [mm] - int nEvents; - int nHits; + TH1D *hTrackLength; + TH1D *hTotalELoss; + TH1D *hHitZ; + TH2D *hXY; + TProfile *hBragg; + int nEvents{0}; + int nHits{0}; }; SimData FillHistograms(const TString &fileName, const TString &branchName, int nEventsMax, const TString &suffix) { SimData d; - d.hTrackLength = new TH1D("hLen_" + suffix, ";Track length [mm];Events", 200, 0, 600); - d.hTotalELoss = new TH1D("hELoss_" + suffix, ";Total #DeltaE [MeV];Events", 200, 0, 100); - d.hHitZ = new TH1D("hZ_" + suffix, ";Z [mm];Hits", 200, -100, 1100); - d.hXY = new TH2D("hXY_" + suffix, ";X [mm];Y [mm]", 200, -300, 300, 200, -300, 300); - d.hBragg = new TProfile("hBragg_" + suffix, ";Z [mm];#LTdE/dx#GT [MeV/mm]", 200, -100, 1100); - d.nEvents = 0; - d.nHits = 0; + d.hTrackLength = new TH1D("hLen_" + suffix, ";Track length [mm];Entries", 150, 0, 1100); + d.hTotalELoss = new TH1D("hELoss_" + suffix, ";Total #DeltaE [MeV];Entries", 150, 0, 15); + d.hHitZ = new TH1D("hZ_" + suffix, ";Z [mm];Hits", 100, 0, 1100); + d.hXY = new TH2D("hXY_" + suffix, ";X [mm];Y [mm]", 100, -300, 300, 100, -300, 300); + d.hBragg = new TProfile("hBragg_" + suffix, ";Z [mm];dE/dx [MeV/mm]", 100, 0, 1100); TFile *f = TFile::Open(fileName); if (!f || f->IsZombie()) { - std::cerr << "ERROR: cannot open " << fileName << "\n"; + std::cerr << "WARNING: cannot open " << fileName << " — skipping.\n"; return d; } TTree *tree = dynamic_cast(f->Get("cbmsim")); if (!tree) { - std::cerr << "ERROR: no 'cbmsim' tree in " << fileName << "\n"; + std::cerr << "WARNING: no 'cbmsim' tree in " << fileName << " — skipping.\n"; f->Close(); return d; } @@ -70,17 +71,21 @@ SimData FillHistograms(const TString &fileName, const TString &branchName, int n TClonesArray *pointArray = nullptr; tree->SetBranchAddress(branchName, &pointArray); - int nEvents = (nEventsMax > 0) ? std::min((int)tree->GetEntriesFast(), nEventsMax) - : (int)tree->GetEntriesFast(); + int nEvents = (nEventsMax > 0 && nEventsMax < (int)tree->GetEntriesFast()) + ? nEventsMax + : (int)tree->GetEntriesFast(); for (int iEv = 0; iEv < nEvents; ++iEv) { tree->GetEntry(iEv); - if (!pointArray) + if (!pointArray || pointArray->GetEntriesFast() == 0) continue; - // Group points by track ID, accumulate per-track quantities + // Per-track accumulators (reset each event) std::map trackELoss; - std::map trackLength; + std::map trackLastLen; + + // Previous hit position per track (for step-length calculation) + std::map prevX, prevY, prevZ; int nPts = pointArray->GetEntriesFast(); for (int i = 0; i < nPts; ++i) { @@ -88,158 +93,160 @@ SimData FillHistograms(const TString &fileName, const TString &branchName, int n if (!pt) continue; - double x_mm = pt->GetX() * 10.; - double y_mm = pt->GetY() * 10.; - double z_mm = pt->GetZ() * 10.; - double eLoss_MeV = pt->GetEnergyLoss() * 1000.; // GeV → MeV - double len_mm = pt->GetLength() * 10.; // cm → mm + double x_mm = pt->GetX() * 10.; // cm → mm + double y_mm = pt->GetY() * 10.; + double z_mm = pt->GetZ() * 10.; + double eLoss = pt->GetEnergyLoss() * 1000.; // GeV → MeV + double len_mm = pt->GetLength() * 10.; // cm → mm + int tid = pt->GetTrackID(); d.hHitZ->Fill(z_mm); d.hXY->Fill(x_mm, y_mm); - // dE/dx = energy loss / step length. Use difference between consecutive - // lengths on the same track as the step size. - int tid = pt->GetTrackID(); - double prevLen = trackLength.count(tid) ? trackLength[tid] : 0; - double stepLen = len_mm - prevLen; - if (stepLen > 0) - d.hBragg->Fill(z_mm, eLoss_MeV / stepLen); - - trackELoss[tid] += eLoss_MeV; - trackLength[tid] = len_mm; + // --- Bragg curve: dE/dx from 3-D step between consecutive hits ---- + // Using position differences avoids the bias from GetLength() which + // counts path outside the active volume for Geant4 tracks. + if (prevZ.count(tid)) { + double dx = x_mm - prevX[tid]; + double dy = y_mm - prevY[tid]; + double dz = z_mm - prevZ[tid]; + double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step > 0.01 && eLoss > 0) // guard: skip zero-length or zero-loss steps + d.hBragg->Fill(z_mm, eLoss / step); + } + prevX[tid] = x_mm; + prevY[tid] = y_mm; + prevZ[tid] = z_mm; + + trackELoss[tid] += eLoss; + trackLastLen[tid] = len_mm; d.nHits++; } + // Per-track summary histograms for (auto &[tid, eLoss] : trackELoss) { d.hTotalELoss->Fill(eLoss); - d.hTrackLength->Fill(trackLength[tid]); + d.hTrackLength->Fill(trackLastLen[tid]); } d.nEvents++; } f->Close(); - std::cout << suffix << ": read " << d.nEvents << " events, " << d.nHits << " hits\n"; + std::cout << suffix << ": " << d.nEvents << " events, " << d.nHits << " hits\n"; return d; } // --------------------------------------------------------------------------- -// Main macro -// --------------------------------------------------------------------------- -void compareSimVsGeant(TString geantFile = "./data/geant_output.root", +void compareSimVsGeant(TString geantFile = "./data/geant_output.root", TString simpleFile = "./data/simpleSim_Bfield.root", - TString branchName = "AtTpcPoint", int nEventsMax = 0) + TString branchName = "AtTpcPoint", + int nEventsMax = 0) { gStyle->SetOptStat(0); - gStyle->SetOptTitle(0); + gStyle->SetOptTitle(1); - SimData geant = FillHistograms(geantFile, branchName, nEventsMax, "G4"); + SimData geant = FillHistograms(geantFile, branchName, nEventsMax, "G4"); SimData simple = FillHistograms(simpleFile, branchName, nEventsMax, "Sim"); if (geant.nHits == 0 && simple.nHits == 0) { - std::cerr << "ERROR: no hits in either file. Check file paths and branch name.\n"; + std::cerr << "ERROR: no hits in either file. Check paths and branch name.\n"; return; } // ---- Style ----------------------------------------------------------- - auto styleG4 = [](TH1 *h) { + auto styleG4 = [](TH1 *h, bool fill = false) { h->SetLineColor(kBlue + 1); h->SetLineWidth(2); + if (fill) { h->SetFillColorAlpha(kBlue + 1, 0.2); h->SetFillStyle(1001); } }; - auto styleSim = [](TH1 *h) { + auto styleSim = [](TH1 *h, bool fill = false) { h->SetLineColor(kRed + 1); h->SetLineWidth(2); h->SetLineStyle(2); + if (fill) { h->SetFillColorAlpha(kRed + 1, 0.2); h->SetFillStyle(1001); } }; - styleG4(geant.hTrackLength); - styleG4(geant.hTotalELoss); - styleG4(geant.hHitZ); - styleG4(geant.hBragg); - styleSim(simple.hTrackLength); - styleSim(simple.hTotalELoss); - styleSim(simple.hHitZ); - styleSim(simple.hBragg); - - // Normalize to events so shapes compare regardless of statistics - auto normalize = [](TH1 *h, double n) { - if (n > 0 && h->Integral() > 0) - h->Scale(1.0 / h->Integral()); + styleG4(geant.hTrackLength); styleG4(geant.hTotalELoss); + styleG4(geant.hHitZ); styleG4(geant.hBragg); + styleSim(simple.hTrackLength); styleSim(simple.hTotalELoss); + styleSim(simple.hHitZ); styleSim(simple.hBragg); + + // Normalize 1-D histograms to unit area so shapes compare + // regardless of event count. Skip empty histograms. + auto normalize = [](TH1 *h) { + if (h->Integral() > 0) h->Scale(1.0 / h->Integral()); }; - normalize(geant.hTrackLength, geant.nEvents); - normalize(geant.hTotalELoss, geant.nEvents); - normalize(geant.hHitZ, geant.nHits); - normalize(simple.hTrackLength, simple.nEvents); - normalize(simple.hTotalELoss, simple.nEvents); - normalize(simple.hHitZ, simple.nHits); - - // ---- Canvas layout --------------------------------------------------- - TCanvas *c = new TCanvas("cCompare", "Geant4 vs SimpleSim comparison", 1400, 900); + if (geant.nEvents > 0) { normalize(geant.hTrackLength); normalize(geant.hTotalELoss); normalize(geant.hHitZ); } + if (simple.nEvents > 0) { normalize(simple.hTrackLength); normalize(simple.hTotalELoss); normalize(simple.hHitZ); } + + // ---- Canvas ---------------------------------------------------------- + TCanvas *c = new TCanvas("cCompare", "Geant4 vs SimpleSim", 1400, 900); c->Divide(3, 2); - auto makeLegend = [&](TVirtualPad *pad) { + auto addLegend = [&](TVirtualPad *pad) { pad->cd(); auto *leg = new TLegend(0.55, 0.72, 0.92, 0.88); leg->SetBorderSize(0); - leg->AddEntry(geant.hBragg, "Geant4", "l"); - leg->AddEntry(simple.hBragg, "SimpleSim", "l"); + if (geant.nHits > 0) leg->AddEntry(geant.hBragg, "Geant4", "l"); + if (simple.nHits > 0) leg->AddEntry(simple.hBragg, "SimpleSim","l"); leg->Draw(); }; + auto drawPair = [](TVirtualPad *p, TH1 *hG4, TH1 *hSim, bool profile = false) { + p->cd(); p->SetLeftMargin(0.15); + bool haveG4 = hG4 && hG4->GetEntries() > 0; + bool haveSim = hSim && hSim->GetEntries() > 0; + double ymax = 0; + if (haveG4) ymax = std::max(ymax, hG4->GetMaximum()); + if (haveSim) ymax = std::max(ymax, hSim->GetMaximum()); + if (ymax == 0) ymax = 1; + TH1 *first = haveG4 ? hG4 : hSim; + if (!first) return; + first->SetMaximum(ymax * 1.25); + first->Draw(profile ? "hist" : "hist"); + if (haveG4 && hG4 != first) hG4->Draw("hist same"); + if (haveSim && hSim != first) hSim->Draw("hist same"); + }; + // 1 — Bragg curve - c->cd(1); - gPad->SetLeftMargin(0.15); - auto *braggTitle = new TH1D("braggFrame", ";Z [mm];#LTdE/dx#GT [MeV/mm]", 1, -100, 1100); - braggTitle->SetMaximum(std::max(geant.hBragg->GetMaximum(), simple.hBragg->GetMaximum()) * 1.2); - braggTitle->Draw(); - geant.hBragg->Draw("same"); - simple.hBragg->Draw("same"); - makeLegend(gPad); + c->cd(1); gPad->SetLeftMargin(0.15); + { + bool haveG4 = geant.hBragg->GetEntries() > 0; + bool haveSim = simple.hBragg->GetEntries() > 0; + double ymax = std::max(haveG4 ? geant.hBragg->GetMaximum() : 0., + haveSim ? simple.hBragg->GetMaximum() : 0.); + if (ymax == 0) ymax = 1; + TH1 *first = haveG4 ? (TH1*)geant.hBragg : (TH1*)simple.hBragg; + first->SetMaximum(ymax * 1.25); + first->Draw(); + if (haveG4 && (TH1*)geant.hBragg != first) geant.hBragg->Draw("same"); + if (haveSim && (TH1*)simple.hBragg != first) simple.hBragg->Draw("same"); + addLegend(gPad); + } // 2 — Track length - c->cd(2); - gPad->SetLeftMargin(0.15); - geant.hTrackLength->GetYaxis()->SetTitle("Normalised entries"); - geant.hTrackLength->SetMaximum( - std::max(geant.hTrackLength->GetMaximum(), simple.hTrackLength->GetMaximum()) * 1.3); - geant.hTrackLength->Draw("hist"); - simple.hTrackLength->Draw("hist same"); - makeLegend(gPad); + drawPair(c->cd(2), geant.hTrackLength, simple.hTrackLength); + addLegend(c->cd(2)); // 3 — Total energy loss - c->cd(3); - gPad->SetLeftMargin(0.15); - geant.hTotalELoss->GetYaxis()->SetTitle("Normalised entries"); - geant.hTotalELoss->SetMaximum( - std::max(geant.hTotalELoss->GetMaximum(), simple.hTotalELoss->GetMaximum()) * 1.3); - geant.hTotalELoss->Draw("hist"); - simple.hTotalELoss->Draw("hist same"); - makeLegend(gPad); - - // 4 — XY projection (Geant4) - c->cd(4); - gPad->SetLeftMargin(0.15); - geant.hXY->GetZaxis()->SetTitle("Hits"); - geant.hXY->SetTitle("Geant4 XY hits"); - geant.hXY->Draw("colz"); - - // 5 — XY projection (SimpleSim) - c->cd(5); - gPad->SetLeftMargin(0.15); - simple.hXY->GetZaxis()->SetTitle("Hits"); + drawPair(c->cd(3), geant.hTotalELoss, simple.hTotalELoss); + addLegend(c->cd(3)); + + // 4 — XY (Geant4 or SimpleSim if no Geant4) + c->cd(4); gPad->SetLeftMargin(0.15); + (geant.nHits > 0 ? geant.hXY : simple.hXY)->Draw("colz"); + if (geant.nHits > 0) geant.hXY->SetTitle("Geant4 XY hits"); + else simple.hXY->SetTitle("SimpleSim XY hits"); + + // 5 — XY (SimpleSim) + c->cd(5); gPad->SetLeftMargin(0.15); simple.hXY->SetTitle("SimpleSim XY hits"); simple.hXY->Draw("colz"); // 6 — Z hit distribution - c->cd(6); - gPad->SetLeftMargin(0.15); - geant.hHitZ->GetYaxis()->SetTitle("Normalised entries"); - geant.hHitZ->SetMaximum( - std::max(geant.hHitZ->GetMaximum(), simple.hHitZ->GetMaximum()) * 1.3); - geant.hHitZ->Draw("hist"); - simple.hHitZ->Draw("hist same"); - makeLegend(gPad); - - // ---- Save ------------------------------------------------------------ + drawPair(c->cd(6), geant.hHitZ, simple.hHitZ); + addLegend(c->cd(6)); + c->SaveAs("./data/compareSimVsGeant.pdf"); - std::cout << "Comparison plot saved to ./data/compareSimVsGeant.pdf\n"; + std::cout << "Saved ./data/compareSimVsGeant.pdf\n"; } diff --git a/macro/Simulation/simpleSim_Bfield.C b/macro/Simulation/simpleSim_Bfield.C index 93f18b988..dda386d4e 100644 --- a/macro/Simulation/simpleSim_Bfield.C +++ b/macro/Simulation/simpleSim_Bfield.C @@ -2,13 +2,18 @@ * simpleSim_Bfield.C * * Standalone AT-TPC simulation using AtTestSimulation (no Geant4). - * Fires 50 MeV protons into a 2 T solenoid field along Z. + * Fires 1 MeV protons into a 2 T solenoid field along Z. * * Expected physics: - * Proton with p_z ≈ 310.5 MeV/c in B = 2 T along Z will travel in a straight line - * because p⊥ = 0 (momentum parallel to B). To see Larmor curvature, use a reaction - * generator that produces particles with transverse momentum, or set a non-zero - * theta in FairBoxGenerator. + * A 1 MeV proton (range ~200 mm in H gas at the table density) stops within the + * drift volume, producing a visible Bragg peak. p⊥ = 0 (parallel to B) so the + * track is straight. To see Larmor curvature set a non-zero theta or use a + * reaction generator. + * + * Energy loss table: HinH.txt (H in H gas, SRIM format with density header). + * proton_He_700torr.txt is available but lacks the SRIM density/conversion + * header required by AtELossTable; use HinH.txt as stand-in until a + * properly-formatted He table is generated. * * Output: ./data/simpleSim_Bfield.root * Tree "cbmsim", branch "AtTpcPoint" (TClonesArray of AtMCPoint). @@ -54,13 +59,15 @@ void simpleSim_Bfield(Int_t nEvents = 100) // ---- FairTask wrapper ------------------------------------------------ auto *simTask = new AtTestSimulation(std::move(sim)); - // ---- Generator: 50 MeV proton along Z -------------------------------- + // ---- Generator: 1 MeV proton along Z ------------------------------------ // FairBoxGenerator works with FairRunAna (no Geant4/FairRunSim required). // PDG 2212 = proton. Momentum is given in GeV/c. - // KE = 50 MeV proton: E = m + KE = 988.272 MeV, p ≈ 310.5 MeV/c = 0.3105 GeV/c + // KE = 1 MeV proton: E = 938.272 + 1 = 939.272 MeV + // p = sqrt(939.272² - 938.272²) ≈ 43.33 MeV/c = 0.04333 GeV/c + // Range ≈ 203 mm (from HinH.txt) — stops well within the 1000 mm drift volume. auto *primGen = new FairPrimaryGenerator(); auto *boxGen = new FairBoxGenerator(2212 /*proton PDG*/, 1 /*multiplicity*/); - boxGen->SetPRange(0.3105, 0.3105); // fixed |p| in GeV/c + boxGen->SetPRange(0.04333, 0.04333); // fixed |p| in GeV/c boxGen->SetPhiRange(0., 0.); // phi = 0 → momentum in XZ plane boxGen->SetThetaRange(0., 0.); // theta = 0 → along +Z // drift_volume is a tube at (0, 6.079, 50) cm with r=25 cm, half-length=50 cm → z: 0–100 cm From fffd5e9d48286601f0f8f9409473817404a78849 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 10:52:22 -0400 Subject: [PATCH 05/18] Add plan for validation --- .claude/CLAUDE.md | 6 +- AGENTS.md | 8 +- .../AtSimValidation/AtSimValidationPlan.md | 223 ++++++++++++++++ macro/Simulation/compareSimVsGeant.C | 252 ------------------ macro/Simulation/simpleSim_Bfield.C | 89 ------- 5 files changed, 235 insertions(+), 343 deletions(-) create mode 100644 macro/Simulation/AtSimValidation/AtSimValidationPlan.md delete mode 100644 macro/Simulation/compareSimVsGeant.C delete mode 100644 macro/Simulation/simpleSim_Bfield.C diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 138117faa..ac23bb6a2 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,7 +4,11 @@ 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: +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/AGENTS.md b/AGENTS.md index 3b59135d5..2d78668c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,15 @@ ATTPCROOT is a ROOT/FairRoot-based C++ framework for simulation and analysis of Active Target Time Projection Chamber (AT-TPC) detector data. + + ## 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/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md new file mode 100644 index 000000000..ba34e9f63 --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -0,0 +1,223 @@ +# Plan: AtSimpleSimulation Validation — Elastic Scatter with B-Field + +## Context + +`AtSimpleSimulation` / `AtPropagator` (Lorentz-force RK4) have never been validated against full Geant4 physics. The validation scenario is proton-on-He elastic scattering with a 2 T solenoid, which gives helical tracks with analytically-known Larmor radii and is straightforward to generate with the existing `AtTPC2Body` + `AtTPCIonGenerator` pair. + +The macros live in `macro/Simulation/protonBragg/` (to be **renamed** to `macro/Simulation/AtSimValidation/`). + +**Why the single-generator approach failed:** +`AtTPCIonGenerator` alone produces identical-momentum particles every event with no vertex variation — no statistical content. The simulation pipeline alternates beam events (even) and reaction events (odd). On beam events `AtTPCIonGenerator` fires; on reaction events `AtTPC2Body` fires. Without a reaction generator registered, reaction-phase events corrupt `AtVertexPropagator` state and can crash VMC initialisation. + +**Why two macros per scenario:** +Geant4 uses `FairRunSim`; `AtSimpleSimulation` uses `FairRunAna` + `AtTestSimulation`. They cannot share a run. Each scenario therefore has a Geant4 macro and a mirrored SimpleSim macro that uses identical generator parameters and the same `gRandom` seed, so both process the same sequence of random CMS angles and beam-depth variations. + +--- + +## Physics Being Simulated + +**Reaction:** p + ⁴He → p + ⁴He (elastic) +**Beam:** proton, 1 MeV kinetic energy (p = 43.33 MeV/c = 0.04333 GeV/c along Z) +**Gas / target:** He-1bar, density 1.664 × 10⁻⁴ g/cm³, serves as both medium and target nuclei +**Geometry:** ATTPC cylinder, r = 25 cm, z = 0–100 cm +**B-field:** 2 T along Z (solenoid), applied in both simulations +**Energy-loss model for SimpleSim:** `AtELossCATIMA` with He material + +With 2 T the proton Larmor radius is r_p ≈ 72 mm and the He-4 recoil radius r_He ≈ 36 mm (at full beam momentum; shrinks as particles lose energy). Both tracks form visible helical arcs within the drift volume before stopping. + +**Bragg information** is retained in summary plots: dE/dx vs Z per track provides the Bragg curve for each product. + +--- + +## Folder Structure + +``` +macro/Simulation/AtSimValidation/ ← renamed from protonBragg/ +├── geant4_fixed.C ← Geant4, fixed CMS angle (thetaCms parameter) +├── simpleSim_fixed.C ← SimpleSim, same fixed angle, same seed +├── geant4_kinematic.C ← Geant4, full kinematic curve (0–180° CMS) +├── simpleSim_kinematic.C ← SimpleSim, full kinematic curve, same seed +├── compareFixed.C ← 3D track overlay + Bragg curves, fixed angle +├── compareKinematic.C ← kinematic locus + Bragg curves, full range +└── data/ + ├── geant4_fixed.root + ├── simpleSim_fixed.root + ├── geant4_kinematic.root + └── simpleSim_kinematic.root +``` + +The old files (`runGeant4_proton.C`, `simpleSim_Bfield.C`, `compareSimVsGeant.C`) are removed; their content is superseded. + +--- + +## Generator Setup (canonical, shared across all four simulation macros) + +Follows `C16_pp_sim.C` exactly. + +``` +Beam: Z=1, A=1, Q=0, px=0, py=0, pz=0.04333 GeV/c/nucleon + Bmass=0.938272 GeV/c², NomEnergy=1.0 MeV + ionGen->SetSpotRadius(0, -100, 0) + ionGen->SetDoReaction(kTRUE) // beam drives AtVertexPropagator + +Target: ⁴He at rest: Z=2, A=4, mass=4.00260 amu, ExE=0 + +Product 1 (scattered proton): Z=1, A=1, mass=1.00728 amu, ExE=0 +Product 2 (recoil He-4): Z=2, A=4, mass=4.00260 amu, ExE=0 + +mult = 4 (beam + target + 2 products — required by AtTPC2Body) +ResEner = 1.0 // MeV, nominal beam energy +``` + +`AtTPCIonGenerator` fires on beam events and uses `AtVertexPropagator` to record the beam stopping depth (the reaction vertex). `AtTPC2Body` fires on reaction events, reads the vertex and residual beam momentum, and adds the two products. + +For SimpleSim, `AtTestSimulation::Exec()` calls `fPrimGen->GenerateEvent(&fCollector)` every event. The same even/odd alternation applies because `AtVertexPropagator` is a singleton: beam events deposit the beam track into the collector and update the vertex; reaction events deposit the two reaction products. `AtSimpleSimulation` propagates whichever particles appear in the collector each event. + +**Same ground truth / seed:** +Both the Geant4 macro and the mirrored SimpleSim macro call `gRandom->SetSeed(42)` before `run->Init()`. This produces the same sequence of: +- random CMS angles (from AtTPC2Body's uniform cos-theta sampling) +- `fRndELoss` values that determine beam stopping depth (from AtTPCIonGenerator) + +Because CATIMA and Geant4 EMSTD agree closely on proton energy loss in He, the actual vertex depths will be similar, giving visually compatible track patterns. + +--- + +## Macro 1 & 2: Fixed Angle (`geant4_fixed.C` / `simpleSim_fixed.C`) + +**Signature:** `(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42)` + +**Key parameters in AtTPC2Body:** +```cpp +Double_t ThetaMinCMS = thetaCms; +Double_t ThetaMaxCMS = thetaCms; // fixed angle: min == max +``` + +**B-field (Geant4):** `AtConstField` with (0, 0, 20) kG (= 2 T); region covers full detector. + +**B-field (SimpleSim):** `sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0))` + +**Output:** `./data/geant4_fixed.root` / `./data/simpleSim_fixed.root` + +--- + +## Macro 3 & 4: Kinematic Curve (`geant4_kinematic.C` / `simpleSim_kinematic.C`) + +**Signature:** `(Int_t nEvents = 1000, UInt_t seed = 42)` + +**AtTPC2Body:** `ThetaMinCMS = 0`, `ThetaMaxCMS = 180` (full range, cos-theta uniform). + +Everything else identical to macros 1 & 2. + +**Output:** `./data/geant4_kinematic.root` / `./data/simpleSim_kinematic.root` + +--- + +## Comparison Macro 1: `compareFixed.C` + +Reads `geant4_fixed.root` + `simpleSim_fixed.root`. + +**Plots:** + +| # | Title | Description | +|---|-------|-------------| +| 1 | 3D track overlay | `TGraph2D` of (x, y, z) for each track; Geant4 in blue, SimpleSim in red; separate panels for proton and He-4 products; should visually coincide | +| 2 | XY projection | `TH2D` of all hit positions; circles expected from helical projection | +| 3 | XZ projection | Shows helical pitch | +| 4 | Bragg curve — proton | mean dE/dx [MeV/mm] vs Z [mm] from both simulations | +| 5 | Bragg curve — He-4 | same for recoil He-4 | +| 6 | Track length | distribution of total path length per track | + +For the 3D overlay (plot 1): iterate over events, group MCPoints by `GetTrackID()`, draw a `TPolyLine3D` for each product track. Up to 20 events overlaid. + +--- + +## Comparison Macro 2: `compareKinematic.C` + +Reads `geant4_kinematic.root` + `simpleSim_kinematic.root`. + +**Plots:** + +| # | Title | Description | +|---|-------|-------------| +| 1 | Kinematic locus | p_t [MeV/c] vs p_z [MeV/c] for the proton product; Geant4 vs SimpleSim should trace the same curve | +| 2 | Kinematic locus | same for He-4 recoil | +| 3 | Bragg curve — proton | mean dE/dx vs Z, all events averaged | +| 4 | Bragg curve — He-4 | same | +| 5 | Range distributions | stopping Z for each product, both simulations | +| 6 | XY projection | all hits, full kinematic range | + +Kinematic locus: use first MCPoint of each track as the initial momentum. In the limit of no energy loss at vertex, both simulations should produce the same two-body kinematic curve. + +--- + +## Energy-Loss Model for SimpleSim + +```cpp +// He gas at 1 bar +constexpr double He_density = 1.664e-4; // g/cm³ at 20°C + +// Proton in He +auto eloss_p = std::make_shared( + He_density, + std::vector>{{4, 2, 1}} // He-4: {A, Z, stoich} +); +eloss_p->SetProjectile(1, 1, 1.007276); // proton: A, Z, mass_amu +sim->AddModel(1, 1, eloss_p); + +// He-4 in He +auto eloss_he = std::make_shared( + He_density, + std::vector>{{4, 2, 1}} +); +eloss_he->SetProjectile(4, 2, 4.002602); // He-4: A, Z, mass_amu +sim->AddModel(2, 4, eloss_he); +``` + +--- + +## Geant4 Fix (present in both Geant4 macros) + +The crash from the previous `runGeant4_proton.C` was caused by calling `AtVertexPropagator::Instance()` before `FairRunSim` was constructed. Fix: **remove the explicit `AtVertexPropagator::Instance()` call entirely** (follow `C16_pp_sim.C` — `AtTPCIonGenerator` calls it internally). `SetDoReaction(kTRUE)` is used (default) so the reaction generator can fire. + +--- + +## Verification + +1. **Rename folder**, remove old files, create new ones +2. Build: no code changes — all classes already compiled +3. **Run fixed-angle pair:** + ```bash + cd macro/Simulation/AtSimValidation + source ../../../build/config.sh + root -l -q 'geant4_fixed.C(45., 100)' + root -l -q 'simpleSim_fixed.C(45., 100)' + root -l -q 'compareFixed.C' + ``` +4. **Run kinematic pair:** + ```bash + root -l -q 'geant4_kinematic.C(1000)' + root -l -q 'simpleSim_kinematic.C(1000)' + root -l -q 'compareKinematic.C' + ``` +5. **Pass criteria:** + - 3D track overlays from fixed-angle run show Geant4 (blue) and SimpleSim (red) helices coinciding within the expected energy-straggling spread + - Kinematic locus from both simulations traces the same two-body kinematic curve + - Bragg curves from both methods agree in peak position ± ~1 cm + +--- + +## Key Files + +| File | Role | +|------|------| +| `macro/Simulation/AtSimValidation/geant4_fixed.C` | new | +| `macro/Simulation/AtSimValidation/simpleSim_fixed.C` | new | +| `macro/Simulation/AtSimValidation/geant4_kinematic.C` | new | +| `macro/Simulation/AtSimValidation/simpleSim_kinematic.C` | new | +| `macro/Simulation/AtSimValidation/compareFixed.C` | new | +| `macro/Simulation/AtSimValidation/compareKinematic.C` | new | +| `macro/Simulation/ATTPC/16C_pp/C16_pp_sim.C` | canonical generator pattern | +| `AtGenerators/AtTPC2Body.h/.cxx` | reaction generator | +| `AtGenerators/AtTPCIonGenerator.h/.cxx` | beam generator | +| `AtDigitization/AtTestSimulation.cxx` | SimpleSim FairTask wrapper | +| `AtTools/AtELossCATIMA.h` | energy-loss model | diff --git a/macro/Simulation/compareSimVsGeant.C b/macro/Simulation/compareSimVsGeant.C deleted file mode 100644 index 8bc6f4e30..000000000 --- a/macro/Simulation/compareSimVsGeant.C +++ /dev/null @@ -1,252 +0,0 @@ -/** - * compareSimVsGeant.C - * - * Overlay AtTestSimulation (SimpleSim) and Geant4 simulation output on the - * same plots for visual comparison. Both produce "AtTpcPoint" branches - * (TClonesArray of AtMCPoint / FairMCPoint subclass) in a "cbmsim" TTree. - * - * Comparison plots produced (saved to ./data/compareSimVsGeant.pdf): - * 1. Bragg curve: mean dE/dx [MeV/mm] vs Z position [mm]. - * 2. Track-length distribution [mm]. - * 3. Total energy loss per track [MeV]. - * 4. XY hit projection. - * 5. Z-position distribution of all hits. - * - * dE/dx is computed as eLoss_step / step_length, where step_length is the - * 3-D distance between consecutive hits on the same track. This avoids the - * bias that arises when using GetLength() (cumulative from track origin), - * which for Geant4 includes path outside the active volume. - * - * Usage: - * source build/config.sh - * root -l -q 'macro/Simulation/compareSimVsGeant.C("geant.root","simple.root")' - * - * Arguments: - * geantFile — output ROOT file from a FairRunSim Geant4 macro - * simpleFile — output ROOT file from simpleSim_Bfield.C - * branchName — MCPoint branch name (default "AtTpcPoint") - * nEventsMax — max events to read per file (0 = all) - */ - -#include -#include -#include -#include -#include - -// --------------------------------------------------------------------------- -struct SimData { - TH1D *hTrackLength; - TH1D *hTotalELoss; - TH1D *hHitZ; - TH2D *hXY; - TProfile *hBragg; - int nEvents{0}; - int nHits{0}; -}; - -SimData FillHistograms(const TString &fileName, const TString &branchName, int nEventsMax, - const TString &suffix) -{ - SimData d; - d.hTrackLength = new TH1D("hLen_" + suffix, ";Track length [mm];Entries", 150, 0, 1100); - d.hTotalELoss = new TH1D("hELoss_" + suffix, ";Total #DeltaE [MeV];Entries", 150, 0, 15); - d.hHitZ = new TH1D("hZ_" + suffix, ";Z [mm];Hits", 100, 0, 1100); - d.hXY = new TH2D("hXY_" + suffix, ";X [mm];Y [mm]", 100, -300, 300, 100, -300, 300); - d.hBragg = new TProfile("hBragg_" + suffix, ";Z [mm];dE/dx [MeV/mm]", 100, 0, 1100); - - TFile *f = TFile::Open(fileName); - if (!f || f->IsZombie()) { - std::cerr << "WARNING: cannot open " << fileName << " — skipping.\n"; - return d; - } - - TTree *tree = dynamic_cast(f->Get("cbmsim")); - if (!tree) { - std::cerr << "WARNING: no 'cbmsim' tree in " << fileName << " — skipping.\n"; - f->Close(); - return d; - } - - TClonesArray *pointArray = nullptr; - tree->SetBranchAddress(branchName, &pointArray); - - int nEvents = (nEventsMax > 0 && nEventsMax < (int)tree->GetEntriesFast()) - ? nEventsMax - : (int)tree->GetEntriesFast(); - - for (int iEv = 0; iEv < nEvents; ++iEv) { - tree->GetEntry(iEv); - if (!pointArray || pointArray->GetEntriesFast() == 0) - continue; - - // Per-track accumulators (reset each event) - std::map trackELoss; - std::map trackLastLen; - - // Previous hit position per track (for step-length calculation) - std::map prevX, prevY, prevZ; - - int nPts = pointArray->GetEntriesFast(); - for (int i = 0; i < nPts; ++i) { - auto *pt = dynamic_cast(pointArray->At(i)); - if (!pt) - continue; - - double x_mm = pt->GetX() * 10.; // cm → mm - double y_mm = pt->GetY() * 10.; - double z_mm = pt->GetZ() * 10.; - double eLoss = pt->GetEnergyLoss() * 1000.; // GeV → MeV - double len_mm = pt->GetLength() * 10.; // cm → mm - int tid = pt->GetTrackID(); - - d.hHitZ->Fill(z_mm); - d.hXY->Fill(x_mm, y_mm); - - // --- Bragg curve: dE/dx from 3-D step between consecutive hits ---- - // Using position differences avoids the bias from GetLength() which - // counts path outside the active volume for Geant4 tracks. - if (prevZ.count(tid)) { - double dx = x_mm - prevX[tid]; - double dy = y_mm - prevY[tid]; - double dz = z_mm - prevZ[tid]; - double step = std::sqrt(dx * dx + dy * dy + dz * dz); - if (step > 0.01 && eLoss > 0) // guard: skip zero-length or zero-loss steps - d.hBragg->Fill(z_mm, eLoss / step); - } - prevX[tid] = x_mm; - prevY[tid] = y_mm; - prevZ[tid] = z_mm; - - trackELoss[tid] += eLoss; - trackLastLen[tid] = len_mm; - d.nHits++; - } - - // Per-track summary histograms - for (auto &[tid, eLoss] : trackELoss) { - d.hTotalELoss->Fill(eLoss); - d.hTrackLength->Fill(trackLastLen[tid]); - } - d.nEvents++; - } - - f->Close(); - std::cout << suffix << ": " << d.nEvents << " events, " << d.nHits << " hits\n"; - return d; -} - -// --------------------------------------------------------------------------- -void compareSimVsGeant(TString geantFile = "./data/geant_output.root", - TString simpleFile = "./data/simpleSim_Bfield.root", - TString branchName = "AtTpcPoint", - int nEventsMax = 0) -{ - gStyle->SetOptStat(0); - gStyle->SetOptTitle(1); - - SimData geant = FillHistograms(geantFile, branchName, nEventsMax, "G4"); - SimData simple = FillHistograms(simpleFile, branchName, nEventsMax, "Sim"); - - if (geant.nHits == 0 && simple.nHits == 0) { - std::cerr << "ERROR: no hits in either file. Check paths and branch name.\n"; - return; - } - - // ---- Style ----------------------------------------------------------- - auto styleG4 = [](TH1 *h, bool fill = false) { - h->SetLineColor(kBlue + 1); - h->SetLineWidth(2); - if (fill) { h->SetFillColorAlpha(kBlue + 1, 0.2); h->SetFillStyle(1001); } - }; - auto styleSim = [](TH1 *h, bool fill = false) { - h->SetLineColor(kRed + 1); - h->SetLineWidth(2); - h->SetLineStyle(2); - if (fill) { h->SetFillColorAlpha(kRed + 1, 0.2); h->SetFillStyle(1001); } - }; - - styleG4(geant.hTrackLength); styleG4(geant.hTotalELoss); - styleG4(geant.hHitZ); styleG4(geant.hBragg); - styleSim(simple.hTrackLength); styleSim(simple.hTotalELoss); - styleSim(simple.hHitZ); styleSim(simple.hBragg); - - // Normalize 1-D histograms to unit area so shapes compare - // regardless of event count. Skip empty histograms. - auto normalize = [](TH1 *h) { - if (h->Integral() > 0) h->Scale(1.0 / h->Integral()); - }; - if (geant.nEvents > 0) { normalize(geant.hTrackLength); normalize(geant.hTotalELoss); normalize(geant.hHitZ); } - if (simple.nEvents > 0) { normalize(simple.hTrackLength); normalize(simple.hTotalELoss); normalize(simple.hHitZ); } - - // ---- Canvas ---------------------------------------------------------- - TCanvas *c = new TCanvas("cCompare", "Geant4 vs SimpleSim", 1400, 900); - c->Divide(3, 2); - - auto addLegend = [&](TVirtualPad *pad) { - pad->cd(); - auto *leg = new TLegend(0.55, 0.72, 0.92, 0.88); - leg->SetBorderSize(0); - if (geant.nHits > 0) leg->AddEntry(geant.hBragg, "Geant4", "l"); - if (simple.nHits > 0) leg->AddEntry(simple.hBragg, "SimpleSim","l"); - leg->Draw(); - }; - - auto drawPair = [](TVirtualPad *p, TH1 *hG4, TH1 *hSim, bool profile = false) { - p->cd(); p->SetLeftMargin(0.15); - bool haveG4 = hG4 && hG4->GetEntries() > 0; - bool haveSim = hSim && hSim->GetEntries() > 0; - double ymax = 0; - if (haveG4) ymax = std::max(ymax, hG4->GetMaximum()); - if (haveSim) ymax = std::max(ymax, hSim->GetMaximum()); - if (ymax == 0) ymax = 1; - TH1 *first = haveG4 ? hG4 : hSim; - if (!first) return; - first->SetMaximum(ymax * 1.25); - first->Draw(profile ? "hist" : "hist"); - if (haveG4 && hG4 != first) hG4->Draw("hist same"); - if (haveSim && hSim != first) hSim->Draw("hist same"); - }; - - // 1 — Bragg curve - c->cd(1); gPad->SetLeftMargin(0.15); - { - bool haveG4 = geant.hBragg->GetEntries() > 0; - bool haveSim = simple.hBragg->GetEntries() > 0; - double ymax = std::max(haveG4 ? geant.hBragg->GetMaximum() : 0., - haveSim ? simple.hBragg->GetMaximum() : 0.); - if (ymax == 0) ymax = 1; - TH1 *first = haveG4 ? (TH1*)geant.hBragg : (TH1*)simple.hBragg; - first->SetMaximum(ymax * 1.25); - first->Draw(); - if (haveG4 && (TH1*)geant.hBragg != first) geant.hBragg->Draw("same"); - if (haveSim && (TH1*)simple.hBragg != first) simple.hBragg->Draw("same"); - addLegend(gPad); - } - - // 2 — Track length - drawPair(c->cd(2), geant.hTrackLength, simple.hTrackLength); - addLegend(c->cd(2)); - - // 3 — Total energy loss - drawPair(c->cd(3), geant.hTotalELoss, simple.hTotalELoss); - addLegend(c->cd(3)); - - // 4 — XY (Geant4 or SimpleSim if no Geant4) - c->cd(4); gPad->SetLeftMargin(0.15); - (geant.nHits > 0 ? geant.hXY : simple.hXY)->Draw("colz"); - if (geant.nHits > 0) geant.hXY->SetTitle("Geant4 XY hits"); - else simple.hXY->SetTitle("SimpleSim XY hits"); - - // 5 — XY (SimpleSim) - c->cd(5); gPad->SetLeftMargin(0.15); - simple.hXY->SetTitle("SimpleSim XY hits"); - simple.hXY->Draw("colz"); - - // 6 — Z hit distribution - drawPair(c->cd(6), geant.hHitZ, simple.hHitZ); - addLegend(c->cd(6)); - - c->SaveAs("./data/compareSimVsGeant.pdf"); - std::cout << "Saved ./data/compareSimVsGeant.pdf\n"; -} diff --git a/macro/Simulation/simpleSim_Bfield.C b/macro/Simulation/simpleSim_Bfield.C deleted file mode 100644 index dda386d4e..000000000 --- a/macro/Simulation/simpleSim_Bfield.C +++ /dev/null @@ -1,89 +0,0 @@ -/** - * simpleSim_Bfield.C - * - * Standalone AT-TPC simulation using AtTestSimulation (no Geant4). - * Fires 1 MeV protons into a 2 T solenoid field along Z. - * - * Expected physics: - * A 1 MeV proton (range ~200 mm in H gas at the table density) stops within the - * drift volume, producing a visible Bragg peak. p⊥ = 0 (parallel to B) so the - * track is straight. To see Larmor curvature set a non-zero theta or use a - * reaction generator. - * - * Energy loss table: HinH.txt (H in H gas, SRIM format with density header). - * proton_He_700torr.txt is available but lacks the SRIM density/conversion - * header required by AtELossTable; use HinH.txt as stand-in until a - * properly-formatted He table is generated. - * - * Output: ./data/simpleSim_Bfield.root - * Tree "cbmsim", branch "AtTpcPoint" (TClonesArray of AtMCPoint). - * Open with ROOT browser or compareSimVsGeant.C. - * - * Usage: - * source build/config.sh - * root -l -q 'macro/Simulation/simpleSim_Bfield.C(100)' - */ - -#include -#include - -void simpleSim_Bfield(Int_t nEvents = 100) -{ - TString dir = getenv("VMCWORKDIR"); - if (dir.IsNull()) { - std::cerr << "ERROR: VMCWORKDIR not set. Run 'source build/config.sh' first.\n"; - return; - } - - TString geoFile = dir + "/geometry/ATTPC_He1bar_geomanager.root"; - TString outputFile = "./data/simpleSim_Bfield.root"; - TString elossFile = dir + "/resources/energy_loss/HinH.txt"; - - // ---- FairRunAna (no Geant4) ------------------------------------------ - FairRunAna *run = new FairRunAna(); - run->SetOutputFile(outputFile); - - // ---- Build the simulation -------------------------------------------- - auto sim = std::make_unique(geoFile.Data()); - - // Proton energy-loss in He gas (SRIM table, H in H approximation for He) - auto eloss = std::make_shared(); - eloss->LoadSrimTable(elossFile.Data()); - sim->AddModel(1, 1, eloss); - - // 2 T solenoidal field along Z (beam axis). - // Particles with transverse momentum will spiral; a purely longitudinal beam - // proton travels straight. Use a reaction generator for curved tracks. - sim->SetMagneticField({0., 0., 2.0}); // T - - // ---- FairTask wrapper ------------------------------------------------ - auto *simTask = new AtTestSimulation(std::move(sim)); - - // ---- Generator: 1 MeV proton along Z ------------------------------------ - // FairBoxGenerator works with FairRunAna (no Geant4/FairRunSim required). - // PDG 2212 = proton. Momentum is given in GeV/c. - // KE = 1 MeV proton: E = 938.272 + 1 = 939.272 MeV - // p = sqrt(939.272² - 938.272²) ≈ 43.33 MeV/c = 0.04333 GeV/c - // Range ≈ 203 mm (from HinH.txt) — stops well within the 1000 mm drift volume. - auto *primGen = new FairPrimaryGenerator(); - auto *boxGen = new FairBoxGenerator(2212 /*proton PDG*/, 1 /*multiplicity*/); - boxGen->SetPRange(0.04333, 0.04333); // fixed |p| in GeV/c - boxGen->SetPhiRange(0., 0.); // phi = 0 → momentum in XZ plane - boxGen->SetThetaRange(0., 0.); // theta = 0 → along +Z - // drift_volume is a tube at (0, 6.079, 50) cm with r=25 cm, half-length=50 cm → z: 0–100 cm - boxGen->SetXYZ(0., 6.079, 1.); // start 1 cm inside the window at beam axis - primGen->AddGenerator(boxGen); - simTask->SetPrimaryGenerator(primGen); - - run->AddTask(simTask); - - // ---- Run --------------------------------------------------------------- - TStopwatch timer; - run->Init(); - timer.Start(); - run->Run(0, nEvents); - timer.Stop(); - - std::cout << "\nMacro finished. Output: " << outputFile << "\n"; - std::cout << "Real time: " << timer.RealTime() << " s, CPU time: " << timer.CpuTime() << " s\n"; -} From 7758cb0d9634f3fee852223da9018421f5f1474a Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 12:20:25 -0400 Subject: [PATCH 06/18] Get grant macros working for sim comp --- .gitignore | 1 + AGENTS.md | 2 + docs/reference/macro-cookbook.md | 3 + docs/subsystems/generators.md | 6 + .../AtSimValidation/AtSimValidationPlan.md | 13 +- .../Simulation/AtSimValidation/compareFixed.C | 279 ++++++++++++++++ .../AtSimValidation/compareKinematic.C | 269 +++++++++++++++ .../Simulation/AtSimValidation/geant4_fixed.C | 104 ++++++ .../AtSimValidation/geant4_kinematic.C | 98 ++++++ .../AtSimValidation/simpleSim_fixed.C | 177 ++++++++++ .../AtSimValidation/simpleSim_kinematic.C | 202 ++++++++++++ .../AtSimValidation/visualizeKinematic.C | 306 ++++++++++++++++++ 12 files changed, 1456 insertions(+), 4 deletions(-) create mode 100644 macro/Simulation/AtSimValidation/compareFixed.C create mode 100644 macro/Simulation/AtSimValidation/compareKinematic.C create mode 100644 macro/Simulation/AtSimValidation/geant4_fixed.C create mode 100644 macro/Simulation/AtSimValidation/geant4_kinematic.C create mode 100644 macro/Simulation/AtSimValidation/simpleSim_fixed.C create mode 100644 macro/Simulation/AtSimValidation/simpleSim_kinematic.C create mode 100644 macro/Simulation/AtSimValidation/visualizeKinematic.C diff --git a/.gitignore b/.gitignore index 081c538ef..b9d05db67 100755 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ data *#* event.dat pulser-files.txt +.codex # Executables *.exe diff --git a/AGENTS.md b/AGENTS.md index 2d78668c4..908c14329 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ 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 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/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/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md index ba34e9f63..ab5c0536e 100644 --- a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -10,7 +10,7 @@ The macros live in `macro/Simulation/protonBragg/` (to be **renamed** to `macro/ `AtTPCIonGenerator` alone produces identical-momentum particles every event with no vertex variation — no statistical content. The simulation pipeline alternates beam events (even) and reaction events (odd). On beam events `AtTPCIonGenerator` fires; on reaction events `AtTPC2Body` fires. Without a reaction generator registered, reaction-phase events corrupt `AtVertexPropagator` state and can crash VMC initialisation. **Why two macros per scenario:** -Geant4 uses `FairRunSim`; `AtSimpleSimulation` uses `FairRunAna` + `AtTestSimulation`. They cannot share a run. Each scenario therefore has a Geant4 macro and a mirrored SimpleSim macro that uses identical generator parameters and the same `gRandom` seed, so both process the same sequence of random CMS angles and beam-depth variations. +Geant4 transport and `AtSimpleSimulation` validation remain separate macros, but each macro must use a single FairRoot run type from start to finish. The revised contract is: both the Geant4 and SimpleSim validation macros are `FairRunSim` macros. The Geant4 macros use the normal FairRoot transport generator. The SimpleSim macros attach `AtTestSimulation` as a simulation task and use a no-op primary generator only to drive the FairRoot event loop; the actual elastic-scatter event generation for `AtSimpleSimulation` is owned by `AtTestSimulation` through its own `FairPrimaryGenerator`. This avoids mixing `FairRunAna` and `FairRunSim` in one macro and preserves FairRoot singleton contracts. --- @@ -71,10 +71,10 @@ ResEner = 1.0 // MeV, nominal beam energy `AtTPCIonGenerator` fires on beam events and uses `AtVertexPropagator` to record the beam stopping depth (the reaction vertex). `AtTPC2Body` fires on reaction events, reads the vertex and residual beam momentum, and adds the two products. -For SimpleSim, `AtTestSimulation::Exec()` calls `fPrimGen->GenerateEvent(&fCollector)` every event. The same even/odd alternation applies because `AtVertexPropagator` is a singleton: beam events deposit the beam track into the collector and update the vertex; reaction events deposit the two reaction products. `AtSimpleSimulation` propagates whichever particles appear in the collector each event. +For SimpleSim, `AtTestSimulation::Exec()` calls its private `FairPrimaryGenerator` every event. The same even/odd alternation applies because `AtVertexPropagator` is a singleton shared by the beam and reaction generators: beam events deposit the beam track into the collector and update the vertex; reaction events deposit the two reaction products. `AtSimpleSimulation` propagates whichever particles appear in the collector each event. **Same ground truth / seed:** -Both the Geant4 macro and the mirrored SimpleSim macro call `gRandom->SetSeed(42)` before `run->Init()`. This produces the same sequence of: +Both the Geant4 macro and the mirrored SimpleSim macro call `gRandom->SetSeed(42)` before `run->Init()`. In the SimpleSim macros the run-level primary generator is intentionally a no-op, so the random sequence is consumed only by the mirrored elastic-scatter generator attached to `AtTestSimulation`. This preserves the same sequence of: - random CMS angles (from AtTPC2Body's uniform cos-theta sampling) - `fRndELoss` values that determine beam stopping depth (from AtTPCIonGenerator) @@ -96,6 +96,11 @@ Double_t ThetaMaxCMS = thetaCms; // fixed angle: min == max **B-field (SimpleSim):** `sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0))` +**Run contract (SimpleSim):** +- `FairRunSim` only +- `run->SetGenerator(noOpPrimGen)` to satisfy the simulation event loop without Geant transport tracks +- `run->AddTask(simTask)` where `simTask` owns the mirrored elastic-scatter `FairPrimaryGenerator` + **Output:** `./data/geant4_fixed.root` / `./data/simpleSim_fixed.root` --- @@ -184,7 +189,7 @@ The crash from the previous `runGeant4_proton.C` was caused by calling `AtVertex ## Verification 1. **Rename folder**, remove old files, create new ones -2. Build: no code changes — all classes already compiled +2. Build if framework code changed; macro-only edits can be run directly after sourcing `build/config.sh` 3. **Run fixed-angle pair:** ```bash cd macro/Simulation/AtSimValidation diff --git a/macro/Simulation/AtSimValidation/compareFixed.C b/macro/Simulation/AtSimValidation/compareFixed.C new file mode 100644 index 000000000..2e58474ef --- /dev/null +++ b/macro/Simulation/AtSimValidation/compareFixed.C @@ -0,0 +1,279 @@ + +namespace { +struct PointSample { + double x{}; + double y{}; + double z{}; + double px{}; + double py{}; + double pz{}; + double eLoss{}; + double length{}; +}; + +struct TrackData { + int trackID{}; + std::vector points; +}; + +struct EventTracks { + TrackData proton; + TrackData helium; +}; + +struct PlotData { + std::vector events; + TProfile *protonBragg{nullptr}; + TProfile *heliumBragg{nullptr}; + TH1D *trackLength{nullptr}; + std::vector> xy; + std::vector> xz; +}; + +bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } + +TrackData BuildTrackData(int trackID, TClonesArray *points) +{ + TrackData out; + out.trackID = trackID; + 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 IsReactionEvent(const std::vector &trackIDs) { return trackIDs.size() >= 2; } + +PlotData LoadPlotData(const TString &fileName, const char *tag) +{ + PlotData out; + out.protonBragg = new TProfile(Form("hProtonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.heliumBragg = new TProfile(Form("hHeliumBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.trackLength = new TH1D(Form("hTrackLength_%s", tag), ";Track length [mm];Tracks", 120, 0., 500.); + + auto *file = TFile::Open(fileName); + if (!file || file->IsZombie()) { + std::cerr << "Cannot open " << fileName << "\n"; + return out; + } + + auto *tree = dynamic_cast(file->Get("cbmsim")); + if (!tree) { + std::cerr << "Missing cbmsim tree in " << fileName << "\n"; + file->Close(); + return out; + } + + TClonesArray *pointArray = nullptr; + tree->SetBranchAddress("AtTpcPoint", &pointArray); + + for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { + tree->GetEntry(iEvent); + if (!pointArray || pointArray->GetEntriesFast() == 0) + continue; + + std::vector trackIDs; + std::map seenTrack; + for (int i = 0; i < pointArray->GetEntriesFast(); ++i) { + auto *pt = dynamic_cast(pointArray->At(i)); + if (!pt) + continue; + if (!seenTrack[pt->GetTrackID()]) { + seenTrack[pt->GetTrackID()] = true; + trackIDs.push_back(pt->GetTrackID()); + } + } + + std::sort(trackIDs.begin(), trackIDs.end()); + if (!IsReactionEvent(trackIDs)) + continue; + + EventTracks event; + event.proton = BuildTrackData(trackIDs.front(), pointArray); + event.helium = BuildTrackData(trackIDs.back(), pointArray); + out.events.push_back(event); + + for (const auto *track : {&event.proton, &event.helium}) { + if (track->points.empty()) + continue; + + out.trackLength->Fill(track->points.back().length); + for (const auto &point : track->points) { + out.xy.emplace_back(point.x, point.y); + out.xz.emplace_back(point.z, point.x); + } + + auto *profile = (track == &event.proton) ? out.protonBragg : out.heliumBragg; + for (size_t i = 1; i < track->points.size(); ++i) { + const auto &prev = track->points[i - 1]; + const auto &curr = track->points[i]; + double dx = curr.x - prev.x; + double dy = curr.y - prev.y; + double dz = curr.z - prev.z; + double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step > 1e-6 && curr.eLoss > 0.) + profile->Fill(curr.z, curr.eLoss / step); + } + } + } + + file->Close(); + return out; +} + +void StyleProfile(TProfile *hist, Color_t color, Style_t style) +{ + hist->SetLineColor(color); + hist->SetLineWidth(2); + hist->SetLineStyle(style); +} + +void StyleTrackLength(TH1D *hist, Color_t color, Style_t style) +{ + hist->SetLineColor(color); + hist->SetLineWidth(2); + hist->SetLineStyle(style); +} + +TGraph *MakeProjectionGraph(const std::vector> &points, const char *name, Color_t color) +{ + auto *graph = new TGraph(points.size()); + graph->SetName(name); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.45); + graph->SetMarkerColor(color); + graph->SetLineColor(color); + for (size_t i = 0; i < points.size(); ++i) + graph->SetPoint(i, points[i].first, points[i].second); + return graph; +} + +void DrawTrackOverlay(const PlotData &data, bool drawProton, Color_t color) +{ + int count = 0; + for (const auto &event : data.events) { + const auto &track = drawProton ? event.proton : event.helium; + if (track.points.empty()) + continue; + auto *line = new TPolyLine3D(track.points.size()); + line->SetLineColor(color); + line->SetLineWidth(2); + 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); + if (count == 0) { + line->Draw(); + } else { + line->Draw("same"); + } + if (++count >= 20) + break; + } +} +} // namespace + +void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simpleFile = "./data/simpleSim_fixed.root") +{ + gStyle->SetOptStat(0); + auto geant = LoadPlotData(geantFile, "g4"); + auto simple = LoadPlotData(simpleFile, "sim"); + + StyleProfile(geant.protonBragg, kBlue + 1, 1); + StyleProfile(simple.protonBragg, kRed + 1, 2); + StyleProfile(geant.heliumBragg, kBlue + 1, 1); + StyleProfile(simple.heliumBragg, kRed + 1, 2); + StyleTrackLength(geant.trackLength, kBlue + 1, 1); + StyleTrackLength(simple.trackLength, kRed + 1, 2); + + auto *xyG4 = MakeProjectionGraph(geant.xy, "xyG4", kBlue + 1); + auto *xySim = MakeProjectionGraph(simple.xy, "xySim", kRed + 1); + auto *xzG4 = MakeProjectionGraph(geant.xz, "xzG4", kBlue + 1); + auto *xzSim = MakeProjectionGraph(simple.xz, "xzSim", kRed + 1); + + auto *canvas = new TCanvas("cFixedCompare", "AtSimpleSimulation fixed-angle validation", 1800, 1000); + canvas->Divide(4, 2); + + canvas->cd(1); + gPad->SetTheta(20); + gPad->SetPhi(35); + DrawTrackOverlay(geant, true, kBlue + 1); + DrawTrackOverlay(simple, true, kRed + 1); + { + auto *legend = new TLegend(0.62, 0.78, 0.9, 0.9); + legend->AddEntry((TObject *)nullptr, "Proton 3D overlay", ""); + legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); + legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); + legend->Draw(); + } + + canvas->cd(2); + gPad->SetTheta(20); + gPad->SetPhi(35); + DrawTrackOverlay(geant, false, kBlue + 1); + DrawTrackOverlay(simple, false, kRed + 1); + { + auto *legend = new TLegend(0.62, 0.78, 0.9, 0.9); + legend->AddEntry((TObject *)nullptr, "He-4 3D overlay", ""); + legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); + legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); + legend->Draw(); + } + + canvas->cd(3); + auto *xy = new TMultiGraph(); + xy->SetTitle("XY projection;X [mm];Y [mm]"); + xy->Add(xyG4, "P"); + xy->Add(xySim, "P"); + xy->Draw("A"); + auto *xyLegend = new TLegend(0.65, 0.78, 0.9, 0.9); + xyLegend->AddEntry(xyG4, "Geant4", "p"); + xyLegend->AddEntry(xySim, "SimpleSim", "p"); + xyLegend->Draw(); + + canvas->cd(4); + auto *xz = new TMultiGraph(); + xz->SetTitle("XZ projection;Z [mm];X [mm]"); + xz->Add(xzG4, "P"); + xz->Add(xzSim, "P"); + xz->Draw("A"); + auto *xzLegend = new TLegend(0.65, 0.78, 0.9, 0.9); + xzLegend->AddEntry(xzG4, "Geant4", "p"); + xzLegend->AddEntry(xzSim, "SimpleSim", "p"); + xzLegend->Draw(); + + canvas->cd(5); + geant.protonBragg->SetTitle("Bragg curve: proton"); + geant.protonBragg->Draw("hist"); + simple.protonBragg->Draw("hist same"); + auto *protonLegend = new TLegend(0.6, 0.78, 0.9, 0.9); + protonLegend->AddEntry(geant.protonBragg, "Geant4", "l"); + protonLegend->AddEntry(simple.protonBragg, "SimpleSim", "l"); + protonLegend->Draw(); + + canvas->cd(6); + geant.heliumBragg->SetTitle("Bragg curve: He-4"); + geant.heliumBragg->Draw("hist"); + simple.heliumBragg->Draw("hist same"); + auto *heliumLegend = new TLegend(0.6, 0.78, 0.9, 0.9); + heliumLegend->AddEntry(geant.heliumBragg, "Geant4 Bragg", "l"); + heliumLegend->AddEntry(simple.heliumBragg, "SimpleSim Bragg", "l"); + heliumLegend->Draw(); + + canvas->cd(7); + geant.trackLength->SetTitle("Track length distribution"); + geant.trackLength->Draw("hist"); + simple.trackLength->Draw("hist same"); + auto *trackLegend = new TLegend(0.6, 0.78, 0.9, 0.9); + trackLegend->AddEntry(geant.trackLength, "Geant4", "l"); + trackLegend->AddEntry(simple.trackLength, "SimpleSim", "l"); + trackLegend->Draw(); + + 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..9a8c60aab --- /dev/null +++ b/macro/Simulation/AtSimValidation/compareKinematic.C @@ -0,0 +1,269 @@ +#include +#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 px{}; + double py{}; + double pz{}; + double eLoss{}; + double length{}; +}; + +struct TrackData { + std::vector points; +}; + +struct PlotData { + std::vector> protonKinematics; + std::vector> heliumKinematics; + std::vector> xy; + TProfile *protonBragg{nullptr}; + TProfile *heliumBragg{nullptr}; + TH1D *protonRange{nullptr}; + TH1D *heliumRange{nullptr}; +}; + +bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } + +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; +} + +void FillBragg(TProfile *profile, const TrackData &track) +{ + for (size_t i = 1; i < track.points.size(); ++i) { + const auto &prev = track.points[i - 1]; + const auto &curr = track.points[i]; + double dx = curr.x - prev.x; + double dy = curr.y - prev.y; + double dz = curr.z - prev.z; + double step = std::sqrt(dx * dx + dy * dy + dz * dz); + if (step > 1e-6 && curr.eLoss > 0.) + profile->Fill(curr.z, curr.eLoss / step); + } +} + +PlotData LoadPlotData(const TString &fileName, const char *tag) +{ + PlotData out; + out.protonBragg = new TProfile(Form("hProtonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.heliumBragg = new TProfile(Form("hHeliumBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.protonRange = new TH1D(Form("hProtonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); + out.heliumRange = new TH1D(Form("hHeliumRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); + + auto *file = TFile::Open(fileName); + if (!file || file->IsZombie()) { + std::cerr << "Cannot open " << fileName << "\n"; + return out; + } + + auto *tree = dynamic_cast(file->Get("cbmsim")); + if (!tree) { + std::cerr << "Missing cbmsim tree in " << fileName << "\n"; + file->Close(); + return out; + } + + TClonesArray *pointArray = nullptr; + tree->SetBranchAddress("AtTpcPoint", &pointArray); + + for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { + tree->GetEntry(iEvent); + if (!pointArray || pointArray->GetEntriesFast() == 0) + continue; + + std::vector trackIDs; + std::map seenTrack; + for (int i = 0; i < pointArray->GetEntriesFast(); ++i) { + auto *pt = dynamic_cast(pointArray->At(i)); + if (!pt) + continue; + if (!seenTrack[pt->GetTrackID()]) { + seenTrack[pt->GetTrackID()] = true; + trackIDs.push_back(pt->GetTrackID()); + } + } + + std::sort(trackIDs.begin(), trackIDs.end()); + if (trackIDs.size() < 2) + continue; + + auto proton = BuildTrackData(trackIDs.front(), pointArray); + auto helium = BuildTrackData(trackIDs.back(), pointArray); + if (proton.points.empty() || helium.points.empty()) + continue; + + const auto &protonFirst = proton.points.front(); + const auto &heliumFirst = helium.points.front(); + out.protonKinematics.emplace_back(std::sqrt(protonFirst.px * protonFirst.px + protonFirst.py * protonFirst.py), + protonFirst.pz); + out.heliumKinematics.emplace_back(std::sqrt(heliumFirst.px * heliumFirst.px + heliumFirst.py * heliumFirst.py), + heliumFirst.pz); + + out.protonRange->Fill(proton.points.back().z); + out.heliumRange->Fill(helium.points.back().z); + FillBragg(out.protonBragg, proton); + FillBragg(out.heliumBragg, helium); + + for (const auto &point : proton.points) + out.xy.emplace_back(point.x, point.y); + for (const auto &point : helium.points) + out.xy.emplace_back(point.x, point.y); + } + + file->Close(); + return out; +} + +void StyleProfile(TProfile *hist, Color_t color, Style_t style) +{ + hist->SetLineColor(color); + hist->SetLineWidth(2); + hist->SetLineStyle(style); +} + +void StyleRange(TH1D *hist, Color_t color, Style_t style) +{ + hist->SetLineColor(color); + hist->SetLineWidth(2); + hist->SetLineStyle(style); +} + +TGraph *MakeGraph(const std::vector> &points, const char *name, Color_t color) +{ + auto *graph = new TGraph(points.size()); + graph->SetName(name); + graph->SetMarkerStyle(20); + graph->SetMarkerSize(0.45); + graph->SetMarkerColor(color); + graph->SetLineColor(color); + for (size_t i = 0; i < points.size(); ++i) + graph->SetPoint(i, points[i].first, points[i].second); + return graph; +} +} // namespace + +void compareKinematic(TString geantFile = "./data/geant4_kinematic.root", + TString simpleFile = "./data/simpleSim_kinematic.root") +{ + gStyle->SetOptStat(0); + auto geant = LoadPlotData(geantFile, "g4"); + auto simple = LoadPlotData(simpleFile, "sim"); + + StyleProfile(geant.protonBragg, kBlue + 1, 1); + StyleProfile(simple.protonBragg, kRed + 1, 2); + StyleProfile(geant.heliumBragg, kBlue + 1, 1); + StyleProfile(simple.heliumBragg, kRed + 1, 2); + StyleRange(geant.protonRange, kBlue + 1, 1); + StyleRange(simple.protonRange, kRed + 1, 2); + StyleRange(geant.heliumRange, kBlue + 1, 3); + StyleRange(simple.heliumRange, kRed + 1, 4); + + auto *protonG4 = MakeGraph(geant.protonKinematics, "protonG4", kBlue + 1); + auto *protonSim = MakeGraph(simple.protonKinematics, "protonSim", kRed + 1); + auto *heliumG4 = MakeGraph(geant.heliumKinematics, "heliumG4", kBlue + 1); + auto *heliumSim = MakeGraph(simple.heliumKinematics, "heliumSim", kRed + 1); + auto *xyG4 = MakeGraph(geant.xy, "xyG4K", kBlue + 1); + auto *xySim = MakeGraph(simple.xy, "xySimK", kRed + 1); + + auto *canvas = new TCanvas("cKinematicCompare", "AtSimpleSimulation kinematic validation", 1600, 900); + canvas->Divide(3, 2); + + canvas->cd(1); + auto *protonMg = new TMultiGraph(); + protonMg->SetTitle("Proton kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); + protonMg->Add(protonG4, "P"); + protonMg->Add(protonSim, "P"); + protonMg->Draw("A"); + auto *protonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + protonLegend->AddEntry(protonG4, "Geant4", "p"); + protonLegend->AddEntry(protonSim, "SimpleSim", "p"); + protonLegend->Draw(); + + canvas->cd(2); + auto *heliumMg = new TMultiGraph(); + heliumMg->SetTitle("He-4 kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); + heliumMg->Add(heliumG4, "P"); + heliumMg->Add(heliumSim, "P"); + heliumMg->Draw("A"); + auto *heliumLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + heliumLegend->AddEntry(heliumG4, "Geant4", "p"); + heliumLegend->AddEntry(heliumSim, "SimpleSim", "p"); + heliumLegend->Draw(); + + canvas->cd(3); + geant.protonBragg->SetTitle("Bragg curve: proton"); + geant.protonBragg->Draw("hist"); + simple.protonBragg->Draw("hist same"); + auto *braggProtonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + braggProtonLegend->AddEntry(geant.protonBragg, "Geant4", "l"); + braggProtonLegend->AddEntry(simple.protonBragg, "SimpleSim", "l"); + braggProtonLegend->Draw(); + + canvas->cd(4); + geant.heliumBragg->SetTitle("Bragg curve: He-4"); + geant.heliumBragg->Draw("hist"); + simple.heliumBragg->Draw("hist same"); + auto *braggHeliumLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + braggHeliumLegend->AddEntry(geant.heliumBragg, "Geant4", "l"); + braggHeliumLegend->AddEntry(simple.heliumBragg, "SimpleSim", "l"); + braggHeliumLegend->Draw(); + + canvas->cd(5); + geant.protonRange->SetTitle("Range distributions"); + geant.protonRange->Draw("hist"); + simple.protonRange->Draw("hist same"); + geant.heliumRange->Draw("hist same"); + simple.heliumRange->Draw("hist same"); + auto *rangeLegend = new TLegend(0.44, 0.64, 0.9, 0.9); + rangeLegend->AddEntry(geant.protonRange, "Geant4 proton", "l"); + rangeLegend->AddEntry(simple.protonRange, "SimpleSim proton", "l"); + rangeLegend->AddEntry(geant.heliumRange, "Geant4 He-4", "l"); + rangeLegend->AddEntry(simple.heliumRange, "SimpleSim He-4", "l"); + rangeLegend->Draw(); + + canvas->cd(6); + auto *xyMg = new TMultiGraph(); + xyMg->SetTitle("XY projection;X [mm];Y [mm]"); + xyMg->Add(xyG4, "P"); + xyMg->Add(xySim, "P"); + xyMg->Draw("A"); + auto *xyLegend = new TLegend(0.65, 0.78, 0.9, 0.9); + xyLegend->AddEntry(xyG4, "Geant4", "p"); + xyLegend->AddEntry(xySim, "SimpleSim", "p"); + xyLegend->Draw(); + + 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..b0eb201c1 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -0,0 +1,177 @@ + +namespace { +std::pair GetZAFromPDG(int pdg) +{ + if (pdg > 1000000000) { + int A = (pdg / 10) % 1000; + int Z = (pdg / 10000) % 1000; + return {Z, A}; + } + + auto *particle = TDatabasePDG::Instance()->GetParticle(pdg); + if (particle) { + 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}; +} + +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 1; + constexpr Int_t a = 1; + 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 = 0.04333; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 0.938272; + constexpr Double_t nominalEnergy = 1.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{1, 2, 1, 2}; + std::vector Ap{1, 4, 1, 4}; + 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{1.007276, 4.00260, 1.007276, 4.00260}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 1.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} + +void ConfigureHeLossModels(AtSimpleSimulation &sim) +{ + constexpr double heDensity = 1.664e-4; + std::vector> material{{4, 2, 1}}; + + auto protonModel = std::make_shared(heDensity, material); + protonModel->SetProjectile(1, 1, 1.007276); + sim.AddModel(1, 1, protonModel, 1.007276); + + auto heliumModel = std::make_shared(heDensity, material); + heliumModel->SetProjectile(4, 2, 4.002602); + sim.AddModel(2, 4, heliumModel, 4.002602); +} + +class SimpleSimTask : public FairTask { +public: + explicit SimpleSimTask(FairPrimaryGenerator *primGen) : FairTask("SimpleSimTask"), fPrimGen(primGen) {} + + InitStatus Init() override + { + fSimulation = std::make_unique(); + ConfigureHeLossModels(*fSimulation); + fSimulation->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + fSimulation->SetMaxPropagationStep(1e-3); + fSimulation->RegisterBranch(); + + if (fPrimGen) { + fMCHeader = std::make_unique(); + fPrimGen->SetEvent(fMCHeader.get()); + fPrimGen->Init(); + } + + return kSUCCESS; + } + + void Exec(Option_t *) override + { + fSimulation->NewEvent(); + if (!fPrimGen) + return; + + fCollector.Clear(); + fPrimGen->GenerateEvent(&fCollector); + + for (const auto &particle : fCollector.GetParticles()) { + auto [Z, A] = GetZAFromPDG(particle.pdgCode); + if (Z == 0 && A == 0) + continue; + + ROOT::Math::XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); + ROOT::Math::PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., + particle.e * 1000.); + + try { + fSimulation->SimulateParticle(Z, A, pos, mom); + } catch (const std::invalid_argument &) { + } + } + } + +private: + std::unique_ptr fSimulation; + FairPrimaryGenerator *fPrimGen{}; + AtSimParticleCollector fCollector; + std::unique_ptr fMCHeader; +}; +} // namespace + +void simpleSim_fixed(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 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); + + run->SetGenerator(new FairPrimaryGenerator()); + + auto *simPrimGen = BuildElasticGenerator(thetaCms, thetaCms); + run->AddTask(new SimpleSimTask(simPrimGen)); + + 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.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C new file mode 100644 index 000000000..0e290c8e7 --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -0,0 +1,202 @@ +#include "AtCave.h" +#include "AtSimParticleCollector.h" +#include "AtSimpleSimulation.h" +#include "AtTPC2Body.h" +#include "AtTPCIonGenerator.h" +#include "AtTpc.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +std::pair GetZAFromPDG(int pdg) +{ + if (pdg > 1000000000) { + int A = (pdg / 10) % 1000; + int Z = (pdg / 10000) % 1000; + return {Z, A}; + } + + auto *particle = TDatabasePDG::Instance()->GetParticle(pdg); + if (particle) { + 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}; +} + +FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) +{ + constexpr Int_t z = 1; + constexpr Int_t a = 1; + 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 = 0.04333; + constexpr Double_t beamExcitation = 0.0; + constexpr Double_t beamMass = 0.938272; + constexpr Double_t nominalEnergy = 1.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{1, 2, 1, 2}; + std::vector Ap{1, 4, 1, 4}; + 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{1.007276, 4.00260, 1.007276, 4.00260}; + std::vector ExE{beamExcitation, 0.0, 0.0, 0.0}; + + constexpr Int_t mult = 4; + constexpr Double_t resEnergy = 1.0; + auto *twoBody = new AtTPC2Body("Elastic", &Zp, &Ap, &Qp, mult, &Pxp, &Pyp, &Pzp, &Mass, &ExE, resEnergy, + thetaMinCmsDeg, thetaMaxCmsDeg); + primGen->AddGenerator(twoBody); + + return primGen; +} + +void ConfigureHeLossModels(AtSimpleSimulation &sim) +{ + constexpr double heDensity = 1.664e-4; + std::vector> material{{4, 2, 1}}; + + auto protonModel = std::make_shared(heDensity, material); + protonModel->SetProjectile(1, 1, 1.007276); + sim.AddModel(1, 1, protonModel, 1.007276); + + auto heliumModel = std::make_shared(heDensity, material); + heliumModel->SetProjectile(4, 2, 4.002602); + sim.AddModel(2, 4, heliumModel, 4.002602); +} + +class SimpleSimTask : public FairTask { +public: + explicit SimpleSimTask(FairPrimaryGenerator *primGen) : FairTask("SimpleSimTask"), fPrimGen(primGen) {} + + InitStatus Init() override + { + fSimulation = std::make_unique(); + ConfigureHeLossModels(*fSimulation); + fSimulation->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + fSimulation->SetMaxPropagationStep(1e-3); + fSimulation->RegisterBranch(); + + if (fPrimGen) { + fMCHeader = std::make_unique(); + fPrimGen->SetEvent(fMCHeader.get()); + fPrimGen->Init(); + } + + return kSUCCESS; + } + + void Exec(Option_t *) override + { + fSimulation->NewEvent(); + if (!fPrimGen) + return; + + fCollector.Clear(); + fPrimGen->GenerateEvent(&fCollector); + + for (const auto &particle : fCollector.GetParticles()) { + auto [Z, A] = GetZAFromPDG(particle.pdgCode); + if (Z == 0 && A == 0) + continue; + + ROOT::Math::XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); + ROOT::Math::PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., + particle.e * 1000.); + + try { + fSimulation->SimulateParticle(Z, A, pos, mom); + } catch (const std::invalid_argument &) { + } + } + } + +private: + std::unique_ptr fSimulation; + FairPrimaryGenerator *fPrimGen{}; + AtSimParticleCollector fCollector; + std::unique_ptr fMCHeader; +}; +} // namespace + +void simpleSim_kinematic(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 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 *simPrimGen = BuildElasticGenerator(0.0, 180.0); + run->AddTask(new SimpleSimTask(simPrimGen)); + + 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/visualizeKinematic.C b/macro/Simulation/AtSimValidation/visualizeKinematic.C new file mode 100644 index 000000000..22f31c26e --- /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/geant4_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"; +} From 3e917f8947bfba5e71ffedc96a63d76fbddaab99 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 13:12:54 -0400 Subject: [PATCH 07/18] Document AtSimpleSim hook plan --- AtDigitization/AtTestSimulation.cxx | 2 + .../AtSimValidation/AtSimValidationPlan.md | 269 ++++------- .../AtSimValidation/AtSimpleSimHookPlan.md | 426 ++++++++++++++++++ .../AtSimpleSimMigrationDraft.md | 117 +++++ .../AtSimValidation/simpleSim_fixed.C | 95 +--- .../AtSimValidation/simpleSim_kinematic.C | 108 +---- 6 files changed, 652 insertions(+), 365 deletions(-) create mode 100644 macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md create mode 100644 macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index 810a92901..4a442bccc 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -81,6 +81,8 @@ void AtTestSimulation::Exec(Option_t *) PxPyPzEVector mom(p.px * 1000., p.py * 1000., p.pz * 1000., p.e * 1000.); // GeV → MeV try { + LOG(info) << "Simulating particle Z=" << Z << " A=" << A << " with initial pos=" << pos << " mm and mom=" << mom + << " MeV/c"; fSimulation->SimulateParticle(Z, A, pos, mom); } catch (const std::invalid_argument &ex) { // Particle may start outside the drift volume (e.g. beam upstream) — skip silently diff --git a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md index ab5c0536e..e6efe72fc 100644 --- a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -1,228 +1,115 @@ -# Plan: AtSimpleSimulation Validation — Elastic Scatter with B-Field +# Plan: AtSimpleSim Validation and Migration Work -## Context +## Summary -`AtSimpleSimulation` / `AtPropagator` (Lorentz-force RK4) have never been validated against full Geant4 physics. The validation scenario is proton-on-He elastic scattering with a 2 T solenoid, which gives helical tracks with analytically-known Larmor radii and is straightforward to generate with the existing `AtTPC2Body` + `AtTPCIonGenerator` pair. +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`. -The macros live in `macro/Simulation/protonBragg/` (to be **renamed** to `macro/Simulation/AtSimValidation/`). +This document is the working plan for that effort. It records the current code state, how the `AtSimpleSim` hook works today, what is still ad hoc, and what must be implemented and verified before this can be described as a supported migration strategy. -**Why the single-generator approach failed:** -`AtTPCIonGenerator` alone produces identical-momentum particles every event with no vertex variation — no statistical content. The simulation pipeline alternates beam events (even) and reaction events (odd). On beam events `AtTPCIonGenerator` fires; on reaction events `AtTPC2Body` fires. Without a reaction generator registered, reaction-phase events corrupt `AtVertexPropagator` state and can crash VMC initialisation. +## Current State of the Code -**Why two macros per scenario:** -Geant4 transport and `AtSimpleSimulation` validation remain separate macros, but each macro must use a single FairRoot run type from start to finish. The revised contract is: both the Geant4 and SimpleSim validation macros are `FairRunSim` macros. The Geant4 macros use the normal FairRoot transport generator. The SimpleSim macros attach `AtTestSimulation` as a simulation task and use a no-op primary generator only to drive the FairRoot event loop; the actual elastic-scatter event generation for `AtSimpleSimulation` is owned by `AtTestSimulation` through its own `FairPrimaryGenerator`. This avoids mixing `FairRunAna` and `FairRunSim` in one macro and preserves FairRoot singleton contracts. +### 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. -## Physics Being Simulated +### Validation work that already exists in this directory -**Reaction:** p + ⁴He → p + ⁴He (elastic) -**Beam:** proton, 1 MeV kinetic energy (p = 43.33 MeV/c = 0.04333 GeV/c along Z) -**Gas / target:** He-1bar, density 1.664 × 10⁻⁴ g/cm³, serves as both medium and target nuclei -**Geometry:** ATTPC cylinder, r = 25 cm, z = 0–100 cm -**B-field:** 2 T along Z (solenoid), applied in both simulations -**Energy-loss model for SimpleSim:** `AtELossCATIMA` with He material +- Geant macros run in `macro/Simulation/AtSimValidation/`. +- Visualization and comparison macros exist. +- SimpleSim validation macros now run through `AtTestSimulation` and write `AtTpcPoint` output. -With 2 T the proton Larmor radius is r_p ≈ 72 mm and the He-4 recoil radius r_He ≈ 36 mm (at full beam momentum; shrinks as particles lose energy). Both tracks form visible helical arcs within the drift volume before stopping. +### What is still wrong with the current attempt -**Bragg information** is retained in summary plots: dE/dx vs Z per track provides the Bragg curve for each product. +- The SimpleSim validation macros originally introduced their own macro-local `SimpleSimTask` class instead of using `AtTestSimulation`. +- That duplication has now been removed in this directory, but the migration still has to be validated against real physics parity with the Geant side. +- The local plan document had drifted into a mix of intended architecture, stale assumptions, and partially outdated physics description. ---- +### Physics/configuration inconsistency to resolve during validation -## Folder Structure +This directory should only be used for side-by-side validation once both paths are running the same physics setup. Earlier versions of the local note described proton-on-He elastic scattering, while the current Geant validation macros in this directory are configured around the `16C + p` example pattern. The SimpleSim side must be checked against the actual Geant configuration being used before any comparison plots are treated as meaningful validation. -``` -macro/Simulation/AtSimValidation/ ← renamed from protonBragg/ -├── geant4_fixed.C ← Geant4, fixed CMS angle (thetaCms parameter) -├── simpleSim_fixed.C ← SimpleSim, same fixed angle, same seed -├── geant4_kinematic.C ← Geant4, full kinematic curve (0–180° CMS) -├── simpleSim_kinematic.C ← SimpleSim, full kinematic curve, same seed -├── compareFixed.C ← 3D track overlay + Bragg curves, fixed angle -├── compareKinematic.C ← kinematic locus + Bragg curves, full range -└── data/ - ├── geant4_fixed.root - ├── simpleSim_fixed.root - ├── geant4_kinematic.root - └── simpleSim_kinematic.root -``` +## How the AtSimpleSim Hook Was Added -The old files (`runGeant4_proton.C`, `simpleSim_Bfield.C`, `compareSimVsGeant.C`) are removed; their content is superseded. +The existing bridge was added in the framework, not in this macro directory. ---- +The transport substitution works like this: -## Generator Setup (canonical, shared across all four simulation macros) +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. -Follows `C16_pp_sim.C` exactly. +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`. -``` -Beam: Z=1, A=1, Q=0, px=0, py=0, pz=0.04333 GeV/c/nucleon - Bmass=0.938272 GeV/c², NomEnergy=1.0 MeV - ionGen->SetSpotRadius(0, -100, 0) - ionGen->SetDoReaction(kTRUE) // beam drives AtVertexPropagator +This means the intended migration is to preserve the generator physics and replace only the transport mechanism. -Target: ⁴He at rest: Z=2, A=4, mass=4.00260 amu, ExE=0 +## What Has To Be Implemented Next -Product 1 (scattered proton): Z=1, A=1, mass=1.00728 amu, ExE=0 -Product 2 (recoil He-4): Z=2, A=4, mass=4.00260 amu, ExE=0 +### 1. Keep the validation macros on the framework bridge -mult = 4 (beam + target + 2 products — required by AtTPC2Body) -ResEner = 1.0 // MeV, nominal beam energy -``` +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. -`AtTPCIonGenerator` fires on beam events and uses `AtVertexPropagator` to record the beam stopping depth (the reaction vertex). `AtTPC2Body` fires on reaction events, reads the vertex and residual beam momentum, and adds the two products. +### 2. Write and maintain local draft documentation -For SimpleSim, `AtTestSimulation::Exec()` calls its private `FairPrimaryGenerator` every event. The same even/odd alternation applies because `AtVertexPropagator` is a singleton shared by the beam and reaction generators: beam events deposit the beam track into the collector and update the vertex; reaction events deposit the two reaction products. `AtSimpleSimulation` propagates whichever particles appear in the collector each event. +Keep draft documentation local to this directory until behavior is verified. -**Same ground truth / seed:** -Both the Geant4 macro and the mirrored SimpleSim macro call `gRandom->SetSeed(42)` before `run->Init()`. In the SimpleSim macros the run-level primary generator is intentionally a no-op, so the random sequence is consumed only by the mirrored elastic-scatter generator attached to `AtTestSimulation`. This preserves the same sequence of: -- random CMS angles (from AtTPC2Body's uniform cos-theta sampling) -- `fRndELoss` values that determine beam stopping depth (from AtTPCIonGenerator) +Required local documents: -Because CATIMA and Geant4 EMSTD agree closely on proton energy loss in He, the actual vertex depths will be similar, giving visually compatible track patterns. +- `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 -## Macro 1 & 2: Fixed Angle (`geant4_fixed.C` / `simpleSim_fixed.C`) +Use the Geant validation macro shape already present in this directory as the first migration target. -**Signature:** `(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42)` +The migration attempt should answer: -**Key parameters in AtTPC2Body:** -```cpp -Double_t ThetaMinCMS = thetaCms; -Double_t ThetaMaxCMS = thetaCms; // fixed angle: min == max -``` +- 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. -**B-field (Geant4):** `AtConstField` with (0, 0, 20) kG (= 2 T); region covers full detector. +### 4. Only then identify framework follow-up -**B-field (SimpleSim):** `sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0))` +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. -**Run contract (SimpleSim):** -- `FairRunSim` only -- `run->SetGenerator(noOpPrimGen)` to satisfy the simulation event loop without Geant transport tracks -- `run->AddTask(simTask)` where `simTask` owns the mirrored elastic-scatter `FairPrimaryGenerator` +## Immediate Work Items -**Output:** `./data/geant4_fixed.root` / `./data/simpleSim_fixed.root` +1. Rewrite the local docs so they match the actual code in the branch. +2. Re-run the local validation workflow and inspect output structure and physics behavior. +3. Align the Geant and SimpleSim macros to the same reaction definition before trusting comparison plots. +4. Update the migration draft with what worked and what did not. +5. Only after the migration path is stable should any framework-wide documentation be proposed. ---- +## Acceptance Criteria for a Viable Migration Strategy -## Macro 3 & 4: Kinematic Curve (`geant4_kinematic.C` / `simpleSim_kinematic.C`) +A migration strategy is viable only if all of the following are true: -**Signature:** `(Int_t nEvents = 1000, UInt_t seed = 42)` +- 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. -**AtTPC2Body:** `ThetaMinCMS = 0`, `ThetaMaxCMS = 180` (full range, cos-theta uniform). - -Everything else identical to macros 1 & 2. - -**Output:** `./data/geant4_kinematic.root` / `./data/simpleSim_kinematic.root` - ---- - -## Comparison Macro 1: `compareFixed.C` - -Reads `geant4_fixed.root` + `simpleSim_fixed.root`. - -**Plots:** - -| # | Title | Description | -|---|-------|-------------| -| 1 | 3D track overlay | `TGraph2D` of (x, y, z) for each track; Geant4 in blue, SimpleSim in red; separate panels for proton and He-4 products; should visually coincide | -| 2 | XY projection | `TH2D` of all hit positions; circles expected from helical projection | -| 3 | XZ projection | Shows helical pitch | -| 4 | Bragg curve — proton | mean dE/dx [MeV/mm] vs Z [mm] from both simulations | -| 5 | Bragg curve — He-4 | same for recoil He-4 | -| 6 | Track length | distribution of total path length per track | - -For the 3D overlay (plot 1): iterate over events, group MCPoints by `GetTrackID()`, draw a `TPolyLine3D` for each product track. Up to 20 events overlaid. - ---- - -## Comparison Macro 2: `compareKinematic.C` - -Reads `geant4_kinematic.root` + `simpleSim_kinematic.root`. - -**Plots:** - -| # | Title | Description | -|---|-------|-------------| -| 1 | Kinematic locus | p_t [MeV/c] vs p_z [MeV/c] for the proton product; Geant4 vs SimpleSim should trace the same curve | -| 2 | Kinematic locus | same for He-4 recoil | -| 3 | Bragg curve — proton | mean dE/dx vs Z, all events averaged | -| 4 | Bragg curve — He-4 | same | -| 5 | Range distributions | stopping Z for each product, both simulations | -| 6 | XY projection | all hits, full kinematic range | - -Kinematic locus: use first MCPoint of each track as the initial momentum. In the limit of no energy loss at vertex, both simulations should produce the same two-body kinematic curve. - ---- - -## Energy-Loss Model for SimpleSim - -```cpp -// He gas at 1 bar -constexpr double He_density = 1.664e-4; // g/cm³ at 20°C - -// Proton in He -auto eloss_p = std::make_shared( - He_density, - std::vector>{{4, 2, 1}} // He-4: {A, Z, stoich} -); -eloss_p->SetProjectile(1, 1, 1.007276); // proton: A, Z, mass_amu -sim->AddModel(1, 1, eloss_p); - -// He-4 in He -auto eloss_he = std::make_shared( - He_density, - std::vector>{{4, 2, 1}} -); -eloss_he->SetProjectile(4, 2, 4.002602); // He-4: A, Z, mass_amu -sim->AddModel(2, 4, eloss_he); -``` - ---- - -## Geant4 Fix (present in both Geant4 macros) - -The crash from the previous `runGeant4_proton.C` was caused by calling `AtVertexPropagator::Instance()` before `FairRunSim` was constructed. Fix: **remove the explicit `AtVertexPropagator::Instance()` call entirely** (follow `C16_pp_sim.C` — `AtTPCIonGenerator` calls it internally). `SetDoReaction(kTRUE)` is used (default) so the reaction generator can fire. - ---- - -## Verification - -1. **Rename folder**, remove old files, create new ones -2. Build if framework code changed; macro-only edits can be run directly after sourcing `build/config.sh` -3. **Run fixed-angle pair:** - ```bash - cd macro/Simulation/AtSimValidation - source ../../../build/config.sh - root -l -q 'geant4_fixed.C(45., 100)' - root -l -q 'simpleSim_fixed.C(45., 100)' - root -l -q 'compareFixed.C' - ``` -4. **Run kinematic pair:** - ```bash - root -l -q 'geant4_kinematic.C(1000)' - root -l -q 'simpleSim_kinematic.C(1000)' - root -l -q 'compareKinematic.C' - ``` -5. **Pass criteria:** - - 3D track overlays from fixed-angle run show Geant4 (blue) and SimpleSim (red) helices coinciding within the expected energy-straggling spread - - Kinematic locus from both simulations traces the same two-body kinematic curve - - Bragg curves from both methods agree in peak position ± ~1 cm - ---- - -## Key Files - -| File | Role | -|------|------| -| `macro/Simulation/AtSimValidation/geant4_fixed.C` | new | -| `macro/Simulation/AtSimValidation/simpleSim_fixed.C` | new | -| `macro/Simulation/AtSimValidation/geant4_kinematic.C` | new | -| `macro/Simulation/AtSimValidation/simpleSim_kinematic.C` | new | -| `macro/Simulation/AtSimValidation/compareFixed.C` | new | -| `macro/Simulation/AtSimValidation/compareKinematic.C` | new | -| `macro/Simulation/ATTPC/16C_pp/C16_pp_sim.C` | canonical generator pattern | -| `AtGenerators/AtTPC2Body.h/.cxx` | reaction generator | -| `AtGenerators/AtTPCIonGenerator.h/.cxx` | beam generator | -| `AtDigitization/AtTestSimulation.cxx` | SimpleSim FairTask wrapper | -| `AtTools/AtELossCATIMA.h` | energy-loss model | +Until those conditions are met, this work remains a local validation and design iteration effort. diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md new file mode 100644 index 000000000..6e33dbb9e --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md @@ -0,0 +1,426 @@ +# 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. + +## 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 +- the current fixed SimpleSim macro can be made to run with `TGeant3` +- the current collector-based bridge can call `AtSimpleSimulation` + +Current blocker: + +- the collector-based bridge does not preserve the detector-side `AtVertexPropagator` contract +- therefore it is not yet a true transport replacement + +That blocker should be addressed in code before any migration guide is written. diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md new file mode 100644 index 000000000..57e28e2b0 --- /dev/null +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -0,0 +1,117 @@ +# Draft: Transitioning a Geant Simulation Macro to AtSimpleSim + +## Status + +This is a local draft for the `AtSimValidation` campaign. It is not framework documentation yet. Its purpose is to describe the candidate migration path, test that path against real macros, and record what has to change before the migration can be treated as clean and reusable. + +## Migration Goal + +The target transition is: + +- preserve the existing ATTPC generator physics, +- preserve the detector geometry and field setup, +- preserve the output and runtime-db structure as much as possible, +- replace Geant/VMC transport with `AtSimpleSimulation`. + +The migration should be about swapping the transport layer, not rewriting the reaction setup. + +## Current Candidate Hook + +The current framework adapter is `AtDigitization/AtTestSimulation`. + +Its role is: + +- own an `AtSimpleSimulation` instance, +- drive a `FairPrimaryGenerator` each event, +- collect generated primaries through `AtSimParticleCollector`, +- convert FairRoot units to `AtSimpleSimulation` units, +- forward the particles into `AtSimpleSimulation`, +- write `AtTpcPoint` output through the normal branch contract. + +At present, this is the first hook that should be used when adapting an existing macro. + +This directory has now exercised that hook in the interpreted validation macros: `AtTestSimulation` can replace the macro-local transport task and still produce the standard `AtTpcPoint` branch. + +## Draft Migration Recipe + +### 1. Start from a working Geant macro + +Keep these pieces unchanged unless the migration experiment proves otherwise: + +- detector modules such as `AtCave` and `AtTpc`, +- geometry file selection, +- magnetic-field setup, +- random seed policy, +- runtime-db output handling, +- output file naming pattern, +- generator construction function such as `BuildElasticGenerator(...)`. + +### 2. Preserve the generator block + +Build the same `FairPrimaryGenerator` and ATTPC generator chain used by the Geant macro. The same beam and reaction generators should define the physics input on both sides. + +### 3. Replace Geant transport with AtTestSimulation + +For the SimpleSim version: + +- keep `FairRunSim`, +- keep the detector geometry and field configuration, +- do not hand the physics generator to Geant transport with `run->SetGenerator(physicsGenerator)`, +- instead, give the run a minimal driver generator and attach an `AtTestSimulation` task configured with: + - a new `AtSimpleSimulation`, + - the required energy-loss models for every transported species, + - the physics generator that was preserved from the Geant macro. + +This is the current transport substitution mechanism being tested in this directory. + +### 4. Configure AtSimpleSimulation explicitly + +The migration must define: + +- all species that need `AtTools::AtELossModel` entries, +- the geometry assumption for the active volume, +- field configuration if curved transport is required, +- propagation step settings if they matter for the validation case. + +If any transported species lack a model, the migration is incomplete. + +## Current Known Constraints + +- `AtTestSimulation` skips particles that start outside `drift_volume`. +- `AtSimpleSimulation` requires explicit energy-loss models for each `(Z, A)` species. +- The detector geometry file used by `AtTpc` is not necessarily the file that should be passed to `AtSimpleSimulation`. In this validation area the run uses `ATTPC_He1bar.root`, while `AtSimpleSimulation` needs the importable `ATTPC_He1bar_geomanager.root`. +- FairRoot generator output uses cm and GeV; `AtSimpleSimulation` uses mm and MeV. +- Interpreted ROOT macros are sensitive to explicit header inclusion. In this directory the stable pattern is to keep the macro header surface minimal and rely on the loaded dictionaries for FairRoot and ATTPC classes where possible. +- A migration is not considered successful just because the macro runs. The resulting tracks must also look physically credible. + +## What Must Be Checked During Each Migration Attempt + +### Structural checks + +- Does the macro still have the same generator construction logic as the Geant source? +- Does the SimpleSim path write the expected `AtTpcPoint` branch? +- Can downstream comparison or digitization scripts read the result without special handling? + +### Physics checks + +- Are both sides running the same reaction setup? +- Are the geometry and magnetic field the same? +- Do the track shapes look qualitatively correct? +- Is the stopping behavior reasonable for the configured energy-loss model? + +### Cleanliness checks + +- Was `AtTestSimulation` sufficient, or did the macro need custom task code? +- Are the remaining edits small enough to describe as a checklist? +- Is there repeated boilerplate that points to missing framework support? + +## Current Working Assumption + +The migration path is viable if the validation macros can be rewritten around `AtTestSimulation` without introducing new macro-local transport logic. If that succeeds and the resulting behavior is physically credible, this draft can be promoted later into framework documentation. If it fails, this draft should be revised to record the exact missing framework support instead of papering over the problem. + +Current local result: + +- the macro-local transport task was removed, +- `AtTestSimulation` runs successfully in the validation macros, +- the SimpleSim output file contains the expected `AtTpcPoint` branch, +- the next unresolved issue is physics/configuration parity with the Geant comparison macros, not the transport hook itself. diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index b0eb201c1..3651dda90 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -1,23 +1,5 @@ namespace { -std::pair GetZAFromPDG(int pdg) -{ - if (pdg > 1000000000) { - int A = (pdg / 10) % 1000; - int Z = (pdg / 10000) % 1000; - return {Z, A}; - } - - auto *particle = TDatabasePDG::Instance()->GetParticle(pdg); - if (particle) { - 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}; -} - FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) { constexpr Int_t z = 1; @@ -57,72 +39,25 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th return primGen; } -void ConfigureHeLossModels(AtSimpleSimulation &sim) +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) { + auto sim = std::make_unique(geoFile.Data()); + constexpr double heDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; auto protonModel = std::make_shared(heDensity, material); protonModel->SetProjectile(1, 1, 1.007276); - sim.AddModel(1, 1, protonModel, 1.007276); + sim->AddModel(1, 1, protonModel, 1.007276); auto heliumModel = std::make_shared(heDensity, material); heliumModel->SetProjectile(4, 2, 4.002602); - sim.AddModel(2, 4, heliumModel, 4.002602); -} + sim->AddModel(2, 4, heliumModel, 4.002602); + sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + sim->SetMaxPropagationStep(1e-3); -class SimpleSimTask : public FairTask { -public: - explicit SimpleSimTask(FairPrimaryGenerator *primGen) : FairTask("SimpleSimTask"), fPrimGen(primGen) {} - - InitStatus Init() override - { - fSimulation = std::make_unique(); - ConfigureHeLossModels(*fSimulation); - fSimulation->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); - fSimulation->SetMaxPropagationStep(1e-3); - fSimulation->RegisterBranch(); - - if (fPrimGen) { - fMCHeader = std::make_unique(); - fPrimGen->SetEvent(fMCHeader.get()); - fPrimGen->Init(); - } - - return kSUCCESS; - } - - void Exec(Option_t *) override - { - fSimulation->NewEvent(); - if (!fPrimGen) - return; - - fCollector.Clear(); - fPrimGen->GenerateEvent(&fCollector); - - for (const auto &particle : fCollector.GetParticles()) { - auto [Z, A] = GetZAFromPDG(particle.pdgCode); - if (Z == 0 && A == 0) - continue; - - ROOT::Math::XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); - ROOT::Math::PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., - particle.e * 1000.); - - try { - fSimulation->SimulateParticle(Z, A, pos, mom); - } catch (const std::invalid_argument &) { - } - } - } - -private: - std::unique_ptr fSimulation; - FairPrimaryGenerator *fPrimGen{}; - AtSimParticleCollector fCollector; - std::unique_ptr fMCHeader; -}; + return sim; +} } // namespace void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42) @@ -156,18 +91,22 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed tpc->SetGeometryFileName((dir + "/geometry/ATTPC_He1bar.root").Data()); run->AddModule(tpc); - run->SetGenerator(new FairPrimaryGenerator()); + // FairRunSim still needs a generator object to drive the event loop, but the + // actual physics generator for the SimpleSim path is owned by AtTestSimulation. + auto *eventLoopDriver = new FairPrimaryGenerator(); + run->SetGenerator(eventLoopDriver); auto *simPrimGen = BuildElasticGenerator(thetaCms, thetaCms); - run->AddTask(new SimpleSimTask(simPrimGen)); + auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + simTask->SetPrimaryGenerator(simPrimGen); + 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->Init(); run->Run(nEvents); rtdb->saveOutput(); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C index 0e290c8e7..3e3a5e178 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -1,48 +1,9 @@ -#include "AtCave.h" -#include "AtSimParticleCollector.h" -#include "AtSimpleSimulation.h" -#include "AtTPC2Body.h" -#include "AtTPCIonGenerator.h" -#include "AtTpc.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include #include #include -#include #include -#include #include namespace { -std::pair GetZAFromPDG(int pdg) -{ - if (pdg > 1000000000) { - int A = (pdg / 10) % 1000; - int Z = (pdg / 10000) % 1000; - return {Z, A}; - } - - auto *particle = TDatabasePDG::Instance()->GetParticle(pdg); - if (particle) { - 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}; -} - FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) { constexpr Int_t z = 1; @@ -82,72 +43,25 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th return primGen; } -void ConfigureHeLossModels(AtSimpleSimulation &sim) +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) { + auto sim = std::make_unique(geoFile.Data()); + constexpr double heDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; auto protonModel = std::make_shared(heDensity, material); protonModel->SetProjectile(1, 1, 1.007276); - sim.AddModel(1, 1, protonModel, 1.007276); + sim->AddModel(1, 1, protonModel, 1.007276); auto heliumModel = std::make_shared(heDensity, material); heliumModel->SetProjectile(4, 2, 4.002602); - sim.AddModel(2, 4, heliumModel, 4.002602); -} - -class SimpleSimTask : public FairTask { -public: - explicit SimpleSimTask(FairPrimaryGenerator *primGen) : FairTask("SimpleSimTask"), fPrimGen(primGen) {} - - InitStatus Init() override - { - fSimulation = std::make_unique(); - ConfigureHeLossModels(*fSimulation); - fSimulation->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); - fSimulation->SetMaxPropagationStep(1e-3); - fSimulation->RegisterBranch(); - - if (fPrimGen) { - fMCHeader = std::make_unique(); - fPrimGen->SetEvent(fMCHeader.get()); - fPrimGen->Init(); - } - - return kSUCCESS; - } + sim->AddModel(2, 4, heliumModel, 4.002602); + sim->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); + sim->SetMaxPropagationStep(1e-3); - void Exec(Option_t *) override - { - fSimulation->NewEvent(); - if (!fPrimGen) - return; - - fCollector.Clear(); - fPrimGen->GenerateEvent(&fCollector); - - for (const auto &particle : fCollector.GetParticles()) { - auto [Z, A] = GetZAFromPDG(particle.pdgCode); - if (Z == 0 && A == 0) - continue; - - ROOT::Math::XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); - ROOT::Math::PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., - particle.e * 1000.); - - try { - fSimulation->SimulateParticle(Z, A, pos, mom); - } catch (const std::invalid_argument &) { - } - } - } - -private: - std::unique_ptr fSimulation; - FairPrimaryGenerator *fPrimGen{}; - AtSimParticleCollector fCollector; - std::unique_ptr fMCHeader; -}; + return sim; +} } // namespace void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42) @@ -184,7 +98,9 @@ void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42) run->SetGenerator(new FairPrimaryGenerator()); auto *simPrimGen = BuildElasticGenerator(0.0, 180.0); - run->AddTask(new SimpleSimTask(simPrimGen)); + auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + simTask->SetPrimaryGenerator(simPrimGen); + run->AddTask(simTask); auto *rtdb = run->GetRuntimeDb(); Bool_t parameterMerged = kTRUE; From 294b4800e59af983faba5775a852cbceb0bfbb37 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 14:39:16 -0400 Subject: [PATCH 08/18] Integrate SimpleSim through the detector step contract Refactor AtTpc around a transport-neutral step payload and route both VMC and the SimpleSim adapter through the same detector-owned hit and reaction logic. Keep the VMC entrypoint intact while moving reaction triggering, vertex handoff, punch-through reset, and non-beam metadata lookup under the shared path. Extend AtSimpleSimulation with callback-based transport reporting and update AtTestSimulation to use a detector-coupled adapter. Reuse the canonical MCTrack branch, preserve generator-assigned track IDs, and carry explicit beam-phase information into detector stepping so reaction-event track 0 keeps the Geant truth contract without tripping beam-only handoff logic. Add focused regression coverage for AtTpc reaction triggering, reaction-event track-0 handling, AtVertexPropagator state writes/resets, and the SimpleSim callback path. Align the local validation macros and comparison tooling to the 16C+p campaign, restore visualizer-readable MC truth, and document the verified detector-contract state plus the remaining Z-parity investigation. --- AtDetectors/AtTpc/AtTpc.cxx | 164 ++++++++++------- AtDetectors/AtTpc/AtTpc.h | 41 ++++- AtDetectors/AtTpc/AtTpcTest.cxx | 133 ++++++++++++++ AtDetectors/CMakeLists.txt | 9 + AtDigitization/AtSimParticleCollector.cxx | 2 +- AtDigitization/AtSimParticleCollector.h | 1 + AtDigitization/AtSimTest.cxx | 100 ++++++++++ AtDigitization/AtSimpleSimulation.cxx | 136 ++++++++++++++ AtDigitization/AtSimpleSimulation.h | 29 +++ AtDigitization/AtTestSimulation.cxx | 134 +++++++++++++- AtDigitization/AtTestSimulation.h | 16 +- AtDigitization/CMakeLists.txt | 1 + AtSimulationData/AtVertexPropagatorTest.cxx | 39 +++- .../AtSimValidation/AtSimValidationPlan.md | 41 ++++- .../AtSimValidation/AtSimpleSimHookPlan.md | 39 +++- .../AtSimpleSimMigrationDraft.md | 21 ++- .../Simulation/AtSimValidation/compareFixed.C | 56 +++--- .../AtSimValidation/compareKinematic.C | 173 +++++++++--------- .../AtSimValidation/simpleSim_fixed.C | 31 ++-- .../AtSimValidation/simpleSim_kinematic.C | 31 ++-- .../AtSimValidation/visualizeKinematic.C | 2 +- 21 files changed, 973 insertions(+), 226 deletions(-) create mode 100644 AtDetectors/AtTpc/AtTpcTest.cxx diff --git a/AtDetectors/AtTpc/AtTpc.cxx b/AtDetectors/AtTpc/AtTpc.cxx index f711a1a10..d8e901d48 100644 --- a/AtDetectors/AtTpc/AtTpc.cxx +++ b/AtDetectors/AtTpc/AtTpc.cxx @@ -60,71 +60,66 @@ void AtTpc::Initialize() rtdb->getContainer("AtTpcGeoPar"); } -void AtTpc::trackEnteringVolume() +void AtTpc::trackEnteringVolume(const StepState &step) { - auto AZ = DecodePdG(gMC->TrackPid()); + auto AZ = DecodePdG(step.pdg); fELoss = 0.; fELossAcc = 0.; - 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; // 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")) + if (fIsBeamTrack && IsReactionVolume(fVolName)) InPos = fPosIn; Int_t VolumeID = 0; - if (fTrackID == 0) + if (fIsBeamTrack) LOG(debug) << cGREEN << " AtTPC: Beam Event "; else 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); - - // Correct fPosOut - if (gMC->IsTrackExiting()) { - correctPosOut(); - if ((fVolName.Contains("drift_volume") || fVolName.Contains("cell")) && fTrackID == 0) - resetVertex(); - } + fTrackID = step.trackID; + fPosOut = step.posOut; + fMomOut = step.momOut; + + if (step.exiting && IsReactionVolume(fVolName) && fIsBeamTrack) + resetVertex(); } void AtTpc::resetVertex() @@ -165,52 +160,92 @@ void AtTpc::correctPosOut() bool AtTpc::reactionOccursHere() { bool atEnergyLoss = fELossAcc * 1000 > AtVertexPropagator::Instance()->GetRndELoss(); - bool isPrimaryBeam = fTrackID == 0; - bool isInRightVolume = fVolName.Contains("drift_volume") || fVolName.Contains("cell"); + bool isPrimaryBeam = fIsBeamTrack; + bool isInRightVolume = IsReactionVolume(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.beamTrack = step.trackID == 0; + 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); + if (step.exiting) { + fPosOut = step.posOut; + correctPosOut(); + step.posOut = fPosOut; + } + } - // 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; + fIsBeamTrack = step.beamTrack; + + if (step.entering) + trackEnteringVolume(step); + + getTrackParametersFromStep(step); + + if (step.exiting || step.stopping || step.disappeared) + getTrackParametersWhileExiting(step); - gMC->StopTrack(); + addHit(step); + + if (reactionOccursHere()) { + startReactionEvent(step); + return true; + } + + 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 +253,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 +274,11 @@ void AtTpc::addHit() TVector3(fMomIn.Px(), fMomIn.Py(), fMomIn.Pz()), fTime, fLength, fELoss, EIni, AIni, AZ.first, AZ.second); } +bool AtTpc::IsReactionVolume(const TString &volumeName) const +{ + return volumeName.Contains("drift_volume") || volumeName.Contains("cell"); +} + void AtTpc::EndOfEvent() { diff --git a/AtDetectors/AtTpc/AtTpc.h b/AtDetectors/AtTpc/AtTpc.h index 842c372a4..0891e0bf9 100644 --- a/AtDetectors/AtTpc/AtTpc.h +++ b/AtDetectors/AtTpc/AtTpc.h @@ -27,6 +27,29 @@ class TList; class TMemberInspector; class AtTpc : public FairDetector { +public: + struct StepState { + int trackID = -1; + int pdg = 0; + TString volumeName; + int volumeID = -1; + int detCopyID = -1; + bool beamTrack = false; + 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. @@ -54,6 +77,7 @@ class AtTpc : public FairDetector { TString fVolName; Double32_t fELossAcc; TLorentzVector InPos; + bool fIsBeamTrack = false; /** container for data points */ @@ -87,17 +111,24 @@ class AtTpc : public FairDetector { 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. + * Returns true when the transport should stop at this step because the 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 IsReactionVolume(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..a8ebb1e83 --- /dev/null +++ b/AtDetectors/AtTpc/AtTpcTest.cxx @@ -0,0 +1,133 @@ +#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.beamTrack = false; + 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.beamTrack = true; + 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.beamTrack = true; + 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, ReactionEventTrackZeroDoesNotTriggerBeamHandoff) +{ + AtVertexPropagator::Instance()->SetIsBeamEvent(false); + AtVertexPropagator::Instance()->SetRndELoss(0.5); + + auto step = MakeStep(0, 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, ReactionEventTrackZeroExitDoesNotResetVertexState) +{ + AtVertexPropagator::Instance()->SetIsBeamEvent(false); + 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, 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/AtSimParticleCollector.cxx b/AtDigitization/AtSimParticleCollector.cxx index d20f1c0a7..50eefa76b 100644 --- a/AtDigitization/AtSimParticleCollector.cxx +++ b/AtDigitization/AtSimParticleCollector.cxx @@ -17,6 +17,6 @@ void AtSimParticleCollector::PushTrack(Int_t toBeDone, Int_t /*parentID*/, Int_t { ntr = static_cast(fParticles.size()); if (toBeDone) { - fParticles.push_back({pdgCode, px, py, pz, e, vx, vy, vz}); + fParticles.push_back({ntr, pdgCode, px, py, pz, e, vx, vy, vz}); } } diff --git a/AtDigitization/AtSimParticleCollector.h b/AtDigitization/AtSimParticleCollector.h index 5a3ed0bff..a9304bca5 100644 --- a/AtDigitization/AtSimParticleCollector.h +++ b/AtDigitization/AtSimParticleCollector.h @@ -18,6 +18,7 @@ class TParticle; * - momentum (px, py, pz) in GeV/c, total energy e in GeV */ struct AtCollectedParticle { + int trackID; int pdgCode; double px, py, pz; ///< Momentum in GeV/c double e; ///< Total energy in GeV diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx index 170089afd..3b0a3271f 100644 --- a/AtDigitization/AtSimTest.cxx +++ b/AtDigitization/AtSimTest.cxx @@ -15,11 +15,18 @@ #include "AtMCPoint.h" #include "AtSimpleSimulation.h" +#define private public +#define protected public +#include "AtTestSimulation.h" +#undef protected +#undef private #include "AtELossModel.h" +#include "AtMCTrack.h" #include #include +#include #include #include #include @@ -29,6 +36,7 @@ #include #include +#include // --------------------------------------------------------------------------- // Minimal energy-loss model with constant dEdx = fRate [MeV/mm]. @@ -209,3 +217,95 @@ TEST_F(AtSimTest, MagneticFieldLarmorRadius) EXPECT_LT(meanErr, larmor_mm * 0.05) << "Mean Larmor radius error " << meanErr << " mm exceeds 5% of " << larmor_mm << " mm"; } + +TEST_F(AtSimTest, LegacySimulateParticleStillRejectsStartsOutsideDriftVolume) +{ + AtSimpleSimulation sim; + sim.AddModel(1, 1, std::make_shared(0.1)); + + 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) +{ + AtSimpleSimulation sim; + sim.AddModel(1, 1, std::make_shared(0.0)); + sim.SetDistanceStep(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.NewEvent(); + sim.TransportParticle(1, 1, pos, mom, [&](const AtSimpleSimulation::TransportStep &step) { + ++callbackCount; + if (step.preVolumeName == "cave" && step.postVolumeName == "drift_volume") + sawCaveToDrift = true; + return callbackCount < 30; + }); + + EXPECT_GT(callbackCount, 0); + EXPECT_TRUE(sawCaveToDrift); + EXPECT_EQ(sim.GetNumPoints(), 0) << "Detector-coupled transport should not emit legacy MC points"; +} + +TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) +{ + auto sim = std::make_unique(); + AtTestSimulation 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 sim = std::make_unique(); + AtTestSimulation 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); +} diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index cd5be8291..8e1e6089a 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -50,6 +50,15 @@ class ELossModelShared : public AtTools::AtELossModel { double GetdEdxStraggling(double ei, double ef) const override { return fImpl->GetdEdxStraggling(ei, ef); } double GetRangeVariance(double e) const override { return fImpl->GetRangeVariance(e); } }; + +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; +} } // namespace AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) @@ -137,6 +146,19 @@ AtSimpleSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const return SimulateParticle(modelIt->second, iniPos, iniMom, func); } +std::pair +AtSimpleSimulation::TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + StepCallback callback) +{ + 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 (GetVolume(iniPos) == nullptr) + throw std::invalid_argument("Position of particle is outside the loaded geometry"); + + return TransportParticle(modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, callback); +} + std::pair AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func) @@ -155,6 +177,7 @@ AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &i prop.SetState(iniPos, iniMom.Vect()); AtTools::AtRK4AdaptiveStepper stepper; + stepper.fInitialStep = fMaxPropStep; stepper.fMaxStep = fMaxPropStep; double length = 0; @@ -231,6 +254,119 @@ AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &i return {pos, mom}; } +std::pair +AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + const StepCallback &callback) +{ + fTrackID++; + + if (fEField.Mag2() != 0 || fBField.Mag2() != 0) { + auto wrapModel = std::make_unique(info.model); + AtTools::AtPropagator prop(info.charge, info.mass, std::move(wrapModel)); + prop.SetEField(fEField); + prop.SetBField(fBField); + prop.SetState(iniPos, iniMom.Vect()); + + AtTools::AtRK4AdaptiveStepper stepper; + stepper.fInitialStep = fMaxPropStep; + stepper.fMaxStep = fMaxPropStep; + double length = 0; + + while (GetVolume(prop.GetPosition()) != nullptr) { + double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); + if (KE <= 1e-3) + break; + + auto momBefore = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); + auto posBefore = prop.GetPosition(); + auto preVolumeName = GetVolumeName(posBefore); + + if (isnan(posBefore.X()) || isnan(prop.GetMomentum().X())) { + LOG(error) << "Failed to transport 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; + + double stepDist = (posAfter - state.fLastPos).R(); + length += stepDist; + + if (callback) { + TransportStep step; + step.trackID = fTrackID; + step.pdg = pdg; + step.preVolumeName = preVolumeName; + step.postVolumeName = GetVolumeName(posAfter); + step.energyLoss = eLoss; + step.length = length; + step.trackMass = info.mass; + step.prePosition = posBefore; + step.postPosition = posAfter; + step.preMomentum = momBefore; + step.postMomentum = momAfter; + if (!callback(step)) + break; + } + } + + return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; + } + + auto &model = info.model; + auto pos = iniPos; + auto mom = iniMom; + double length = 0; + + while (GetVolume(pos) != nullptr && mom.E() - mom.M() > 1e-3) { + if (isnan(pos.X()) || isnan(mom.X())) { + LOG(error) << "Failed to transport a point with nan!"; + return {{0, 0, 0}, {0, 0, 0, 0}}; + } + + auto posBefore = pos; + auto momBefore = mom; + auto preVolumeName = GetVolumeName(posBefore); + auto dir = mom.Vect().Unit(); + double KE = mom.E() - mom.M(); + double eLoss = model->GetEnergyLoss(KE, fDistStep); + auto E = mom.E() - eLoss; + double p = sqrt(E * E - mom.M2()); + mom.SetPxPyPzE(dir.X() * p, dir.Y() * p, dir.Z() * p, E); + pos += dir * fDistStep; + length += fDistStep; + + if (callback) { + TransportStep step; + step.trackID = fTrackID; + step.pdg = pdg; + step.preVolumeName = preVolumeName; + step.postVolumeName = GetVolumeName(pos); + step.energyLoss = eLoss; + step.length = length; + step.trackMass = info.mass; + step.prePosition = posBefore; + step.postPosition = pos; + step.preMomentum = momBefore; + step.postMomentum = mom; + if (!callback(step)) + break; + } + } + + return {pos, mom}; +} + void AtSimpleSimulation::NewEvent() { fMCPoints.Clear(); diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index 7d005b448..d4129c79f 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -67,6 +67,22 @@ class AtSimpleSimulation { static thread_local TClonesArray fMCPoints; public: + struct TransportStep { + int trackID = -1; + int pdg = 0; + std::string preVolumeName; + std::string postVolumeName; + double energyLoss = 0.0; // MeV + double length = 0.0; // mm + double trackMass = 0.0; // MeV/c^2 + XYZPoint prePosition; + XYZPoint postPosition; + PxPyPzEVector preMomentum; + PxPyPzEVector postMomentum; + }; + + using StepCallback = std::function; + /** * Assumes that the IO manager has been initialized (it will attempt to construct the branch needed here). */ @@ -107,10 +123,20 @@ class AtSimpleSimulation { int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); + /** + * Transport a particle through the loaded geometry without writing detector hits. + * This is intended for detector-coupled adapters that want transport state but keep hit + * semantics in the detector code. + */ + std::pair TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + StepCallback callback); + AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } int GetNumPoints() { return fMCPoints.GetEntries(); } TClonesArray &GetPointsArray() { return fMCPoints; } SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } + bool IsInsideGeometry(const XYZPoint &point) { return GetVolume(point) != nullptr; } + std::string GetVolumeNameAt(const XYZPoint &point) { return GetVolumeName(point); } protected: bool IsInVolume(const std::string &volName, const XYZPoint &point); @@ -123,6 +149,9 @@ class AtSimpleSimulation { const ParticleInfo &info, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); + std::pair TransportParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, const StepCallback &callback); + void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); TGeoVolume *GetVolume(const XYZPoint &pos); }; diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index 4a442bccc..375db3481 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -1,21 +1,28 @@ #include "AtTestSimulation.h" +#include "AtDetectorList.h" +#include "AtMCTrack.h" #include "AtSimParticleCollector.h" #include "AtSimpleSimulation.h" +#include "AtTpc/AtTpc.h" +#include "AtVertexPropagator.h" #include #include #include +#include #include // for InitStatus, kSUCCESS #include #include // for Math, XYZPoint #include // for LorentzVector #include // for PxPyPzEVector +#include #include #include #include +#include #include using namespace ROOT::Math; @@ -46,9 +53,46 @@ std::pair GetZAFromPDG(int pdg) } } // namespace +void AtTestSimulation::RegisterMCTrackBranch() +{ + auto *ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before AtTestSimulation::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 AtTestSimulation::FillMCTracks() +{ + if (fMCTrackArray == nullptr) + return; + + fMCTrackArray->Clear("C"); + + for (const auto &p : fCollector.GetParticles()) { + new ((*fMCTrackArray)[p.trackID]) AtMCTrack(p.pdgCode, -1, p.px, p.py, p.pz, p.vx, p.vy, p.vz, 0.0, 0); + } +} + InitStatus AtTestSimulation::Init() { - fSimulation->RegisterBranch(); + if (fDetector == nullptr) { + LOG(info) << "AtTestSimulation: using standalone AtSimpleSimulation branch writer"; + fSimulation->RegisterBranch(); + } else { + LOG(info) << "AtTestSimulation: using detector-coupled transport adapter"; + } if (fPrimGen) { // FairPrimaryGenerator::GenerateEvent() requires a non-null FairMCEventHeader. @@ -57,6 +101,8 @@ InitStatus AtTestSimulation::Init() fPrimGen->Init(); } + RegisterMCTrackBranch(); + return kSUCCESS; } @@ -67,8 +113,10 @@ void AtTestSimulation::Exec(Option_t *) if (!fPrimGen) return; + const bool isBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); fCollector.Clear(); fPrimGen->GenerateEvent(&fCollector); + FillMCTracks(); for (const auto &p : fCollector.GetParticles()) { auto [Z, A] = GetZAFromPDG(p.pdgCode); @@ -81,14 +129,94 @@ void AtTestSimulation::Exec(Option_t *) PxPyPzEVector mom(p.px * 1000., p.py * 1000., p.pz * 1000., p.e * 1000.); // GeV → MeV try { + if (fDetector != nullptr && !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"; - fSimulation->SimulateParticle(Z, A, pos, mom); + if (fDetector != nullptr) { + bool seenSensitiveVolume = false; + fSimulation->TransportParticle( + Z, A, pos, mom, + [this, trackID = p.trackID, isBeamEvent, seenSensitiveVolume](const AtSimpleSimulation::TransportStep &step + ) mutable { + const bool preSensitive = seenSensitiveVolume || IsSensitiveVolume(step.preVolumeName); + const bool postSensitive = IsSensitiveVolume(step.postVolumeName); + const bool entering = !seenSensitiveVolume && postSensitive; + const bool exiting = seenSensitiveVolume && !postSensitive; + const bool beamTrack = isBeamEvent && trackID == 0; + if (postSensitive) + seenSensitiveVolume = true; + return ProcessDetectorStep(step, trackID, beamTrack, preSensitive, postSensitive, entering, exiting); + }); + } else { + fSimulation->SimulateParticle(Z, A, pos, mom); + } } catch (const std::invalid_argument &ex) { - // Particle may start outside the drift volume (e.g. beam upstream) — skip silently + // Legacy direct simulation only supports drift-volume starts. The detector-coupled path + // also rejects tracks that begin outside the imported geometry. LOG(debug) << "AtTestSimulation: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); } } } +bool AtTestSimulation::ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, + bool preSensitive, bool postSensitive, bool entering, bool exiting) +{ + if (!preSensitive && !postSensitive) + return true; + + AtTpc::StepState detectorStep; + detectorStep.trackID = trackID; + detectorStep.pdg = step.pdg; + detectorStep.volumeName = postSensitive ? step.postVolumeName.c_str() : step.preVolumeName.c_str(); + detectorStep.volumeID = kAtTpc; + detectorStep.detCopyID = 0; + detectorStep.beamTrack = beamTrack; + detectorStep.entering = entering; + detectorStep.exiting = exiting; + detectorStep.stopping = postSensitive && (step.postMomentum.E() - step.postMomentum.M() <= 1e-3); + detectorStep.disappeared = false; + detectorStep.energyLoss = step.energyLoss / 1000.; + detectorStep.timeNs = 0.; + detectorStep.trackLength = step.length / 10.; + + const auto &refPos = postSensitive ? step.postPosition : step.prePosition; + const auto &refMom = postSensitive ? step.postMomentum : step.preMomentum; + detectorStep.totalEnergy = refMom.E() / 1000.; + detectorStep.trackMass = step.trackMass / 1000.; + detectorStep.pos.SetXYZT(refPos.X() / 10., refPos.Y() / 10., refPos.Z() / 10., 0.); + detectorStep.mom.SetXYZT(refMom.Px() / 1000., refMom.Py() / 1000., refMom.Pz() / 1000., refMom.E() / 1000.); + detectorStep.posOut.SetXYZT(step.postPosition.X() / 10., step.postPosition.Y() / 10., step.postPosition.Z() / 10., 0.); + detectorStep.momOut.SetXYZT(step.postMomentum.Px() / 1000., step.postMomentum.Py() / 1000., step.postMomentum.Pz() / 1000., + step.postMomentum.E() / 1000.); + + const bool stopTransport = fDetector->ProcessStep(detectorStep); + return !stopTransport; +} + +XYZPoint AtTestSimulation::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"); + + constexpr double stepMm = 1.0; + constexpr int maxSteps = 5000; + auto probe = pos; + for (int i = 0; i < maxSteps; ++i) { + probe += dir * stepMm; + if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(probe))) + return probe; + } + + throw std::invalid_argument("Particle does not intersect a sensitive detector volume"); +} + +bool AtTestSimulation::IsSensitiveVolume(const std::string &volumeName) +{ + return volumeName.find("drift_volume") != std::string::npos || volumeName.find("window") != std::string::npos || + volumeName.find("cell") != std::string::npos; +} + ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h index 3f2727968..c52827fb7 100644 --- a/AtDigitization/AtTestSimulation.h +++ b/AtDigitization/AtTestSimulation.h @@ -2,9 +2,11 @@ #define AtTestSimulation_h #include "AtSimParticleCollector.h" +#include "AtMCTrack.h" #include "AtSimpleSimulation.h" // for AtSimpleSimulation #include // for THashConsistencyHolder, ClassDefOver... +#include #include "FairTask.h" @@ -12,6 +14,7 @@ #include // for move #include +class AtTpc; class FairPrimaryGenerator; class TBuffer; class TClass; @@ -30,7 +33,9 @@ class AtTestSimulation : public FairTask { protected: std::unique_ptr fSimulation{nullptr}; //! FairPrimaryGenerator *fPrimGen{nullptr}; //! + AtTpc *fDetector{nullptr}; //! AtSimParticleCollector fCollector; //! + TClonesArray *fMCTrackArray{nullptr}; //! // Owned MCEventHeader required by FairPrimaryGenerator::GenerateEvent() std::unique_ptr fMCHeader; //! @@ -46,13 +51,22 @@ class AtTestSimulation : public FairTask { * It must remain valid for the lifetime of the task. */ void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } + void SetDetector(AtTpc *detector) { fDetector = detector; } virtual InitStatus Init() override; virtual void Exec(Option_t *option) override; virtual void Finish() override {} AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } - ClassDefOverride(AtTestSimulation, 1); +private: + void RegisterMCTrackBranch(); + void FillMCTracks(); + bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, + bool postSensitive, bool entering, bool exiting); + ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom) const; + static bool IsSensitiveVolume(const std::string &volumeName); + + ClassDefOverride(AtTestSimulation, 2); }; #endif /* AtTestSimulation_h */ diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index ac395f480..fe85b54c6 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 ) 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/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md index e6efe72fc..689a97cc7 100644 --- a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -24,18 +24,25 @@ This document is the working plan for that effort. It records the current code s - 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 is still wrong with the current attempt - The SimpleSim validation macros originally introduced their own macro-local `SimpleSimTask` class instead of using `AtTestSimulation`. - That duplication has now been removed in this directory, but the migration still has to be validated against real physics parity with the Geant side. - The local plan document had drifted into a mix of intended architecture, stale assumptions, and partially outdated physics description. +- The fixed validation macro now reaches the detector-side reaction trigger and produces both + beam-event and reaction-event `AtTpcPoint` output through the shared detector path. ### Physics/configuration inconsistency to resolve during validation @@ -96,11 +103,10 @@ If the migration is still awkward after using `AtTestSimulation`, record the mis ## Immediate Work Items -1. Rewrite the local docs so they match the actual code in the branch. -2. Re-run the local validation workflow and inspect output structure and physics behavior. -3. Align the Geant and SimpleSim macros to the same reaction definition before trusting comparison plots. -4. Update the migration draft with what worked and what did not. -5. Only after the migration path is stable should any framework-wide documentation be proposed. +1. Re-run the local validation workflow and inspect output structure and physics behavior. +2. Align the Geant and SimpleSim macros to the same reaction definition before trusting comparison plots. +3. Update the migration draft with what worked and what did not. +4. Only after the migration path is stable should any framework-wide documentation be proposed. ## Acceptance Criteria for a Viable Migration Strategy @@ -113,3 +119,28 @@ A migration strategy is viable only if all of the following are true: - 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, + - SimpleSim callback transport outside the legacy direct-hit path. +- `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. +- `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`. diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md index 6e33dbb9e..c99b8beca 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md @@ -142,6 +142,31 @@ Even if the same `FairPrimaryGenerator` object is reused, that is not enough. Th 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 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 @@ -415,12 +440,14 @@ Do not use physics plots to hide a broken contract. Current verified state: - the custom collector can reuse generator syntax -- the current fixed SimpleSim macro can be made to run with `TGeant3` -- the current collector-based bridge can call `AtSimpleSimulation` +- `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: -- the collector-based bridge does not preserve the detector-side `AtVertexPropagator` contract -- therefore it is not yet a true transport replacement - -That blocker should be addressed in code before any migration guide is written. +- 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 index 57e28e2b0..27d4aff5a 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -32,6 +32,18 @@ At present, this is the first hook that should be used when adapting an existing This directory has now exercised that hook in the interpreted validation macros: `AtTestSimulation` can replace the macro-local transport task and still produce the standard `AtTpcPoint` branch. +Current branch status: + +- `AtTestSimulation` now has a detector-coupled mode that feeds shared `AtTpc` step logic. +- The fixed validation macro uses that detector-coupled mode. +- Beam-event `AtTpcPoint` output is now produced through the detector path. +- In the fixed validation case, detector-side reaction handoff now reaches `AtTPC2Body` with a + non-zero residual beam energy and produces reaction-event `AtTpcPoint` output. +- In the kinematic validation case, the adapter now also restores the canonical `MCTrack` truth + branch while preserving Geant-style reaction-event IDs (`track 0` scattered ion, `track 1` + recoil proton), so downstream truth consumers such as `visualizeKinematic.C` can find the + transported reaction products without a placeholder beam slot. + ## Draft Migration Recipe ### 1. Start from a working Geant macro @@ -113,5 +125,10 @@ Current local result: - the macro-local transport task was removed, - `AtTestSimulation` runs successfully in the validation macros, -- the SimpleSim output file contains the expected `AtTpcPoint` branch, -- the next unresolved issue is physics/configuration parity with the Geant comparison macros, not the transport hook itself. +- the detector-coupled path now produces non-empty beam-event `AtTpcPoint` output, +- the fixed validation macro now also produces detector-triggered reaction-event output through the + shared path, +- the kinematic validation macro now writes the canonical `MCTrack` branch expected by local + visualization tooling with Geant-style reaction-event IDs, +- the next unresolved issue is broader physics parity against the Geant comparison macros, not the + detector contract itself. diff --git a/macro/Simulation/AtSimValidation/compareFixed.C b/macro/Simulation/AtSimValidation/compareFixed.C index 2e58474ef..599921437 100644 --- a/macro/Simulation/AtSimValidation/compareFixed.C +++ b/macro/Simulation/AtSimValidation/compareFixed.C @@ -1,4 +1,6 @@ +#include + namespace { struct PointSample { double x{}; @@ -17,8 +19,8 @@ struct TrackData { }; struct EventTracks { - TrackData proton; - TrackData helium; + TrackData scatteredIon; + TrackData recoilProton; }; struct PlotData { @@ -49,13 +51,16 @@ TrackData BuildTrackData(int trackID, TClonesArray *points) return out; } -bool IsReactionEvent(const std::vector &trackIDs) { return trackIDs.size() >= 2; } +bool IsSelectedPrimaryTrack(AtMCTrack *track, int pdgCode) +{ + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == pdgCode; +} PlotData LoadPlotData(const TString &fileName, const char *tag) { PlotData out; - out.protonBragg = new TProfile(Form("hProtonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.heliumBragg = new TProfile(Form("hHeliumBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.protonBragg = new TProfile(Form("hRecoilProtonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.heliumBragg = new TProfile(Form("hScatteredIonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); out.trackLength = new TH1D(Form("hTrackLength_%s", tag), ";Track length [mm];Tracks", 120, 0., 500.); auto *file = TFile::Open(fileName); @@ -72,35 +77,34 @@ PlotData LoadPlotData(const TString &fileName, const char *tag) } TClonesArray *pointArray = nullptr; + TClonesArray *trackArray = nullptr; tree->SetBranchAddress("AtTpcPoint", &pointArray); + tree->SetBranchAddress("MCTrack", &trackArray); for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { tree->GetEntry(iEvent); - if (!pointArray || pointArray->GetEntriesFast() == 0) + if (!pointArray || !trackArray || pointArray->GetEntriesFast() == 0) continue; - std::vector trackIDs; - std::map seenTrack; - for (int i = 0; i < pointArray->GetEntriesFast(); ++i) { - auto *pt = dynamic_cast(pointArray->At(i)); - if (!pt) - continue; - if (!seenTrack[pt->GetTrackID()]) { - seenTrack[pt->GetTrackID()] = true; - trackIDs.push_back(pt->GetTrackID()); - } + int scatteredIonTrackID = -1; + int recoilProtonTrackID = -1; + for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(trackArray->At(i)); + if (scatteredIonTrackID < 0 && IsSelectedPrimaryTrack(track, 1000060160)) + scatteredIonTrackID = i; + if (recoilProtonTrackID < 0 && IsSelectedPrimaryTrack(track, 2212)) + recoilProtonTrackID = i; } - std::sort(trackIDs.begin(), trackIDs.end()); - if (!IsReactionEvent(trackIDs)) + if (scatteredIonTrackID < 0 || recoilProtonTrackID < 0) continue; EventTracks event; - event.proton = BuildTrackData(trackIDs.front(), pointArray); - event.helium = BuildTrackData(trackIDs.back(), pointArray); + event.scatteredIon = BuildTrackData(scatteredIonTrackID, pointArray); + event.recoilProton = BuildTrackData(recoilProtonTrackID, pointArray); out.events.push_back(event); - for (const auto *track : {&event.proton, &event.helium}) { + for (const auto *track : {&event.scatteredIon, &event.recoilProton}) { if (track->points.empty()) continue; @@ -110,7 +114,7 @@ PlotData LoadPlotData(const TString &fileName, const char *tag) out.xz.emplace_back(point.z, point.x); } - auto *profile = (track == &event.proton) ? out.protonBragg : out.heliumBragg; + auto *profile = (track == &event.recoilProton) ? out.protonBragg : out.heliumBragg; for (size_t i = 1; i < track->points.size(); ++i) { const auto &prev = track->points[i - 1]; const auto &curr = track->points[i]; @@ -159,7 +163,7 @@ void DrawTrackOverlay(const PlotData &data, bool drawProton, Color_t color) { int count = 0; for (const auto &event : data.events) { - const auto &track = drawProton ? event.proton : event.helium; + const auto &track = drawProton ? event.recoilProton : event.scatteredIon; if (track.points.empty()) continue; auto *line = new TPolyLine3D(track.points.size()); @@ -206,7 +210,7 @@ void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simple DrawTrackOverlay(simple, true, kRed + 1); { auto *legend = new TLegend(0.62, 0.78, 0.9, 0.9); - legend->AddEntry((TObject *)nullptr, "Proton 3D overlay", ""); + legend->AddEntry((TObject *)nullptr, "Recoil proton 3D overlay", ""); legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); legend->Draw(); @@ -219,7 +223,7 @@ void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simple DrawTrackOverlay(simple, false, kRed + 1); { auto *legend = new TLegend(0.62, 0.78, 0.9, 0.9); - legend->AddEntry((TObject *)nullptr, "He-4 3D overlay", ""); + legend->AddEntry((TObject *)nullptr, "Scattered 16C 3D overlay", ""); legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); legend->Draw(); @@ -257,7 +261,7 @@ void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simple protonLegend->Draw(); canvas->cd(6); - geant.heliumBragg->SetTitle("Bragg curve: He-4"); + geant.heliumBragg->SetTitle("Bragg curve: scattered 16C"); geant.heliumBragg->Draw("hist"); simple.heliumBragg->Draw("hist same"); auto *heliumLegend = new TLegend(0.6, 0.78, 0.9, 0.9); diff --git a/macro/Simulation/AtSimValidation/compareKinematic.C b/macro/Simulation/AtSimValidation/compareKinematic.C index 9a8c60aab..3b35f3158 100644 --- a/macro/Simulation/AtSimValidation/compareKinematic.C +++ b/macro/Simulation/AtSimValidation/compareKinematic.C @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -34,13 +35,13 @@ struct TrackData { }; struct PlotData { - std::vector> protonKinematics; - std::vector> heliumKinematics; + std::vector> recoilProtonKinematics; + std::vector> scatteredIonKinematics; std::vector> xy; - TProfile *protonBragg{nullptr}; - TProfile *heliumBragg{nullptr}; - TH1D *protonRange{nullptr}; - TH1D *heliumRange{nullptr}; + TProfile *recoilProtonBragg{nullptr}; + TProfile *scatteredIonBragg{nullptr}; + TH1D *recoilProtonRange{nullptr}; + TH1D *scatteredIonRange{nullptr}; }; bool SortByLength(const PointSample &lhs, const PointSample &rhs) { return lhs.length < rhs.length; } @@ -74,13 +75,18 @@ void FillBragg(TProfile *profile, const TrackData &track) } } +bool IsSelectedPrimaryTrack(AtMCTrack *track, int pdgCode) +{ + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == pdgCode; +} + PlotData LoadPlotData(const TString &fileName, const char *tag) { PlotData out; - out.protonBragg = new TProfile(Form("hProtonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.heliumBragg = new TProfile(Form("hHeliumBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.protonRange = new TH1D(Form("hProtonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); - out.heliumRange = new TH1D(Form("hHeliumRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); + out.recoilProtonBragg = new TProfile(Form("hRecoilProtonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.scatteredIonBragg = new TProfile(Form("hScatteredIonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); + out.recoilProtonRange = new TH1D(Form("hRecoilProtonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); + out.scatteredIonRange = new TH1D(Form("hScatteredIonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); auto *file = TFile::Open(fileName); if (!file || file->IsZombie()) { @@ -96,49 +102,50 @@ PlotData LoadPlotData(const TString &fileName, const char *tag) } TClonesArray *pointArray = nullptr; + TClonesArray *trackArray = nullptr; tree->SetBranchAddress("AtTpcPoint", &pointArray); + tree->SetBranchAddress("MCTrack", &trackArray); for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { tree->GetEntry(iEvent); - if (!pointArray || pointArray->GetEntriesFast() == 0) + if (!pointArray || !trackArray || pointArray->GetEntriesFast() == 0) continue; - std::vector trackIDs; - std::map seenTrack; - for (int i = 0; i < pointArray->GetEntriesFast(); ++i) { - auto *pt = dynamic_cast(pointArray->At(i)); - if (!pt) - continue; - if (!seenTrack[pt->GetTrackID()]) { - seenTrack[pt->GetTrackID()] = true; - trackIDs.push_back(pt->GetTrackID()); - } + int scatteredIonTrackID = -1; + int recoilProtonTrackID = -1; + for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { + auto *track = dynamic_cast(trackArray->At(i)); + if (scatteredIonTrackID < 0 && IsSelectedPrimaryTrack(track, 1000060160)) + scatteredIonTrackID = i; + if (recoilProtonTrackID < 0 && IsSelectedPrimaryTrack(track, 2212)) + recoilProtonTrackID = i; } - std::sort(trackIDs.begin(), trackIDs.end()); - if (trackIDs.size() < 2) + if (scatteredIonTrackID < 0 || recoilProtonTrackID < 0) continue; - auto proton = BuildTrackData(trackIDs.front(), pointArray); - auto helium = BuildTrackData(trackIDs.back(), pointArray); - if (proton.points.empty() || helium.points.empty()) + auto scatteredIon = BuildTrackData(scatteredIonTrackID, pointArray); + auto recoilProton = BuildTrackData(recoilProtonTrackID, pointArray); + if (scatteredIon.points.empty() || recoilProton.points.empty()) continue; - const auto &protonFirst = proton.points.front(); - const auto &heliumFirst = helium.points.front(); - out.protonKinematics.emplace_back(std::sqrt(protonFirst.px * protonFirst.px + protonFirst.py * protonFirst.py), - protonFirst.pz); - out.heliumKinematics.emplace_back(std::sqrt(heliumFirst.px * heliumFirst.px + heliumFirst.py * heliumFirst.py), - heliumFirst.pz); - - out.protonRange->Fill(proton.points.back().z); - out.heliumRange->Fill(helium.points.back().z); - FillBragg(out.protonBragg, proton); - FillBragg(out.heliumBragg, helium); - - for (const auto &point : proton.points) + const auto &recoilProtonFirst = recoilProton.points.front(); + const auto &scatteredIonFirst = scatteredIon.points.front(); + out.recoilProtonKinematics.emplace_back( + std::sqrt(recoilProtonFirst.px * recoilProtonFirst.px + recoilProtonFirst.py * recoilProtonFirst.py), + recoilProtonFirst.pz); + out.scatteredIonKinematics.emplace_back( + std::sqrt(scatteredIonFirst.px * scatteredIonFirst.px + scatteredIonFirst.py * scatteredIonFirst.py), + scatteredIonFirst.pz); + + out.recoilProtonRange->Fill(recoilProton.points.back().z); + out.scatteredIonRange->Fill(scatteredIon.points.back().z); + FillBragg(out.recoilProtonBragg, recoilProton); + FillBragg(out.scatteredIonBragg, scatteredIon); + + for (const auto &point : recoilProton.points) out.xy.emplace_back(point.x, point.y); - for (const auto &point : helium.points) + for (const auto &point : scatteredIon.points) out.xy.emplace_back(point.x, point.y); } @@ -181,19 +188,19 @@ void compareKinematic(TString geantFile = "./data/geant4_kinematic.root", auto geant = LoadPlotData(geantFile, "g4"); auto simple = LoadPlotData(simpleFile, "sim"); - StyleProfile(geant.protonBragg, kBlue + 1, 1); - StyleProfile(simple.protonBragg, kRed + 1, 2); - StyleProfile(geant.heliumBragg, kBlue + 1, 1); - StyleProfile(simple.heliumBragg, kRed + 1, 2); - StyleRange(geant.protonRange, kBlue + 1, 1); - StyleRange(simple.protonRange, kRed + 1, 2); - StyleRange(geant.heliumRange, kBlue + 1, 3); - StyleRange(simple.heliumRange, kRed + 1, 4); - - auto *protonG4 = MakeGraph(geant.protonKinematics, "protonG4", kBlue + 1); - auto *protonSim = MakeGraph(simple.protonKinematics, "protonSim", kRed + 1); - auto *heliumG4 = MakeGraph(geant.heliumKinematics, "heliumG4", kBlue + 1); - auto *heliumSim = MakeGraph(simple.heliumKinematics, "heliumSim", kRed + 1); + StyleProfile(geant.recoilProtonBragg, kBlue + 1, 1); + StyleProfile(simple.recoilProtonBragg, kRed + 1, 2); + StyleProfile(geant.scatteredIonBragg, kBlue + 1, 1); + StyleProfile(simple.scatteredIonBragg, kRed + 1, 2); + StyleRange(geant.recoilProtonRange, kBlue + 1, 1); + StyleRange(simple.recoilProtonRange, kRed + 1, 2); + StyleRange(geant.scatteredIonRange, kBlue + 1, 3); + StyleRange(simple.scatteredIonRange, kRed + 1, 4); + + auto *protonG4 = MakeGraph(geant.recoilProtonKinematics, "protonG4", kBlue + 1); + auto *protonSim = MakeGraph(simple.recoilProtonKinematics, "protonSim", kRed + 1); + auto *ionG4 = MakeGraph(geant.scatteredIonKinematics, "ionG4", kBlue + 1); + auto *ionSim = MakeGraph(simple.scatteredIonKinematics, "ionSim", kRed + 1); auto *xyG4 = MakeGraph(geant.xy, "xyG4K", kBlue + 1); auto *xySim = MakeGraph(simple.xy, "xySimK", kRed + 1); @@ -212,45 +219,45 @@ void compareKinematic(TString geantFile = "./data/geant4_kinematic.root", protonLegend->Draw(); canvas->cd(2); - auto *heliumMg = new TMultiGraph(); - heliumMg->SetTitle("He-4 kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); - heliumMg->Add(heliumG4, "P"); - heliumMg->Add(heliumSim, "P"); - heliumMg->Draw("A"); - auto *heliumLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - heliumLegend->AddEntry(heliumG4, "Geant4", "p"); - heliumLegend->AddEntry(heliumSim, "SimpleSim", "p"); - heliumLegend->Draw(); + auto *ionMg = new TMultiGraph(); + ionMg->SetTitle("Scattered 16C kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); + ionMg->Add(ionG4, "P"); + ionMg->Add(ionSim, "P"); + ionMg->Draw("A"); + auto *ionLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + ionLegend->AddEntry(ionG4, "Geant4", "p"); + ionLegend->AddEntry(ionSim, "SimpleSim", "p"); + ionLegend->Draw(); canvas->cd(3); - geant.protonBragg->SetTitle("Bragg curve: proton"); - geant.protonBragg->Draw("hist"); - simple.protonBragg->Draw("hist same"); + geant.recoilProtonBragg->SetTitle("Bragg curve: recoil proton"); + geant.recoilProtonBragg->Draw("hist"); + simple.recoilProtonBragg->Draw("hist same"); auto *braggProtonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - braggProtonLegend->AddEntry(geant.protonBragg, "Geant4", "l"); - braggProtonLegend->AddEntry(simple.protonBragg, "SimpleSim", "l"); + braggProtonLegend->AddEntry(geant.recoilProtonBragg, "Geant4", "l"); + braggProtonLegend->AddEntry(simple.recoilProtonBragg, "SimpleSim", "l"); braggProtonLegend->Draw(); canvas->cd(4); - geant.heliumBragg->SetTitle("Bragg curve: He-4"); - geant.heliumBragg->Draw("hist"); - simple.heliumBragg->Draw("hist same"); - auto *braggHeliumLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - braggHeliumLegend->AddEntry(geant.heliumBragg, "Geant4", "l"); - braggHeliumLegend->AddEntry(simple.heliumBragg, "SimpleSim", "l"); - braggHeliumLegend->Draw(); + geant.scatteredIonBragg->SetTitle("Bragg curve: scattered 16C"); + geant.scatteredIonBragg->Draw("hist"); + simple.scatteredIonBragg->Draw("hist same"); + auto *braggIonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); + braggIonLegend->AddEntry(geant.scatteredIonBragg, "Geant4", "l"); + braggIonLegend->AddEntry(simple.scatteredIonBragg, "SimpleSim", "l"); + braggIonLegend->Draw(); canvas->cd(5); - geant.protonRange->SetTitle("Range distributions"); - geant.protonRange->Draw("hist"); - simple.protonRange->Draw("hist same"); - geant.heliumRange->Draw("hist same"); - simple.heliumRange->Draw("hist same"); + geant.recoilProtonRange->SetTitle("Range distributions"); + geant.recoilProtonRange->Draw("hist"); + simple.recoilProtonRange->Draw("hist same"); + geant.scatteredIonRange->Draw("hist same"); + simple.scatteredIonRange->Draw("hist same"); auto *rangeLegend = new TLegend(0.44, 0.64, 0.9, 0.9); - rangeLegend->AddEntry(geant.protonRange, "Geant4 proton", "l"); - rangeLegend->AddEntry(simple.protonRange, "SimpleSim proton", "l"); - rangeLegend->AddEntry(geant.heliumRange, "Geant4 He-4", "l"); - rangeLegend->AddEntry(simple.heliumRange, "SimpleSim He-4", "l"); + rangeLegend->AddEntry(geant.recoilProtonRange, "Geant4 proton", "l"); + rangeLegend->AddEntry(simple.recoilProtonRange, "SimpleSim proton", "l"); + rangeLegend->AddEntry(geant.scatteredIonRange, "Geant4 16C", "l"); + rangeLegend->AddEntry(simple.scatteredIonRange, "SimpleSim 16C", "l"); rangeLegend->Draw(); canvas->cd(6); diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index 3651dda90..9d68d2ff4 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -2,16 +2,16 @@ namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) { - constexpr Int_t z = 1; - constexpr Int_t a = 1; + 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 = 0.04333; + constexpr Double_t pz = 2.297 / a; constexpr Double_t beamExcitation = 0.0; - constexpr Double_t beamMass = 0.938272; - constexpr Double_t nominalEnergy = 1.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; auto *primGen = new FairPrimaryGenerator(); @@ -21,17 +21,17 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th ionGen->SetDoReaction(kTRUE); primGen->AddGenerator(ionGen); - std::vector Zp{1, 2, 1, 2}; - std::vector Ap{1, 4, 1, 4}; + 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{1.007276, 4.00260, 1.007276, 4.00260}; + 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 = 1.0; + 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); @@ -46,13 +46,13 @@ std::unique_ptr BuildSimpleSimulation(const TString &geoFile constexpr double heDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; - auto protonModel = std::make_shared(heDensity, material); - protonModel->SetProjectile(1, 1, 1.007276); - sim->AddModel(1, 1, protonModel, 1.007276); + auto carbonModel = std::make_shared(heDensity, material); + carbonModel->SetProjectile(16, 6, 16.014701); + sim->AddModel(6, 16, carbonModel, 16.014701); - auto heliumModel = std::make_shared(heDensity, material); - heliumModel->SetProjectile(4, 2, 4.002602); - sim->AddModel(2, 4, heliumModel, 4.002602); + auto protonModel = std::make_shared(heDensity, 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); @@ -99,6 +99,7 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed auto *simPrimGen = BuildElasticGenerator(thetaCms, thetaCms); auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryGenerator(simPrimGen); + simTask->SetDetector(tpc); run->AddTask(simTask); run->Init(); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C index 3e3a5e178..c15bd3211 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -6,16 +6,16 @@ namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) { - constexpr Int_t z = 1; - constexpr Int_t a = 1; + 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 = 0.04333; + constexpr Double_t pz = 2.297 / a; constexpr Double_t beamExcitation = 0.0; - constexpr Double_t beamMass = 0.938272; - constexpr Double_t nominalEnergy = 1.0; + constexpr Double_t beamMass = 16.014701; + constexpr Double_t nominalEnergy = 0.0; auto *primGen = new FairPrimaryGenerator(); @@ -25,17 +25,17 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th ionGen->SetDoReaction(kTRUE); primGen->AddGenerator(ionGen); - std::vector Zp{1, 2, 1, 2}; - std::vector Ap{1, 4, 1, 4}; + 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{1.007276, 4.00260, 1.007276, 4.00260}; + 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 = 1.0; + 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); @@ -50,13 +50,13 @@ std::unique_ptr BuildSimpleSimulation(const TString &geoFile constexpr double heDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; - auto protonModel = std::make_shared(heDensity, material); - protonModel->SetProjectile(1, 1, 1.007276); - sim->AddModel(1, 1, protonModel, 1.007276); + auto carbonModel = std::make_shared(heDensity, material); + carbonModel->SetProjectile(16, 6, 16.014701); + sim->AddModel(6, 16, carbonModel, 16.014701); - auto heliumModel = std::make_shared(heDensity, material); - heliumModel->SetProjectile(4, 2, 4.002602); - sim->AddModel(2, 4, heliumModel, 4.002602); + auto protonModel = std::make_shared(heDensity, 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); @@ -100,6 +100,7 @@ void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42) auto *simPrimGen = BuildElasticGenerator(0.0, 180.0); auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryGenerator(simPrimGen); + simTask->SetDetector(tpc); run->AddTask(simTask); auto *rtdb = run->GetRuntimeDb(); diff --git a/macro/Simulation/AtSimValidation/visualizeKinematic.C b/macro/Simulation/AtSimValidation/visualizeKinematic.C index 22f31c26e..e0a851cef 100644 --- a/macro/Simulation/AtSimValidation/visualizeKinematic.C +++ b/macro/Simulation/AtSimValidation/visualizeKinematic.C @@ -138,7 +138,7 @@ TGraph *BuildTheoryGraph(Int_t trackID) } } // namespace -void visualizeKinematic(TString inputFile = "./data/geant4_kinematic.root", Int_t selectedTrackID = 2, Int_t maxEvents = 8) +void visualizeKinematic(TString inputFile = "./data/simpleSim_kinematic.root", Int_t selectedTrackID = 2, Int_t maxEvents = 8) { gStyle->SetOptStat(0); From ddc85d486a7aab44d22104f6f108e4568691e3dd Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 15:28:26 -0400 Subject: [PATCH 09/18] Align SimpleSim detector entry points with Geant Add an explicit detector-entry submission path in AtTestSimulation so the SimpleSim adapter emits a zero-length, zero-loss AtTpc point at the actual sensitive-volume start state before the first propagated step. This restores the detector-side contract for tracks that start inside the active volume and removes the previous one-step Z offset in the first recorded point. Keep the transport and detector logic otherwise unchanged: the reaction handoff still fires at the same detector-side threshold, while beam and reaction products now begin with Geant-style entry points at z~=1 mm instead of one full step downstream. The remaining difference versus Geant is reduced to the sub-micron boundary bookkeeping level rather than a full transport-step mismatch. Add regression coverage for the new entry-point behavior in AtSimTest and verify the updated kinematic validation path still renders in visualizeKinematic.C. On the current 2-event spot check the visualizer reports 'Visualized track 2 (recoil proton)' and 'Drew 1 trajectories and 1 event points'. --- AtDigitization/AtSimTest.cxx | 32 ++++++++++++++++++++++++ AtDigitization/AtTestSimulation.cxx | 38 +++++++++++++++++++++++++++-- AtDigitization/AtTestSimulation.h | 2 ++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx index 3b0a3271f..53e5b537b 100644 --- a/AtDigitization/AtSimTest.cxx +++ b/AtDigitization/AtSimTest.cxx @@ -23,6 +23,8 @@ #include "AtELossModel.h" #include "AtMCTrack.h" +#include "AtTpc/AtTpc.h" +#include "AtVertexPropagator.h" #include #include @@ -309,3 +311,33 @@ TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) EXPECT_EQ(beam->GetPdgCode(), 2212); EXPECT_EQ(beam->GetMotherId(), -1); } + +TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) +{ + auto sim = std::make_unique(); + AtTestSimulation 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)); + + const bool keepTransporting = task.SubmitInitialSensitivePoint(0, 1000060160, true, pos, mom); + + 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/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index 375db3481..e38021d46 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -135,6 +135,10 @@ void AtTestSimulation::Exec(Option_t *) LOG(info) << "Simulating particle Z=" << Z << " A=" << A << " with initial pos=" << pos << " mm and mom=" << mom << " MeV/c"; if (fDetector != nullptr) { + const bool beamTrack = isBeamEvent && p.trackID == 0; + if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && + !SubmitInitialSensitivePoint(p.trackID, p.pdgCode, beamTrack, pos, mom)) + continue; bool seenSensitiveVolume = false; fSimulation->TransportParticle( Z, A, pos, mom, @@ -144,10 +148,10 @@ void AtTestSimulation::Exec(Option_t *) const bool postSensitive = IsSensitiveVolume(step.postVolumeName); const bool entering = !seenSensitiveVolume && postSensitive; const bool exiting = seenSensitiveVolume && !postSensitive; - const bool beamTrack = isBeamEvent && trackID == 0; + const bool isBeamTrack = isBeamEvent && trackID == 0; if (postSensitive) seenSensitiveVolume = true; - return ProcessDetectorStep(step, trackID, beamTrack, preSensitive, postSensitive, entering, exiting); + return ProcessDetectorStep(step, trackID, isBeamTrack, preSensitive, postSensitive, entering, exiting); }); } else { fSimulation->SimulateParticle(Z, A, pos, mom); @@ -160,6 +164,36 @@ void AtTestSimulation::Exec(Option_t *) } } +bool AtTestSimulation::SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const XYZPoint &pos, + const PxPyPzEVector &mom) +{ + if (fDetector == nullptr) + return true; + + AtTpc::StepState detectorStep; + detectorStep.trackID = trackID; + detectorStep.pdg = pdg; + detectorStep.volumeName = fSimulation->GetVolumeNameAt(pos).c_str(); + detectorStep.volumeID = kAtTpc; + detectorStep.detCopyID = 0; + detectorStep.beamTrack = beamTrack; + detectorStep.entering = true; + detectorStep.exiting = false; + detectorStep.stopping = (mom.E() - mom.M() <= 1e-3); + detectorStep.disappeared = false; + detectorStep.energyLoss = 0.0; + detectorStep.timeNs = 0.0; + detectorStep.trackLength = 0.0; + detectorStep.totalEnergy = mom.E() / 1000.; + detectorStep.trackMass = mom.M() / 1000.; + detectorStep.pos.SetXYZT(pos.X() / 10., pos.Y() / 10., pos.Z() / 10., 0.0); + detectorStep.mom.SetXYZT(mom.Px() / 1000., mom.Py() / 1000., mom.Pz() / 1000., mom.E() / 1000.); + detectorStep.posOut = detectorStep.pos; + detectorStep.momOut = detectorStep.mom; + + return !fDetector->ProcessStep(detectorStep); +} + bool AtTestSimulation::ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, bool postSensitive, bool entering, bool exiting) { diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h index c52827fb7..46356f318 100644 --- a/AtDigitization/AtTestSimulation.h +++ b/AtDigitization/AtTestSimulation.h @@ -61,6 +61,8 @@ class AtTestSimulation : public FairTask { private: void RegisterMCTrackBranch(); void FillMCTracks(); + bool SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const ROOT::Math::XYZPoint &pos, + const ROOT::Math::PxPyPzEVector &mom); bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, bool postSensitive, bool entering, bool exiting); ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom) const; From b95f2be896631af5819481a400570cc3b0fd7fd4 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 17:41:14 -0400 Subject: [PATCH 10/18] Stabilize SimpleSim validation migration Document the validated migration path from Geant-style macros to AtTestSimulation-backed SimpleSim transport in the AtSimValidation area. Make the detector stop transport when tracks exit the active reaction volume so Geant and SimpleSim follow the same wall-bounded physics assumption for this geometry. Add detector coverage for the active-volume exit stop and keep the existing shared detector-step contract exercised through tests. Update the fixed and kinematic comparison macros to truth-match by reaction index, keep zero-point events in the bookkeeping, and report usable, generator-only, and incomplete event counts separately. This commit captures the current working state of the SimpleSim migration effort, including the previously validated adapter, propagator, and generator-side changes already present in the tree. --- AtDetectors/AtTpc/AtTpc.cxx | 5 + AtDetectors/AtTpc/AtTpcTest.cxx | 11 + AtDigitization/AtSimpleSimulation.cxx | 65 +- AtDigitization/AtSimpleSimulation.h | 6 + AtDigitization/AtTestSimulation.cxx | 18 +- AtGenerators/AtTPC2Body.cxx | 18 +- AtTools/AtPropagator.cxx | 4 +- AtTools/AtPropagatorTest.cxx | 33 + .../AtSimValidation/AtSimValidationPlan.md | 55 +- .../AtSimValidation/AtSimpleSimHookPlan.md | 6 + .../AtSimpleSimMigrationDraft.md | 103 ++- .../Simulation/AtSimValidation/compareFixed.C | 616 ++++++++++++----- .../AtSimValidation/compareKinematic.C | 632 +++++++++++++----- 13 files changed, 1207 insertions(+), 365 deletions(-) diff --git a/AtDetectors/AtTpc/AtTpc.cxx b/AtDetectors/AtTpc/AtTpc.cxx index d8e901d48..f497aa8b5 100644 --- a/AtDetectors/AtTpc/AtTpc.cxx +++ b/AtDetectors/AtTpc/AtTpc.cxx @@ -230,6 +230,11 @@ bool AtTpc::ProcessStep(const StepState &step) return true; } + // For this detector geometry, leaving the active gas means hitting the surrounding wall. + // The validation transport should terminate at the first exit from the reaction volume. + if (step.exiting && IsReactionVolume(fVolName)) + return true; + return false; } diff --git a/AtDetectors/AtTpc/AtTpcTest.cxx b/AtDetectors/AtTpc/AtTpcTest.cxx index a8ebb1e83..b14657303 100644 --- a/AtDetectors/AtTpc/AtTpcTest.cxx +++ b/AtDetectors/AtTpc/AtTpcTest.cxx @@ -112,6 +112,17 @@ TEST_F(AtTpcTest, ReactionEventTrackZeroExitDoesNotResetVertexState) EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 1.0); } +TEST_F(AtTpcTest, ExitingReactionVolumeStopsTransport) +{ + auto step = MakeStep(1, 2212, "drift_volume", 0.0, 0.95, 20.0); + step.exiting = true; + step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); + + const bool stopTransport = detector.ProcessStep(step); + + EXPECT_TRUE(stopTransport); +} + TEST_F(AtTpcTest, NonBeamTracksUseStoredMetadata) { AtVertexPropagator::Instance()->SetTrackEnergy(1, 7.5); diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index 8e1e6089a..aaa411aa3 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -12,6 +12,7 @@ #include // for TClonesArray #include +#include #include #include #include // for TObject @@ -29,6 +30,12 @@ using XYZPoint = ROOT::Math::XYZPoint; using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; +namespace { +constexpr int kMaxCurvedTransportSteps = 200000; +constexpr int kMaxMinStepCurvedSteps = 4096; +constexpr double kMinStepGuardScale = 1.01; +} + // --------------------------------------------------------------------------- // Thin wrapper so a shared_ptr can be passed to AtPropagator // (which requires a unique_ptr). @@ -67,11 +74,17 @@ AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) if (gGeoManager == nullptr) LOG(fatal) << "Failed to load geometry file " << geoFile << " " << geo; + + fGeoManager = gGeoManager; + fNavigator = nullptr; } AtSimpleSimulation::AtSimpleSimulation() { if (gGeoManager == nullptr) LOG(fatal) << "No geometry file loaded!"; + + fGeoManager = gGeoManager; + fNavigator = nullptr; } bool AtSimpleSimulation::ParticleID::operator<(const ParticleID &other) const @@ -91,7 +104,15 @@ TGeoVolume *AtSimpleSimulation::GetVolume(const XYZPoint &point) auto pointCm = point / 10.; { std::lock_guard lock(fGeoMutex); - TGeoNode *node = gGeoManager->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); + if (gGeoManager == nullptr) + return nullptr; + + if (fGeoManager != gGeoManager || fNavigator == nullptr) { + fGeoManager = gGeoManager; + fNavigator = fGeoManager->AddNavigator(); + } + + TGeoNode *node = fNavigator->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); if (node == nullptr) { return nullptr; } @@ -179,11 +200,20 @@ AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &i AtTools::AtRK4AdaptiveStepper stepper; stepper.fInitialStep = fMaxPropStep; stepper.fMaxStep = fMaxPropStep; + const double minAcceptedStepMm = stepper.fMinStep * 1e3 * kMinStepGuardScale; double length = 0; + int numSteps = 0; + int minStepSteps = 0; while (IsInVolume("drift_volume", prop.GetPosition())) { + if (++numSteps > kMaxCurvedTransportSteps) { + LOG(warning) << "Aborting curved SimpleSim track after " << numSteps + << " steps without leaving drift_volume"; + break; + } + double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - if (KE <= 1e-3) + if (KE <= fCurvedStopTol) break; auto mom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); @@ -207,6 +237,16 @@ AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &i eLoss = 0; // magnetic field does no work double stepDist = (prop.GetPosition() - state.fLastPos).R(); // mm + if (stepDist <= minAcceptedStepMm || state.hUsed <= stepper.fMinStep * kMinStepGuardScale) { + if (++minStepSteps > kMaxMinStepCurvedSteps) { + LOG(warning) << "Aborting curved SimpleSim track after " << minStepSteps + << " minimum-size steps at position " << prop.GetPosition() << " with KE " + << KE_after << " MeV"; + break; + } + } else { + minStepSteps = 0; + } length += stepDist; auto newMom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); @@ -270,11 +310,20 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X AtTools::AtRK4AdaptiveStepper stepper; stepper.fInitialStep = fMaxPropStep; stepper.fMaxStep = fMaxPropStep; + const double minAcceptedStepMm = stepper.fMinStep * 1e3 * kMinStepGuardScale; double length = 0; + int numSteps = 0; + int minStepSteps = 0; while (GetVolume(prop.GetPosition()) != nullptr) { + if (++numSteps > kMaxCurvedTransportSteps) { + LOG(warning) << "Aborting curved SimpleSim transport track after " << numSteps + << " steps without leaving the geometry"; + break; + } + double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - if (KE <= 1e-3) + if (KE <= fCurvedStopTol) break; auto momBefore = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); @@ -300,6 +349,16 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X eLoss = 0; double stepDist = (posAfter - state.fLastPos).R(); + if (stepDist <= minAcceptedStepMm || state.hUsed <= stepper.fMinStep * kMinStepGuardScale) { + if (++minStepSteps > kMaxMinStepCurvedSteps) { + LOG(warning) << "Aborting curved SimpleSim transport track after " << minStepSteps + << " minimum-size steps at position " << posAfter << " with KE " << KE_after + << " MeV for PDG " << pdg << " track " << fTrackID; + break; + } + } else { + minStepSteps = 0; + } length += stepDist; if (callback) { diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index d4129c79f..6e08bead8 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -22,6 +22,8 @@ namespace AtTools { class AtELossModel; } // namespace AtTools class TGeoVolume; +class TGeoManager; +class TGeoNavigator; class AtSpaceChargeModel; /** @@ -57,10 +59,13 @@ class AtSimpleSimulation { SpaceChargeModel fSCModel{nullptr}; double fDistStep{1.}; // Distance step in mm for straight-line propagation std::mutex fGeoMutex; + TGeoManager *fGeoManager{nullptr}; + TGeoNavigator *fNavigator{nullptr}; XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) + double fCurvedStopTol{0.1}; ///< Curved-track stop tolerance in MeV; avoids pathological late stopping tails // Variables to across an entire event static thread_local int fTrackID; @@ -111,6 +116,7 @@ class AtSimpleSimulation { void SetMagneticField(XYZVector bField) { fBField = bField; } ///< Magnetic field in T /// Maximum step size (m) for the RK4 adaptive stepper in curved-track mode (default: 1e-3 m = 1 mm). void SetMaxPropagationStep(double stepM) { fMaxPropStep = stepM; } + void SetCurvedStopTolerance(double stopTolMeV) { fCurvedStopTol = stopTolMeV; } void NewEvent(); diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index e38021d46..cacec6d1c 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -139,19 +139,19 @@ void AtTestSimulation::Exec(Option_t *) if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && !SubmitInitialSensitivePoint(p.trackID, p.pdgCode, beamTrack, pos, mom)) continue; - bool seenSensitiveVolume = false; fSimulation->TransportParticle( Z, A, pos, mom, - [this, trackID = p.trackID, isBeamEvent, seenSensitiveVolume](const AtSimpleSimulation::TransportStep &step - ) mutable { - const bool preSensitive = seenSensitiveVolume || IsSensitiveVolume(step.preVolumeName); + [this, trackID = p.trackID, isBeamEvent](const AtSimpleSimulation::TransportStep &step) { + const bool preSensitive = IsSensitiveVolume(step.preVolumeName); const bool postSensitive = IsSensitiveVolume(step.postVolumeName); - const bool entering = !seenSensitiveVolume && postSensitive; - const bool exiting = seenSensitiveVolume && !postSensitive; + const bool entering = !preSensitive && postSensitive; + const bool exiting = preSensitive && !postSensitive; const bool isBeamTrack = isBeamEvent && trackID == 0; - if (postSensitive) - seenSensitiveVolume = true; - return ProcessDetectorStep(step, trackID, isBeamTrack, preSensitive, postSensitive, entering, exiting); + const bool keepTransporting = + ProcessDetectorStep(step, trackID, isBeamTrack, preSensitive, postSensitive, entering, exiting); + if (exiting && !postSensitive) + return false; + return keepTransporting; }); } else { fSimulation->SimulateParticle(Z, A, pos, mom); diff --git a/AtGenerators/AtTPC2Body.cxx b/AtGenerators/AtTPC2Body.cxx index 841b0be89..48e6b0cb4 100644 --- a/AtGenerators/AtTPC2Body.cxx +++ b/AtGenerators/AtTPC2Body.cxx @@ -335,14 +335,22 @@ Bool_t AtTPC2Body::GenerateReaction(FairPrimaryGenerator *primGen) "< 0.0 && BeamPos.Perp() / BeamPos.Mag() < 1e-3) { + beamTheta = 0.0; + beamPhi = 0.0; + } + LOG(debug) << " Beam Theta (Mom) : " << beamTheta * 180.0 / TMath::Pi(); + LOG(debug) << " Beam Phi (Mom) : " << beamPhi * 180.0 / TMath::Pi(); Double_t thetaLab1, phiLab1, thetaLab2, phiLab2; auto EulerTransformer = std::make_unique(); - EulerTransformer->SetBeamDirectionAtVertexTheta(BeamPos.Theta()); - EulerTransformer->SetBeamDirectionAtVertexPhi(BeamPos.Phi()); + EulerTransformer->SetBeamDirectionAtVertexTheta(beamTheta); + EulerTransformer->SetBeamDirectionAtVertexPhi(beamPhi); EulerTransformer->SetThetaInBeamSystem(Ang.at(0)); EulerTransformer->SetPhiInBeamSystem(phiBeam1); diff --git a/AtTools/AtPropagator.cxx b/AtTools/AtPropagator.cxx index 98f43d302..b35c18ca5 100644 --- a/AtTools/AtPropagator.cxx +++ b/AtTools/AtPropagator.cxx @@ -339,7 +339,7 @@ AtPropagator::StepState AtRK4AdaptiveStepper::Step(const AtPropagator::StepState // 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 * 1e-3, p / fReltoSImom); + 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) }; @@ -359,7 +359,7 @@ AtPropagator::StepState AtRK4AdaptiveStepper::Step(const AtPropagator::StepState // Calculate kx and kp for each stage // build stage 0 - calc_k(x_SI, p0, 0); + calc_k(x_SI, p_SI, 0); // build stage 1 auto [x1, p1] = calc_xp(1); diff --git a/AtTools/AtPropagatorTest.cxx b/AtTools/AtPropagatorTest.cxx index 10c0ab2e1..9579f3477 100644 --- a/AtTools/AtPropagatorTest.cxx +++ b/AtTools/AtPropagatorTest.cxx @@ -366,3 +366,36 @@ TEST(AtPropagatorTest, PropagateToPoint_Field) 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, std::move(elossModel)); + 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/macro/Simulation/AtSimValidation/AtSimValidationPlan.md b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md index 689a97cc7..9923ffc2d 100644 --- a/macro/Simulation/AtSimValidation/AtSimValidationPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimValidationPlan.md @@ -4,7 +4,7 @@ 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 is the working plan for that effort. It records the current code state, how the `AtSimpleSim` hook works today, what is still ad hoc, and what must be implemented and verified before this can be described as a supported migration strategy. +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 @@ -36,17 +36,16 @@ This document is the working plan for that effort. It records the current code s - The fixed SimpleSim validation macro now uses the detector-coupled adapter path instead of the collector-only branch writer. -### What is still wrong with the current attempt +### 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 now been removed in this directory, but the migration still has to be validated against real physics parity with the Geant side. -- The local plan document had drifted into a mix of intended architecture, stale assumptions, and partially outdated physics description. -- The fixed validation macro now reaches the detector-side reaction trigger and produces both - beam-event and reaction-event `AtTpcPoint` output through the shared detector path. - -### Physics/configuration inconsistency to resolve during validation - -This directory should only be used for side-by-side validation once both paths are running the same physics setup. Earlier versions of the local note described proton-on-He elastic scattering, while the current Geant validation macros in this directory are configured around the `16C + p` example pattern. The SimpleSim side must be checked against the actual Geant configuration being used before any comparison plots are treated as meaningful validation. +- 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 @@ -69,7 +68,7 @@ One important implementation detail uncovered during this migration work: the ge This means the intended migration is to preserve the generator physics and replace only the transport mechanism. -## What Has To Be Implemented Next +## Working Migration Path ### 1. Keep the validation macros on the framework bridge @@ -101,12 +100,13 @@ The migration attempt should answer: 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. -## Immediate Work Items +## Actual Validation Procedure -1. Re-run the local validation workflow and inspect output structure and physics behavior. -2. Align the Geant and SimpleSim macros to the same reaction definition before trusting comparison plots. -3. Update the migration draft with what worked and what did not. -4. Only after the migration path is stable should any framework-wide documentation be proposed. +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 @@ -127,7 +127,7 @@ Until those conditions are met, this work remains a local validation and design - detector reaction triggering, - detector-side `AtVertexPropagator` writes and resets, - non-beam metadata lookup, - - SimpleSim callback transport outside the legacy direct-hit path. + - 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 @@ -141,6 +141,27 @@ Until those conditions are met, this work remains a local validation and design 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 index c99b8beca..19f188b0e 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimHookPlan.md @@ -159,6 +159,12 @@ The shared detector-path refactor is now in place and the current local status i - `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 diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md index 27d4aff5a..11d067682 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -1,8 +1,8 @@ -# Draft: Transitioning a Geant Simulation Macro to AtSimpleSim +# Transitioning a Geant Simulation Macro to AtSimpleSim ## Status -This is a local draft for the `AtSimValidation` campaign. It is not framework documentation yet. Its purpose is to describe the candidate migration path, test that path against real macros, and record what has to change before the migration can be treated as clean and reusable. +This is local documentation for the `AtSimValidation` campaign. It records the migration path that is currently validated in this directory for swapping Geant/VMC transport with `AtSimpleSimulation` while preserving the ATTPC generator and detector contract. ## Migration Goal @@ -15,9 +15,9 @@ The target transition is: The migration should be about swapping the transport layer, not rewriting the reaction setup. -## Current Candidate Hook +## Validated Hook -The current framework adapter is `AtDigitization/AtTestSimulation`. +The framework adapter is `AtDigitization/AtTestSimulation`. Its role is: @@ -28,11 +28,11 @@ Its role is: - forward the particles into `AtSimpleSimulation`, - write `AtTpcPoint` output through the normal branch contract. -At present, this is the first hook that should be used when adapting an existing macro. +This is the hook that should be used when adapting an existing Geant-style macro in this repository. This directory has now exercised that hook in the interpreted validation macros: `AtTestSimulation` can replace the macro-local transport task and still produce the standard `AtTpcPoint` branch. -Current branch status: +Current validated behavior: - `AtTestSimulation` now has a detector-coupled mode that feeds shared `AtTpc` step logic. - The fixed validation macro uses that detector-coupled mode. @@ -43,8 +43,11 @@ Current branch status: branch while preserving Geant-style reaction-event IDs (`track 0` scattered ion, `track 1` recoil proton), so downstream truth consumers such as `visualizeKinematic.C` can find the transported reaction products without a placeholder beam slot. +- The detector now stops transport when a particle exits the active reaction volume. In this + validation geometry that is the physically correct approximation because the active gas is bounded + by chamber walls. The Geant path and the SimpleSim path now use the same stop rule. -## Draft Migration Recipe +## Migration Recipe ### 1. Start from a working Geant macro @@ -74,7 +77,19 @@ For the SimpleSim version: - the required energy-loss models for every transported species, - the physics generator that was preserved from the Geant macro. -This is the current transport substitution mechanism being tested in this directory. +This is the validated transport substitution mechanism in this directory. + +Minimal pattern: + +```cpp +run->SetGenerator(new FairPrimaryGenerator()); + +auto *simPrimGen = BuildElasticGenerator(...); +auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); +simTask->SetPrimaryGenerator(simPrimGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` ### 4. Configure AtSimpleSimulation explicitly @@ -87,6 +102,53 @@ The migration must define: If any transported species lack a model, the migration is incomplete. +Minimal pattern: + +```cpp +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +{ + auto sim = std::make_unique(geoFile.Data()); + + 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); + sim->AddModel(6, 16, carbonModel, 16.014701); + + auto protonModel = std::make_shared(heDensity, 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; +} +``` + +### 5. Keep the detector in the loop + +The migration is not just a branch writer swap. `AtTpc` must still own: + +- reaction triggering, +- `AtVertexPropagator` updates, +- detector-side hit semantics, +- stopping transport at the active-volume boundary. + +This is why the migration uses `AtTestSimulation` in detector-coupled mode instead of a macro-local +task that writes `AtTpcPoint` objects directly. + +### 6. Validate with truth-matched comparisons + +When comparing Geant and SimpleSim outputs: + +- match reaction events by `reactionIndex`, +- do not drop an event from the truth bookkeeping just because one side produced zero points, +- report `generator-only` and `incomplete` events separately from usable matched pairs. + +This matters because the earlier local comparison macros hid zero-point events before matching and +made the transport disagreement look worse than it was. + ## Current Known Constraints - `AtTestSimulation` skips particles that start outside `drift_volume`. @@ -95,6 +157,9 @@ If any transported species lack a model, the migration is incomplete. - FairRoot generator output uses cm and GeV; `AtSimpleSimulation` uses mm and MeV. - Interpreted ROOT macros are sensitive to explicit header inclusion. In this directory the stable pattern is to keep the macro header surface minimal and rely on the loaded dictionaries for FairRoot and ATTPC classes where possible. - A migration is not considered successful just because the macro runs. The resulting tracks must also look physically credible. +- For this geometry, a particle leaving the active reaction volume should be treated as stopped by the + wall boundary condition. Allowing Geant to continue transport past active-volume exit produces + unphysical ranges for this validation problem. ## What Must Be Checked During Each Migration Attempt @@ -117,18 +182,20 @@ If any transported species lack a model, the migration is incomplete. - Are the remaining edits small enough to describe as a checklist? - Is there repeated boilerplate that points to missing framework support? -## Current Working Assumption +## Current Verified Result -The migration path is viable if the validation macros can be rewritten around `AtTestSimulation` without introducing new macro-local transport logic. If that succeeds and the resulting behavior is physically credible, this draft can be promoted later into framework documentation. If it fails, this draft should be revised to record the exact missing framework support instead of papering over the problem. +The migration path is viable in this directory. -Current local result: +Verified local result: - the macro-local transport task was removed, - `AtTestSimulation` runs successfully in the validation macros, -- the detector-coupled path now produces non-empty beam-event `AtTpcPoint` output, -- the fixed validation macro now also produces detector-triggered reaction-event output through the - shared path, -- the kinematic validation macro now writes the canonical `MCTrack` branch expected by local - visualization tooling with Geant-style reaction-event IDs, -- the next unresolved issue is broader physics parity against the Geant comparison macros, not the - detector contract itself. +- the detector-coupled path produces both beam-event and reaction-event `AtTpcPoint` output, +- the detector and SimpleSim path preserve the canonical truth contract used by local visualization, +- the Geant detector path now stops transport at active-volume exit just as the SimpleSim path does, +- the fixed comparison now gives `50` usable truth-matched proton pairs with `0/0` generator-only + and `0/0` incomplete events, +- a reduced kinematic comparison now gives `100` usable truth-matched proton pairs with `0/0` + generator-only and `0/0` incomplete events, +- the earlier catastrophic Geant path-length excess is gone once the active-volume stop rule is + enforced. diff --git a/macro/Simulation/AtSimValidation/compareFixed.C b/macro/Simulation/AtSimValidation/compareFixed.C index 599921437..cb2d7b681 100644 --- a/macro/Simulation/AtSimValidation/compareFixed.C +++ b/macro/Simulation/AtSimValidation/compareFixed.C @@ -1,7 +1,29 @@ +#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{}; @@ -14,30 +36,143 @@ struct PointSample { }; struct TrackData { - int trackID{}; std::vector points; }; -struct EventTracks { - TrackData scatteredIon; - TrackData recoilProton; +struct ReactionEvent { + int fairEvent{-1}; + int reactionIndex{-1}; + double truthKE{0.0}; + double truthTheta{0.0}; + double truthPhi{0.0}; + TrackData proton; }; -struct PlotData { - std::vector events; - TProfile *protonBragg{nullptr}; - TProfile *heliumBragg{nullptr}; - TH1D *trackLength{nullptr}; - std::vector> xy; - std::vector> xz; +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; - out.trackID = trackID; for (int i = 0; i < points->GetEntriesFast(); ++i) { auto *pt = dynamic_cast(points->At(i)); if (!pt || pt->GetTrackID() != trackID) @@ -51,29 +186,26 @@ TrackData BuildTrackData(int trackID, TClonesArray *points) return out; } -bool IsSelectedPrimaryTrack(AtMCTrack *track, int pdgCode) +bool IsPrimaryProton(AtMCTrack *track) { - return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == pdgCode; + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == 2212; } -PlotData LoadPlotData(const TString &fileName, const char *tag) +std::vector LoadReactionEvents(const TString &fileName) { - PlotData out; - out.protonBragg = new TProfile(Form("hRecoilProtonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.heliumBragg = new TProfile(Form("hScatteredIonBragg_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.trackLength = new TH1D(Form("hTrackLength_%s", tag), ";Track length [mm];Tracks", 120, 0., 500.); + std::vector events; auto *file = TFile::Open(fileName); if (!file || file->IsZombie()) { std::cerr << "Cannot open " << fileName << "\n"; - return out; + return events; } auto *tree = dynamic_cast(file->Get("cbmsim")); if (!tree) { std::cerr << "Missing cbmsim tree in " << fileName << "\n"; file->Close(); - return out; + return events; } TClonesArray *pointArray = nullptr; @@ -81,202 +213,370 @@ PlotData LoadPlotData(const TString &fileName, const char *tag) tree->SetBranchAddress("AtTpcPoint", &pointArray); tree->SetBranchAddress("MCTrack", &trackArray); - for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { + for (Long64_t iEvent = 1; iEvent < tree->GetEntries(); iEvent += 2) { tree->GetEntry(iEvent); - if (!pointArray || !trackArray || pointArray->GetEntriesFast() == 0) + if (!trackArray) continue; - int scatteredIonTrackID = -1; - int recoilProtonTrackID = -1; + int protonTrackID = -1; for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { auto *track = dynamic_cast(trackArray->At(i)); - if (scatteredIonTrackID < 0 && IsSelectedPrimaryTrack(track, 1000060160)) - scatteredIonTrackID = i; - if (recoilProtonTrackID < 0 && IsSelectedPrimaryTrack(track, 2212)) - recoilProtonTrackID = i; + if (IsPrimaryProton(track)) { + protonTrackID = i; + break; + } } - if (scatteredIonTrackID < 0 || recoilProtonTrackID < 0) + if (protonTrackID < 0) continue; - EventTracks event; - event.scatteredIon = BuildTrackData(scatteredIonTrackID, pointArray); - event.recoilProton = BuildTrackData(recoilProtonTrackID, pointArray); - out.events.push_back(event); + auto *protonTrack = dynamic_cast(trackArray->At(protonTrackID)); + if (protonTrack == nullptr) + continue; - for (const auto *track : {&event.scatteredIon, &event.recoilProton}) { - if (track->points.empty()) - 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)}); + } - out.trackLength->Fill(track->points.back().length); - for (const auto &point : track->points) { - out.xy.emplace_back(point.x, point.y); - out.xz.emplace_back(point.z, point.x); - } + file->Close(); + return events; +} - auto *profile = (track == &event.recoilProton) ? out.protonBragg : out.heliumBragg; - for (size_t i = 1; i < track->points.size(); ++i) { - const auto &prev = track->points[i - 1]; - const auto &curr = track->points[i]; - double dx = curr.x - prev.x; - double dy = curr.y - prev.y; - double dz = curr.z - prev.z; - double step = std::sqrt(dx * dx + dy * dy + dz * dz); - if (step > 1e-6 && curr.eLoss > 0.) - profile->Fill(curr.z, curr.eLoss / step); - } +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); } - file->Close(); - return out; + summary.simpleOnly = simpleByTruth.size(); + return summary; } -void StyleProfile(TProfile *hist, Color_t color, Style_t style) +TGraph *MakeProjectionGraph(const TrackData &track, bool xz, const char *name, Color_t color, Style_t style) { - hist->SetLineColor(color); - hist->SetLineWidth(2); - hist->SetLineStyle(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; } -void StyleTrackLength(TH1D *hist, Color_t color, Style_t style) +TPolyMarker3D *MakeTrackMarkers3D(const TrackData &track, Color_t color, Style_t style) { - hist->SetLineColor(color); - hist->SetLineWidth(2); - hist->SetLineStyle(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 *MakeProjectionGraph(const std::vector> &points, const char *name, Color_t color) +TGraph *MakeScatterGraph(const std::vector> &pairs, const char *name, Color_t color) { - auto *graph = new TGraph(points.size()); + auto *graph = new TGraph(pairs.size()); graph->SetName(name); graph->SetMarkerStyle(20); - graph->SetMarkerSize(0.45); + graph->SetMarkerSize(0.8); graph->SetMarkerColor(color); graph->SetLineColor(color); - for (size_t i = 0; i < points.size(); ++i) - graph->SetPoint(i, points[i].first, points[i].second); + for (size_t i = 0; i < pairs.size(); ++i) + graph->SetPoint(i, pairs[i].first, pairs[i].second); return graph; } -void DrawTrackOverlay(const PlotData &data, bool drawProton, Color_t color) +void DrawIdentityScatter(const std::vector> &pairs, const char *graphName, Color_t color, + const TString &title, const TString &xTitle, const TString &yTitle) { - int count = 0; - for (const auto &event : data.events) { - const auto &track = drawProton ? event.recoilProton : event.scatteredIon; - if (track.points.empty()) - continue; - auto *line = new TPolyLine3D(track.points.size()); - line->SetLineColor(color); - line->SetLineWidth(2); - 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); - if (count == 0) { - line->Draw(); - } else { - line->Draw("same"); - } - if (++count >= 20) - break; + 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") +void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simpleFile = "./data/simpleSim_fixed.root", + Int_t reactionIndex = 0) { gStyle->SetOptStat(0); - auto geant = LoadPlotData(geantFile, "g4"); - auto simple = LoadPlotData(simpleFile, "sim"); - - StyleProfile(geant.protonBragg, kBlue + 1, 1); - StyleProfile(simple.protonBragg, kRed + 1, 2); - StyleProfile(geant.heliumBragg, kBlue + 1, 1); - StyleProfile(simple.heliumBragg, kRed + 1, 2); - StyleTrackLength(geant.trackLength, kBlue + 1, 1); - StyleTrackLength(simple.trackLength, kRed + 1, 2); - - auto *xyG4 = MakeProjectionGraph(geant.xy, "xyG4", kBlue + 1); - auto *xySim = MakeProjectionGraph(simple.xy, "xySim", kRed + 1); - auto *xzG4 = MakeProjectionGraph(geant.xz, "xzG4", kBlue + 1); - auto *xzSim = MakeProjectionGraph(simple.xz, "xzSim", kRed + 1); - - auto *canvas = new TCanvas("cFixedCompare", "AtSimpleSimulation fixed-angle validation", 1800, 1000); + + 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", "AtSimpleSimulation fixed-angle proton comparison", 1900, 1000); canvas->Divide(4, 2); canvas->cd(1); - gPad->SetTheta(20); - gPad->SetPhi(35); - DrawTrackOverlay(geant, true, kBlue + 1); - DrawTrackOverlay(simple, true, kRed + 1); + gPad->SetTheta(22); + gPad->SetPhi(32); + track3DG4->Draw(); + track3DSim->Draw("same"); + mark3DG4->Draw(); + mark3DSim->Draw(); { - auto *legend = new TLegend(0.62, 0.78, 0.9, 0.9); - legend->AddEntry((TObject *)nullptr, "Recoil proton 3D overlay", ""); - legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); - legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); + 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); - gPad->SetTheta(20); - gPad->SetPhi(35); - DrawTrackOverlay(geant, false, kBlue + 1); - DrawTrackOverlay(simple, false, kRed + 1); + 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.62, 0.78, 0.9, 0.9); - legend->AddEntry((TObject *)nullptr, "Scattered 16C 3D overlay", ""); - legend->AddEntry((TObject *)nullptr, "Blue: Geant4", ""); - legend->AddEntry((TObject *)nullptr, "Red: SimpleSim", ""); + 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); - auto *xy = new TMultiGraph(); - xy->SetTitle("XY projection;X [mm];Y [mm]"); - xy->Add(xyG4, "P"); - xy->Add(xySim, "P"); - xy->Draw("A"); - auto *xyLegend = new TLegend(0.65, 0.78, 0.9, 0.9); - xyLegend->AddEntry(xyG4, "Geant4", "p"); - xyLegend->AddEntry(xySim, "SimpleSim", "p"); - xyLegend->Draw(); + 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); - auto *xz = new TMultiGraph(); - xz->SetTitle("XZ projection;Z [mm];X [mm]"); - xz->Add(xzG4, "P"); - xz->Add(xzSim, "P"); - xz->Draw("A"); - auto *xzLegend = new TLegend(0.65, 0.78, 0.9, 0.9); - xzLegend->AddEntry(xzG4, "Geant4", "p"); - xzLegend->AddEntry(xzSim, "SimpleSim", "p"); - xzLegend->Draw(); + 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); - geant.protonBragg->SetTitle("Bragg curve: proton"); - geant.protonBragg->Draw("hist"); - simple.protonBragg->Draw("hist same"); - auto *protonLegend = new TLegend(0.6, 0.78, 0.9, 0.9); - protonLegend->AddEntry(geant.protonBragg, "Geant4", "l"); - protonLegend->AddEntry(simple.protonBragg, "SimpleSim", "l"); - protonLegend->Draw(); + DrawIdentityScatter(kineticPairs, "gFixedEnergyScatter", kBlue + 1, "Matched proton kinetic energy; ; ", + "Geant4 proton KE [MeV]", "SimpleSim proton KE [MeV]"); canvas->cd(6); - geant.heliumBragg->SetTitle("Bragg curve: scattered 16C"); - geant.heliumBragg->Draw("hist"); - simple.heliumBragg->Draw("hist same"); - auto *heliumLegend = new TLegend(0.6, 0.78, 0.9, 0.9); - heliumLegend->AddEntry(geant.heliumBragg, "Geant4 Bragg", "l"); - heliumLegend->AddEntry(simple.heliumBragg, "SimpleSim Bragg", "l"); - heliumLegend->Draw(); + DrawIdentityScatter(thetaPairs, "gFixedThetaScatter", kBlue + 1, "Matched proton lab angle; ; ", + "Geant4 proton #theta_{lab} [deg]", "SimpleSim proton #theta_{lab} [deg]"); canvas->cd(7); - geant.trackLength->SetTitle("Track length distribution"); - geant.trackLength->Draw("hist"); - simple.trackLength->Draw("hist same"); - auto *trackLegend = new TLegend(0.6, 0.78, 0.9, 0.9); - trackLegend->AddEntry(geant.trackLength, "Geant4", "l"); - trackLegend->AddEntry(simple.trackLength, "SimpleSim", "l"); - trackLegend->Draw(); + 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 index 3b35f3158..d99742b82 100644 --- a/macro/Simulation/AtSimValidation/compareKinematic.C +++ b/macro/Simulation/AtSimValidation/compareKinematic.C @@ -1,7 +1,9 @@ #include #include #include +#include #include +#include #include #include @@ -11,14 +13,18 @@ #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{}; @@ -34,18 +40,145 @@ struct TrackData { std::vector points; }; -struct PlotData { - std::vector> recoilProtonKinematics; - std::vector> scatteredIonKinematics; - std::vector> xy; - TProfile *recoilProtonBragg{nullptr}; - TProfile *scatteredIonBragg{nullptr}; - TH1D *recoilProtonRange{nullptr}; - TH1D *scatteredIonRange{nullptr}; +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; @@ -53,6 +186,7 @@ TrackData BuildTrackData(int trackID, TClonesArray *points) 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.}); @@ -61,44 +195,26 @@ TrackData BuildTrackData(int trackID, TClonesArray *points) return out; } -void FillBragg(TProfile *profile, const TrackData &track) -{ - for (size_t i = 1; i < track.points.size(); ++i) { - const auto &prev = track.points[i - 1]; - const auto &curr = track.points[i]; - double dx = curr.x - prev.x; - double dy = curr.y - prev.y; - double dz = curr.z - prev.z; - double step = std::sqrt(dx * dx + dy * dy + dz * dz); - if (step > 1e-6 && curr.eLoss > 0.) - profile->Fill(curr.z, curr.eLoss / step); - } -} - -bool IsSelectedPrimaryTrack(AtMCTrack *track, int pdgCode) +bool IsPrimaryProton(AtMCTrack *track) { - return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == pdgCode; + return track != nullptr && track->GetMotherId() == -1 && track->GetPdgCode() == 2212; } -PlotData LoadPlotData(const TString &fileName, const char *tag) +std::vector LoadReactionEvents(const TString &fileName) { - PlotData out; - out.recoilProtonBragg = new TProfile(Form("hRecoilProtonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.scatteredIonBragg = new TProfile(Form("hScatteredIonBraggK_%s", tag), ";Z [mm];dE/dx [MeV/mm]", 100, 0., 1000.); - out.recoilProtonRange = new TH1D(Form("hRecoilProtonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); - out.scatteredIonRange = new TH1D(Form("hScatteredIonRange_%s", tag), ";Stopping Z [mm];Tracks", 100, 0., 1000.); + std::vector events; auto *file = TFile::Open(fileName); if (!file || file->IsZombie()) { std::cerr << "Cannot open " << fileName << "\n"; - return out; + return events; } auto *tree = dynamic_cast(file->Get("cbmsim")); if (!tree) { std::cerr << "Missing cbmsim tree in " << fileName << "\n"; file->Close(); - return out; + return events; } TClonesArray *pointArray = nullptr; @@ -106,170 +222,380 @@ PlotData LoadPlotData(const TString &fileName, const char *tag) tree->SetBranchAddress("AtTpcPoint", &pointArray); tree->SetBranchAddress("MCTrack", &trackArray); - for (Long64_t iEvent = 0; iEvent < tree->GetEntries(); ++iEvent) { + for (Long64_t iEvent = 1; iEvent < tree->GetEntries(); iEvent += 2) { tree->GetEntry(iEvent); - if (!pointArray || !trackArray || pointArray->GetEntriesFast() == 0) + if (!trackArray) continue; - int scatteredIonTrackID = -1; - int recoilProtonTrackID = -1; + int protonTrackID = -1; for (int i = 0; i < trackArray->GetEntriesFast(); ++i) { auto *track = dynamic_cast(trackArray->At(i)); - if (scatteredIonTrackID < 0 && IsSelectedPrimaryTrack(track, 1000060160)) - scatteredIonTrackID = i; - if (recoilProtonTrackID < 0 && IsSelectedPrimaryTrack(track, 2212)) - recoilProtonTrackID = i; + if (IsPrimaryProton(track)) { + protonTrackID = i; + break; + } } - if (scatteredIonTrackID < 0 || recoilProtonTrackID < 0) + if (protonTrackID < 0) continue; - auto scatteredIon = BuildTrackData(scatteredIonTrackID, pointArray); - auto recoilProton = BuildTrackData(recoilProtonTrackID, pointArray); - if (scatteredIon.points.empty() || recoilProton.points.empty()) + auto *protonTrack = dynamic_cast(trackArray->At(protonTrackID)); + if (protonTrack == nullptr) continue; - const auto &recoilProtonFirst = recoilProton.points.front(); - const auto &scatteredIonFirst = scatteredIon.points.front(); - out.recoilProtonKinematics.emplace_back( - std::sqrt(recoilProtonFirst.px * recoilProtonFirst.px + recoilProtonFirst.py * recoilProtonFirst.py), - recoilProtonFirst.pz); - out.scatteredIonKinematics.emplace_back( - std::sqrt(scatteredIonFirst.px * scatteredIonFirst.px + scatteredIonFirst.py * scatteredIonFirst.py), - scatteredIonFirst.pz); - - out.recoilProtonRange->Fill(recoilProton.points.back().z); - out.scatteredIonRange->Fill(scatteredIon.points.back().z); - FillBragg(out.recoilProtonBragg, recoilProton); - FillBragg(out.scatteredIonBragg, scatteredIon); - - for (const auto &point : recoilProton.points) - out.xy.emplace_back(point.x, point.y); - for (const auto &point : scatteredIon.points) - out.xy.emplace_back(point.x, point.y); + 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 out; + 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; } -void StyleProfile(TProfile *hist, Color_t color, Style_t style) +TGraph *MakeProjectionGraph(const TrackData &track, bool xz, const char *name, Color_t color, Style_t style) { - hist->SetLineColor(color); - hist->SetLineWidth(2); - hist->SetLineStyle(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; } -void StyleRange(TH1D *hist, Color_t color, Style_t style) +TPolyLine3D *MakeTrackLine3D(const TrackData &track, const char *name, Color_t color, Style_t style) { - hist->SetLineColor(color); - hist->SetLineWidth(2); - hist->SetLineStyle(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; } -TGraph *MakeGraph(const std::vector> &points, const char *name, Color_t color) +TPolyMarker3D *MakeTrackMarkers3D(const TrackData &track, Color_t color, Style_t style) { - auto *graph = new TGraph(points.size()); + 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.45); + 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 < points.size(); ++i) - graph->SetPoint(i, points[i].first, points[i].second); + 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") + TString simpleFile = "./data/simpleSim_kinematic.root", Int_t reactionIndex = 0) { gStyle->SetOptStat(0); - auto geant = LoadPlotData(geantFile, "g4"); - auto simple = LoadPlotData(simpleFile, "sim"); - - StyleProfile(geant.recoilProtonBragg, kBlue + 1, 1); - StyleProfile(simple.recoilProtonBragg, kRed + 1, 2); - StyleProfile(geant.scatteredIonBragg, kBlue + 1, 1); - StyleProfile(simple.scatteredIonBragg, kRed + 1, 2); - StyleRange(geant.recoilProtonRange, kBlue + 1, 1); - StyleRange(simple.recoilProtonRange, kRed + 1, 2); - StyleRange(geant.scatteredIonRange, kBlue + 1, 3); - StyleRange(simple.scatteredIonRange, kRed + 1, 4); - - auto *protonG4 = MakeGraph(geant.recoilProtonKinematics, "protonG4", kBlue + 1); - auto *protonSim = MakeGraph(simple.recoilProtonKinematics, "protonSim", kRed + 1); - auto *ionG4 = MakeGraph(geant.scatteredIonKinematics, "ionG4", kBlue + 1); - auto *ionSim = MakeGraph(simple.scatteredIonKinematics, "ionSim", kRed + 1); - auto *xyG4 = MakeGraph(geant.xy, "xyG4K", kBlue + 1); - auto *xySim = MakeGraph(simple.xy, "xySimK", kRed + 1); - - auto *canvas = new TCanvas("cKinematicCompare", "AtSimpleSimulation kinematic validation", 1600, 900); - canvas->Divide(3, 2); + + 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", "AtSimpleSimulation kinematic proton comparison", 1900, 1000); + canvas->Divide(4, 2); canvas->cd(1); - auto *protonMg = new TMultiGraph(); - protonMg->SetTitle("Proton kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); - protonMg->Add(protonG4, "P"); - protonMg->Add(protonSim, "P"); - protonMg->Draw("A"); - auto *protonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - protonLegend->AddEntry(protonG4, "Geant4", "p"); - protonLegend->AddEntry(protonSim, "SimpleSim", "p"); - protonLegend->Draw(); + 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); - auto *ionMg = new TMultiGraph(); - ionMg->SetTitle("Scattered 16C kinematic locus;p_{T} [MeV/c];p_{Z} [MeV/c]"); - ionMg->Add(ionG4, "P"); - ionMg->Add(ionSim, "P"); - ionMg->Draw("A"); - auto *ionLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - ionLegend->AddEntry(ionG4, "Geant4", "p"); - ionLegend->AddEntry(ionSim, "SimpleSim", "p"); - ionLegend->Draw(); + 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); - geant.recoilProtonBragg->SetTitle("Bragg curve: recoil proton"); - geant.recoilProtonBragg->Draw("hist"); - simple.recoilProtonBragg->Draw("hist same"); - auto *braggProtonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - braggProtonLegend->AddEntry(geant.recoilProtonBragg, "Geant4", "l"); - braggProtonLegend->AddEntry(simple.recoilProtonBragg, "SimpleSim", "l"); - braggProtonLegend->Draw(); + 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); - geant.scatteredIonBragg->SetTitle("Bragg curve: scattered 16C"); - geant.scatteredIonBragg->Draw("hist"); - simple.scatteredIonBragg->Draw("hist same"); - auto *braggIonLegend = new TLegend(0.62, 0.78, 0.9, 0.9); - braggIonLegend->AddEntry(geant.scatteredIonBragg, "Geant4", "l"); - braggIonLegend->AddEntry(simple.scatteredIonBragg, "SimpleSim", "l"); - braggIonLegend->Draw(); + 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); - geant.recoilProtonRange->SetTitle("Range distributions"); - geant.recoilProtonRange->Draw("hist"); - simple.recoilProtonRange->Draw("hist same"); - geant.scatteredIonRange->Draw("hist same"); - simple.scatteredIonRange->Draw("hist same"); - auto *rangeLegend = new TLegend(0.44, 0.64, 0.9, 0.9); - rangeLegend->AddEntry(geant.recoilProtonRange, "Geant4 proton", "l"); - rangeLegend->AddEntry(simple.recoilProtonRange, "SimpleSim proton", "l"); - rangeLegend->AddEntry(geant.scatteredIonRange, "Geant4 16C", "l"); - rangeLegend->AddEntry(simple.scatteredIonRange, "SimpleSim 16C", "l"); - rangeLegend->Draw(); + DrawIdentityScatter(kineticPairs, "gKineEnergyScatter", kBlue + 1, "Matched proton kinetic energy; ; ", + "Geant4 proton KE [MeV]", "SimpleSim proton KE [MeV]"); canvas->cd(6); - auto *xyMg = new TMultiGraph(); - xyMg->SetTitle("XY projection;X [mm];Y [mm]"); - xyMg->Add(xyG4, "P"); - xyMg->Add(xySim, "P"); - xyMg->Draw("A"); - auto *xyLegend = new TLegend(0.65, 0.78, 0.9, 0.9); - xyLegend->AddEntry(xyG4, "Geant4", "p"); - xyLegend->AddEntry(xySim, "SimpleSim", "p"); - xyLegend->Draw(); + 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"); From 248eb783c9856918a1f9144801bed573283d89d4 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 17:50:43 -0400 Subject: [PATCH 11/18] Rewrite SimpleSim migration guide Refocus the AtSimValidation migration note on what a user needs to edit in a normal simulation macro when swapping Geant transport for SimpleSim. Remove the implementation-history framing and the validation-macro-specific assumptions, and present the migration as a transport-hookup change for existing user macros with inline generator setup. --- .../AtSimpleSimMigrationDraft.md | 329 +++++++++++------- 1 file changed, 200 insertions(+), 129 deletions(-) diff --git a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md index 11d067682..a0eaac58e 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -1,201 +1,272 @@ -# Transitioning a Geant Simulation Macro to AtSimpleSim +# User Guide: Migrating a Simulation Macro from Geant to SimpleSim -## Status +## Purpose -This is local documentation for the `AtSimValidation` campaign. It records the migration path that is currently validated in this directory for swapping Geant/VMC transport with `AtSimpleSimulation` while preserving the ATTPC generator and detector contract. +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. -## Migration Goal +The goal is simple: -The target transition is: +- keep the same macro structure +- keep the same generator setup +- keep the same detector setup +- replace only the transport hookup -- preserve the existing ATTPC generator physics, -- preserve the detector geometry and field setup, -- preserve the output and runtime-db structure as much as possible, -- replace Geant/VMC transport with `AtSimpleSimulation`. +## What You Usually Start With -The migration should be about swapping the transport layer, not rewriting the reaction setup. +A typical user macro already has: -## Validated Hook +- 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)` -The framework adapter is `AtDigitization/AtTestSimulation`. +That is enough. You do not need to restructure the macro into helper functions to use SimpleSim. -Its role is: +## What Stays the Same -- own an `AtSimpleSimulation` instance, -- drive a `FairPrimaryGenerator` each event, -- collect generated primaries through `AtSimParticleCollector`, -- convert FairRoot units to `AtSimpleSimulation` units, -- forward the particles into `AtSimpleSimulation`, -- write `AtTpcPoint` output through the normal branch contract. +Keep these parts unchanged unless you have a physics reason to change them: -This is the hook that should be used when adapting an existing Geant-style macro in this repository. +- 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 -This directory has now exercised that hook in the interpreted validation macros: `AtTestSimulation` can replace the macro-local transport task and still produce the standard `AtTpcPoint` branch. +If the Geant macro already produces the right physics input, preserve that input. -Current validated behavior: +## What Actually Changes -- `AtTestSimulation` now has a detector-coupled mode that feeds shared `AtTpc` step logic. -- The fixed validation macro uses that detector-coupled mode. -- Beam-event `AtTpcPoint` output is now produced through the detector path. -- In the fixed validation case, detector-side reaction handoff now reaches `AtTPC2Body` with a - non-zero residual beam energy and produces reaction-event `AtTpcPoint` output. -- In the kinematic validation case, the adapter now also restores the canonical `MCTrack` truth - branch while preserving Geant-style reaction-event IDs (`track 0` scattered ion, `track 1` - recoil proton), so downstream truth consumers such as `visualizeKinematic.C` can find the - transported reaction products without a placeholder beam slot. -- The detector now stops transport when a particle exits the active reaction volume. In this - validation geometry that is the physically correct approximation because the active gas is bounded - by chamber walls. The Geant path and the SimpleSim path now use the same stop rule. +You replace the transport hookup. -## Migration Recipe +In a Geant macro, the physics generator is usually connected directly to the run: -### 1. Start from a working Geant macro +```cpp +run->SetGenerator(primGen); +``` -Keep these pieces unchanged unless the migration experiment proves otherwise: +In a SimpleSim macro, the run gets a dummy event-loop generator, and the real physics generator is passed to `AtTestSimulation`: + +```cpp +run->SetGenerator(new FairPrimaryGenerator()); -- detector modules such as `AtCave` and `AtTpc`, -- geometry file selection, -- magnetic-field setup, -- random seed policy, -- runtime-db output handling, -- output file naming pattern, -- generator construction function such as `BuildElasticGenerator(...)`. +auto *simTask = new AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` -### 2. Preserve the generator block +That is the main migration step. -Build the same `FairPrimaryGenerator` and ATTPC generator chain used by the Geant macro. The same beam and reaction generators should define the physics input on both sides. +## Minimal Migration Procedure -### 3. Replace Geant transport with AtTestSimulation +### 1. Copy the working Geant macro -For the SimpleSim version: +Start from the macro that already works for your case. -- keep `FairRunSim`, -- keep the detector geometry and field configuration, -- do not hand the physics generator to Geant transport with `run->SetGenerator(physicsGenerator)`, -- instead, give the run a minimal driver generator and attach an `AtTestSimulation` task configured with: - - a new `AtSimpleSimulation`, - - the required energy-loss models for every transported species, - - the physics generator that was preserved from the Geant macro. +Do not refactor the macro at the same time. First make the transport swap only. -This is the validated transport substitution mechanism in this directory. +### 2. Keep the generator block as it is -Minimal pattern: +If your macro builds the generator inline, keep it inline. + +For example, if you already have: ```cpp -run->SetGenerator(new FairPrimaryGenerator()); +auto *primGen = new FairPrimaryGenerator(); -auto *simPrimGen = BuildElasticGenerator(...); -auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); -simTask->SetPrimaryGenerator(simPrimGen); -simTask->SetDetector(tpc); -run->AddTask(simTask); +auto *ionGen = new AtTPCIonGenerator(...); +primGen->AddGenerator(ionGen); + +auto *twoBody = new AtTPC2Body(...); +primGen->AddGenerator(twoBody); ``` -### 4. Configure AtSimpleSimulation explicitly +leave that section alone. + +The migration should not require users to move their generator code into helper functions. + +### 3. Add a SimpleSim configuration block -The migration must define: +You need one place where `AtSimpleSimulation` is configured. -- all species that need `AtTools::AtELossModel` entries, -- the geometry assumption for the active volume, -- field configuration if curved transport is required, -- propagation step settings if they matter for the validation case. +This can be: -If any transported species lack a model, the migration is incomplete. +- a helper function such as `BuildSimpleSimulation(...)` +- or inline setup code if you prefer -Minimal pattern: +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(const TString &geoFile) { auto sim = std::make_unique(geoFile.Data()); - constexpr double heDensity = 1.664e-4; + constexpr double gasDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; - auto carbonModel = std::make_shared(heDensity, material); - carbonModel->SetProjectile(16, 6, 16.014701); - sim->AddModel(6, 16, carbonModel, 16.014701); + 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(heDensity, material); + 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; } ``` -### 5. Keep the detector in the loop +You must add a model for every species that will be transported. -The migration is not just a branch writer swap. `AtTpc` must still own: +### 4. Replace the Geant transport hookup -- reaction triggering, -- `AtVertexPropagator` updates, -- detector-side hit semantics, -- stopping transport at the active-volume boundary. +Leave the run setup mostly alone and replace only the generator hookup. + +Typical Geant-style pattern: + +```cpp +run->SetGenerator(primGen); +``` -This is why the migration uses `AtTestSimulation` in detector-coupled mode instead of a macro-local -task that writes `AtTpcPoint` objects directly. +SimpleSim pattern: -### 6. Validate with truth-matched comparisons +```cpp +run->SetGenerator(new FairPrimaryGenerator()); -When comparing Geant and SimpleSim outputs: +auto *simTask = new AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` -- match reaction events by `reactionIndex`, -- do not drop an event from the truth bookkeeping just because one side produced zero points, -- report `generator-only` and `incomplete` events separately from usable matched pairs. +Important: -This matters because the earlier local comparison macros hid zero-point events before matching and -made the transport disagreement look worse than it was. +- `FairRunSim` still needs a generator object for the event loop +- the real physics generator is now passed into `AtTestSimulation` -## Current Known Constraints +### 5. Use the right geometry file for SimpleSim -- `AtTestSimulation` skips particles that start outside `drift_volume`. -- `AtSimpleSimulation` requires explicit energy-loss models for each `(Z, A)` species. -- The detector geometry file used by `AtTpc` is not necessarily the file that should be passed to `AtSimpleSimulation`. In this validation area the run uses `ATTPC_He1bar.root`, while `AtSimpleSimulation` needs the importable `ATTPC_He1bar_geomanager.root`. -- FairRoot generator output uses cm and GeV; `AtSimpleSimulation` uses mm and MeV. -- Interpreted ROOT macros are sensitive to explicit header inclusion. In this directory the stable pattern is to keep the macro header surface minimal and rely on the loaded dictionaries for FairRoot and ATTPC classes where possible. -- A migration is not considered successful just because the macro runs. The resulting tracks must also look physically credible. -- For this geometry, a particle leaving the active reaction volume should be treated as stopped by the - wall boundary condition. Allowing Geant to continue transport past active-volume exit produces - unphysical ranges for this validation problem. +This is one of the easiest mistakes to make. -## What Must Be Checked During Each Migration Attempt +For the detector module: -### Structural checks +- keep the usual detector geometry file, for example `ATTPC_He1bar.root` -- Does the macro still have the same generator construction logic as the Geant source? -- Does the SimpleSim path write the expected `AtTpcPoint` branch? -- Can downstream comparison or digitization scripts read the result without special handling? +For `AtSimpleSimulation`: + +- use the importable geometry-manager file, for example `ATTPC_He1bar_geomanager.root` + +Typical pattern: + +```cpp +tpc->SetGeometryFileName((dir + "/geometry/ATTPC_He1bar.root").Data()); + +TString simpleSimGeoFile = dir + "/geometry/ATTPC_He1bar_geomanager.root"; +auto *simTask = new AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); +``` + +### 6. 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())` + - `AtTestSimulation` + - `simTask->SetPrimaryGenerator(primGen)` + - `simTask->SetDetector(tpc)` + - `run->AddTask(simTask)` +5. Use the `*_geomanager.root` geometry for SimpleSim. +6. 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 -- Are both sides running the same reaction setup? -- Are the geometry and magnetic field the same? -- Do the track shapes look qualitatively correct? -- Is the stopping behavior reasonable for the configured energy-loss model? +- 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 -### Cleanliness checks +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. +- `AtTestSimulation` 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. -- Was `AtTestSimulation` sufficient, or did the macro need custom task code? -- Are the remaining edits small enough to describe as a checklist? -- Is there repeated boilerplate that points to missing framework support? +## Suggested Workflow -## Current Verified Result +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. -The migration path is viable in this directory. +## Optional Local References -Verified local result: +If you want concrete examples of this migration pattern, see: -- the macro-local transport task was removed, -- `AtTestSimulation` runs successfully in the validation macros, -- the detector-coupled path produces both beam-event and reaction-event `AtTpcPoint` output, -- the detector and SimpleSim path preserve the canonical truth contract used by local visualization, -- the Geant detector path now stops transport at active-volume exit just as the SimpleSim path does, -- the fixed comparison now gives `50` usable truth-matched proton pairs with `0/0` generator-only - and `0/0` incomplete events, -- a reduced kinematic comparison now gives `100` usable truth-matched proton pairs with `0/0` - generator-only and `0/0` incomplete events, -- the earlier catastrophic Geant path-length excess is gone once the active-volume stop rule is - enforced. +- [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) From 691698490a501cf5e3bb4721cb65fa5ff971e3de Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 18:22:04 -0400 Subject: [PATCH 12/18] Add replay mode for SimpleSim validation --- AtDigitization/AtTestSimulation.cxx | 74 ++++++++++++++++++- AtDigitization/AtTestSimulation.h | 12 ++- .../AtSimValidation/simpleSim_fixed.C | 6 +- .../AtSimValidation/simpleSim_kinematic.C | 6 +- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index cacec6d1c..2bbd73316 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -19,7 +19,9 @@ #include // for PxPyPzEVector #include #include +#include #include +#include #include #include @@ -101,23 +103,76 @@ InitStatus AtTestSimulation::Init() fPrimGen->Init(); } + if (!fPrimaryTrackSourceFile.empty()) { + fPrimaryTrackFile = TFile::Open(fPrimaryTrackSourceFile.c_str(), "READ"); + if (fPrimaryTrackFile == nullptr || fPrimaryTrackFile->IsZombie()) { + LOG(fatal) << "AtTestSimulation: cannot open primary track source " << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree = dynamic_cast(fPrimaryTrackFile->Get("cbmsim")); + if (fPrimaryTrackTree == nullptr) { + LOG(fatal) << "AtTestSimulation: missing cbmsim tree in primary track source " << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree->SetBranchAddress("MCTrack", &fPrimaryTrackInput); + LOG(info) << "AtTestSimulation: replaying primary MC tracks from " << fPrimaryTrackSourceFile; + } + RegisterMCTrackBranch(); return kSUCCESS; } +bool AtTestSimulation::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; +} + void AtTestSimulation::Exec(Option_t *) { fSimulation->NewEvent(); + bool loadedEvent = false; + bool isBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); + + if (fPrimaryTrackTree != nullptr) { + isBeamEvent = (fSourceEventIndex % 2) == 0; + AtVertexPropagator::Instance()->SetIsBeamEvent(isBeamEvent); + loadedEvent = LoadPrimaryTracksFromSource(); + } else if (fPrimGen != nullptr) { + fCollector.Clear(); + fPrimGen->GenerateEvent(&fCollector); + loadedEvent = true; + } - if (!fPrimGen) + if (!loadedEvent) return; - const bool isBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); - fCollector.Clear(); - fPrimGen->GenerateEvent(&fCollector); FillMCTracks(); + if (fPrimaryTrackTree != nullptr && isBeamEvent) + return; + for (const auto &p : fCollector.GetParticles()) { auto [Z, A] = GetZAFromPDG(p.pdgCode); if (Z == 0 && A == 0) { @@ -253,4 +308,15 @@ bool AtTestSimulation::IsSensitiveVolume(const std::string &volumeName) volumeName.find("cell") != std::string::npos; } +void AtTestSimulation::Finish() +{ + if (fPrimaryTrackFile != nullptr) { + fPrimaryTrackFile->Close(); + delete fPrimaryTrackFile; + fPrimaryTrackFile = nullptr; + fPrimaryTrackTree = nullptr; + fPrimaryTrackInput = nullptr; + } +} + ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h index 46356f318..bd73b936f 100644 --- a/AtDigitization/AtTestSimulation.h +++ b/AtDigitization/AtTestSimulation.h @@ -11,6 +11,7 @@ #include "FairTask.h" #include // for unique_ptr +#include #include // for move #include @@ -19,6 +20,8 @@ class FairPrimaryGenerator; class TBuffer; class TClass; class TMemberInspector; +class TFile; +class TTree; /** * @brief FairTask wrapper for AtSimpleSimulation. @@ -36,6 +39,11 @@ class AtTestSimulation : public FairTask { AtTpc *fDetector{nullptr}; //! AtSimParticleCollector fCollector; //! TClonesArray *fMCTrackArray{nullptr}; //! + std::string fPrimaryTrackSourceFile; //! + TFile *fPrimaryTrackFile{nullptr}; //! + TTree *fPrimaryTrackTree{nullptr}; //! + TClonesArray *fPrimaryTrackInput{nullptr}; //! + Long64_t fSourceEventIndex{0}; //! // Owned MCEventHeader required by FairPrimaryGenerator::GenerateEvent() std::unique_ptr fMCHeader; //! @@ -51,16 +59,18 @@ class AtTestSimulation : public FairTask { * It must remain valid for the lifetime of the task. */ void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } + void SetPrimaryTrackSource(const std::string &fileName) { fPrimaryTrackSourceFile = fileName; } void SetDetector(AtTpc *detector) { fDetector = detector; } virtual InitStatus Init() override; virtual void Exec(Option_t *option) override; - virtual void Finish() override {} + virtual void Finish() override; AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } private: void RegisterMCTrackBranch(); void FillMCTracks(); + bool LoadPrimaryTracksFromSource(); bool SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom); bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index 9d68d2ff4..478da35a0 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -60,7 +60,8 @@ std::unique_ptr BuildSimpleSimulation(const TString &geoFile } } // namespace -void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed = 42) +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()) { @@ -96,9 +97,8 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed auto *eventLoopDriver = new FairPrimaryGenerator(); run->SetGenerator(eventLoopDriver); - auto *simPrimGen = BuildElasticGenerator(thetaCms, thetaCms); auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); - simTask->SetPrimaryGenerator(simPrimGen); + simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C index c15bd3211..64805bc1e 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -64,7 +64,8 @@ std::unique_ptr BuildSimpleSimulation(const TString &geoFile } } // namespace -void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42) +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()) { @@ -97,9 +98,8 @@ void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42) run->SetGenerator(new FairPrimaryGenerator()); - auto *simPrimGen = BuildElasticGenerator(0.0, 180.0); auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); - simTask->SetPrimaryGenerator(simPrimGen); + simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); From 215ddb5ee78ebed9927a89f397dfa7f5d5160689 Mon Sep 17 00:00:00 2001 From: anthoak13 Date: Mon, 6 Apr 2026 18:29:36 -0400 Subject: [PATCH 13/18] Split SimpleSim generator and replay tasks --- AtDigitization/AtDigiLinkDef.h | 17 +- .../AtSimpleSimulationGeneratorTask.cxx | 38 +++ .../AtSimpleSimulationGeneratorTask.h | 34 ++ .../AtSimpleSimulationReplayTask.cxx | 93 +++++ AtDigitization/AtSimpleSimulationReplayTask.h | 40 +++ AtDigitization/AtSimpleSimulationTask.cxx | 257 ++++++++++++++ AtDigitization/AtSimpleSimulationTask.h | 65 ++++ AtDigitization/AtTestSimulation.cxx | 319 ------------------ AtDigitization/AtTestSimulation.h | 76 +---- AtDigitization/CMakeLists.txt | 3 + .../AtSimValidation/simpleSim_fixed.C | 2 +- .../AtSimValidation/simpleSim_kinematic.C | 3 +- 12 files changed, 549 insertions(+), 398 deletions(-) create mode 100644 AtDigitization/AtSimpleSimulationGeneratorTask.cxx create mode 100644 AtDigitization/AtSimpleSimulationGeneratorTask.h create mode 100644 AtDigitization/AtSimpleSimulationReplayTask.cxx create mode 100644 AtDigitization/AtSimpleSimulationReplayTask.h create mode 100644 AtDigitization/AtSimpleSimulationTask.cxx create mode 100644 AtDigitization/AtSimpleSimulationTask.h diff --git a/AtDigitization/AtDigiLinkDef.h b/AtDigitization/AtDigiLinkDef.h index ec1c7b136..550797aed 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 AtSimpleSimulationTask +; +#pragma link C++ class AtSimpleSimulationGeneratorTask +; +#pragma link C++ class AtSimpleSimulationReplayTask +; +#pragma link C++ class AtTestSimulation +; +#pragma link C++ class AtSimpleSimulation -!; #endif diff --git a/AtDigitization/AtSimpleSimulationGeneratorTask.cxx b/AtDigitization/AtSimpleSimulationGeneratorTask.cxx new file mode 100644 index 000000000..b1cff87d9 --- /dev/null +++ b/AtDigitization/AtSimpleSimulationGeneratorTask.cxx @@ -0,0 +1,38 @@ +#include "AtSimpleSimulationGeneratorTask.h" + +#include "AtVertexPropagator.h" + +#include +#include + +AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask(std::unique_ptr sim) + : AtSimpleSimulationTask(std::move(sim)) +{ +} + +InitStatus AtSimpleSimulationGeneratorTask::InitEventSource() +{ + if (fPrimGen == nullptr) + return kSUCCESS; + + fMCHeader = std::make_unique(); + fPrimGen->SetEvent(fMCHeader.get()); + fPrimGen->Init(); + return kSUCCESS; +} + +AtSimpleSimulationTask::EventState AtSimpleSimulationGeneratorTask::LoadEvent() +{ + fCollector.Clear(); + if (fPrimGen == nullptr) + return {}; + + fPrimGen->GenerateEvent(&fCollector); + EventState state; + state.hasEvent = true; + state.beamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); + state.transportPrimaries = true; + return state; +} + +ClassImp(AtSimpleSimulationGeneratorTask); diff --git a/AtDigitization/AtSimpleSimulationGeneratorTask.h b/AtDigitization/AtSimpleSimulationGeneratorTask.h new file mode 100644 index 000000000..186db936e --- /dev/null +++ b/AtDigitization/AtSimpleSimulationGeneratorTask.h @@ -0,0 +1,34 @@ +#ifndef AtSimpleSimulationGeneratorTask_h +#define AtSimpleSimulationGeneratorTask_h + +#include "AtSimpleSimulationTask.h" + +#include +#include + +#include + +class FairPrimaryGenerator; +class TBuffer; +class TClass; +class TMemberInspector; + +class AtSimpleSimulationGeneratorTask : public AtSimpleSimulationTask { +public: + explicit AtSimpleSimulationGeneratorTask(std::unique_ptr sim); + ~AtSimpleSimulationGeneratorTask() 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(AtSimpleSimulationGeneratorTask, 1); +}; + +#endif diff --git a/AtDigitization/AtSimpleSimulationReplayTask.cxx b/AtDigitization/AtSimpleSimulationReplayTask.cxx new file mode 100644 index 000000000..65b11e4bb --- /dev/null +++ b/AtDigitization/AtSimpleSimulationReplayTask.cxx @@ -0,0 +1,93 @@ +#include "AtSimpleSimulationReplayTask.h" + +#include "AtMCTrack.h" +#include "AtVertexPropagator.h" + +#include +#include +#include +#include +#include + +AtSimpleSimulationReplayTask::AtSimpleSimulationReplayTask(std::unique_ptr sim) + : AtSimpleSimulationTask(std::move(sim)) +{ +} + +InitStatus AtSimpleSimulationReplayTask::InitEventSource() +{ + if (fPrimaryTrackSourceFile.empty()) + return kSUCCESS; + + fPrimaryTrackFile = TFile::Open(fPrimaryTrackSourceFile.c_str(), "READ"); + if (fPrimaryTrackFile == nullptr || fPrimaryTrackFile->IsZombie()) { + LOG(fatal) << "AtSimpleSimulationReplayTask: cannot open primary track source " << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree = dynamic_cast(fPrimaryTrackFile->Get("cbmsim")); + if (fPrimaryTrackTree == nullptr) { + LOG(fatal) << "AtSimpleSimulationReplayTask: missing cbmsim tree in primary track source " + << fPrimaryTrackSourceFile; + return kFATAL; + } + + fPrimaryTrackTree->SetBranchAddress("MCTrack", &fPrimaryTrackInput); + LOG(info) << "AtSimpleSimulationReplayTask: replaying primary MC tracks from " << fPrimaryTrackSourceFile; + return kSUCCESS; +} + +AtSimpleSimulationTask::EventState AtSimpleSimulationReplayTask::LoadEvent() +{ + if (fPrimaryTrackTree == nullptr) + return {}; + + const bool beamEvent = (fSourceEventIndex % 2) == 0; + AtVertexPropagator::Instance()->SetIsBeamEvent(beamEvent); + if (!LoadPrimaryTracksFromSource()) + return {}; + + EventState state; + state.hasEvent = true; + state.beamEvent = beamEvent; + state.transportPrimaries = !beamEvent; + return state; +} + +void AtSimpleSimulationReplayTask::FinishEventSource() +{ + if (fPrimaryTrackFile == nullptr) + return; + + fPrimaryTrackFile->Close(); + delete fPrimaryTrackFile; + fPrimaryTrackFile = nullptr; + fPrimaryTrackTree = nullptr; + fPrimaryTrackInput = nullptr; +} + +bool AtSimpleSimulationReplayTask::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(AtSimpleSimulationReplayTask); diff --git a/AtDigitization/AtSimpleSimulationReplayTask.h b/AtDigitization/AtSimpleSimulationReplayTask.h new file mode 100644 index 000000000..f0c8ba98a --- /dev/null +++ b/AtDigitization/AtSimpleSimulationReplayTask.h @@ -0,0 +1,40 @@ +#ifndef AtSimpleSimulationReplayTask_h +#define AtSimpleSimulationReplayTask_h + +#include "AtSimpleSimulationTask.h" + +#include + +#include + +class TBuffer; +class TClass; +class TFile; +class TMemberInspector; +class TTree; + +class AtSimpleSimulationReplayTask : public AtSimpleSimulationTask { +public: + explicit AtSimpleSimulationReplayTask(std::unique_ptr sim); + ~AtSimpleSimulationReplayTask() 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(AtSimpleSimulationReplayTask, 1); +}; + +#endif diff --git a/AtDigitization/AtSimpleSimulationTask.cxx b/AtDigitization/AtSimpleSimulationTask.cxx new file mode 100644 index 000000000..54658b4be --- /dev/null +++ b/AtDigitization/AtSimpleSimulationTask.cxx @@ -0,0 +1,257 @@ +#include "AtSimpleSimulationTask.h" + +#include "AtDetectorList.h" +#include "AtMCTrack.h" +#include "AtSimpleSimulation.h" +#include "AtTpc/AtTpc.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +using namespace ROOT::Math; + +namespace { +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 + +AtSimpleSimulationTask::AtSimpleSimulationTask(std::unique_ptr sim) : fSimulation(std::move(sim)) {} + +InitStatus AtSimpleSimulationTask::Init() +{ + if (fDetector == nullptr) { + LOG(info) << "AtSimpleSimulationTask: using standalone AtMCPoint writer"; + fSimulation->RegisterBranch(); + } else { + LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; + } + + auto sourceStatus = InitEventSource(); + if (sourceStatus != kSUCCESS) + return sourceStatus; + + RegisterMCTrackBranch(); + return kSUCCESS; +} + +void AtSimpleSimulationTask::Exec(Option_t *) +{ + fSimulation->NewEvent(); + + auto eventState = LoadEvent(); + if (!eventState.hasEvent) + return; + + FillMCTracks(); + if (!eventState.transportPrimaries) + return; + + TransportCurrentEvent(eventState.beamEvent); +} + +void AtSimpleSimulationTask::Finish() { FinishEventSource(); } + +InitStatus AtSimpleSimulationTask::InitEventSource() { return kSUCCESS; } + +void AtSimpleSimulationTask::FinishEventSource() {} + +void AtSimpleSimulationTask::RegisterMCTrackBranch() +{ + auto *ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before AtSimpleSimulationTask::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 AtSimpleSimulationTask::FillMCTracks() +{ + if (fMCTrackArray == nullptr) + return; + + fMCTrackArray->Clear("C"); + for (const auto &particle : fCollector.GetParticles()) { + new ((*fMCTrackArray)[particle.trackID]) AtMCTrack(particle.pdgCode, -1, particle.px, particle.py, particle.pz, + particle.vx, particle.vy, particle.vz, 0.0, 0); + } +} + +void AtSimpleSimulationTask::TransportCurrentEvent(bool beamEvent) +{ + for (const auto &particle : fCollector.GetParticles()) + TransportParticle(particle, beamEvent); +} + +void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &particle, bool beamEvent) +{ + auto [Z, A] = GetZAFromPDG(particle.pdgCode); + if (Z == 0 && A == 0) + return; + + XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); + PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., particle.e * 1000.); + + try { + if (fDetector != nullptr && !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"; + + if (fDetector == nullptr) { + fSimulation->SimulateParticle(Z, A, pos, mom); + return; + } + + const bool beamTrack = beamEvent && particle.trackID == 0; + if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && + !SubmitInitialSensitivePoint(particle.trackID, particle.pdgCode, beamTrack, pos, mom)) + return; + + fSimulation->TransportParticle( + Z, A, pos, mom, [this, trackID = particle.trackID, beamEvent](const AtSimpleSimulation::TransportStep &step) { + const bool preSensitive = IsSensitiveVolume(step.preVolumeName); + const bool postSensitive = IsSensitiveVolume(step.postVolumeName); + const bool entering = !preSensitive && postSensitive; + const bool exiting = preSensitive && !postSensitive; + const bool currentBeamTrack = beamEvent && trackID == 0; + const bool keepTransporting = + ProcessDetectorStep(step, trackID, currentBeamTrack, preSensitive, postSensitive, entering, exiting); + if (exiting && !postSensitive) + return false; + return keepTransporting; + }); + } catch (const std::invalid_argument &ex) { + LOG(debug) << "AtSimpleSimulationTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); + } +} + +bool AtSimpleSimulationTask::SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const XYZPoint &pos, + const PxPyPzEVector &mom) +{ + if (fDetector == nullptr) + return true; + + AtTpc::StepState detectorStep; + detectorStep.trackID = trackID; + detectorStep.pdg = pdg; + detectorStep.volumeName = fSimulation->GetVolumeNameAt(pos).c_str(); + detectorStep.volumeID = kAtTpc; + detectorStep.detCopyID = 0; + detectorStep.beamTrack = beamTrack; + detectorStep.entering = true; + detectorStep.exiting = false; + detectorStep.stopping = (mom.E() - mom.M() <= 1e-3); + detectorStep.disappeared = false; + detectorStep.energyLoss = 0.0; + detectorStep.timeNs = 0.0; + detectorStep.trackLength = 0.0; + detectorStep.totalEnergy = mom.E() / 1000.; + detectorStep.trackMass = mom.M() / 1000.; + detectorStep.pos.SetXYZT(pos.X() / 10., pos.Y() / 10., pos.Z() / 10., 0.0); + detectorStep.mom.SetXYZT(mom.Px() / 1000., mom.Py() / 1000., mom.Pz() / 1000., mom.E() / 1000.); + detectorStep.posOut = detectorStep.pos; + detectorStep.momOut = detectorStep.mom; + + return !fDetector->ProcessStep(detectorStep); +} + +bool AtSimpleSimulationTask::ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, + bool preSensitive, bool postSensitive, bool entering, bool exiting) +{ + if (!preSensitive && !postSensitive) + return true; + + AtTpc::StepState detectorStep; + detectorStep.trackID = trackID; + detectorStep.pdg = step.pdg; + detectorStep.volumeName = postSensitive ? step.postVolumeName.c_str() : step.preVolumeName.c_str(); + detectorStep.volumeID = kAtTpc; + detectorStep.detCopyID = 0; + detectorStep.beamTrack = beamTrack; + detectorStep.entering = entering; + detectorStep.exiting = exiting; + detectorStep.stopping = postSensitive && (step.postMomentum.E() - step.postMomentum.M() <= 1e-3); + detectorStep.disappeared = false; + detectorStep.energyLoss = step.energyLoss / 1000.; + detectorStep.timeNs = 0.; + detectorStep.trackLength = step.length / 10.; + + const auto &refPos = postSensitive ? step.postPosition : step.prePosition; + const auto &refMom = postSensitive ? step.postMomentum : step.preMomentum; + detectorStep.totalEnergy = refMom.E() / 1000.; + detectorStep.trackMass = step.trackMass / 1000.; + detectorStep.pos.SetXYZT(refPos.X() / 10., refPos.Y() / 10., refPos.Z() / 10., 0.); + detectorStep.mom.SetXYZT(refMom.Px() / 1000., refMom.Py() / 1000., refMom.Pz() / 1000., refMom.E() / 1000.); + detectorStep.posOut.SetXYZT(step.postPosition.X() / 10., step.postPosition.Y() / 10., step.postPosition.Z() / 10., 0.); + detectorStep.momOut.SetXYZT(step.postMomentum.Px() / 1000., step.postMomentum.Py() / 1000., + step.postMomentum.Pz() / 1000., step.postMomentum.E() / 1000.); + + const bool stopTransport = fDetector->ProcessStep(detectorStep); + return !stopTransport; +} + +XYZPoint AtSimpleSimulationTask::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"); + + constexpr double stepMm = 1.0; + constexpr int maxSteps = 5000; + auto probe = pos; + for (int i = 0; i < maxSteps; ++i) { + probe += dir * stepMm; + if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(probe))) + return probe; + } + + throw std::invalid_argument("Particle does not intersect a sensitive detector volume"); +} + +bool AtSimpleSimulationTask::IsSensitiveVolume(const std::string &volumeName) +{ + return volumeName.find("drift_volume") != std::string::npos || volumeName.find("window") != std::string::npos || + volumeName.find("cell") != std::string::npos; +} + +ClassImp(AtSimpleSimulationTask); diff --git a/AtDigitization/AtSimpleSimulationTask.h b/AtDigitization/AtSimpleSimulationTask.h new file mode 100644 index 000000000..7df297f97 --- /dev/null +++ b/AtDigitization/AtSimpleSimulationTask.h @@ -0,0 +1,65 @@ +#ifndef AtSimpleSimulationTask_h +#define AtSimpleSimulationTask_h + +#include "AtSimParticleCollector.h" +#include "AtSimpleSimulation.h" + +#include +#include +#include + +#include +#include + +class AtTpc; +class TBuffer; +class TClass; +class TClonesArray; +class TMemberInspector; + +class AtSimpleSimulationTask : public FairTask { +public: + struct EventState { + bool hasEvent{false}; + bool beamEvent{false}; + bool transportPrimaries{true}; + }; + + explicit AtSimpleSimulationTask(std::unique_ptr sim); + ~AtSimpleSimulationTask() 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; + + AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } + +protected: + std::unique_ptr fSimulation{nullptr}; //! + AtTpc *fDetector{nullptr}; //! + AtSimParticleCollector fCollector; //! + TClonesArray *fMCTrackArray{nullptr}; //! + + virtual InitStatus InitEventSource(); + virtual EventState LoadEvent() = 0; + virtual void FinishEventSource(); + + void RegisterMCTrackBranch(); + void FillMCTracks(); + void TransportCurrentEvent(bool beamEvent); + void TransportParticle(const AtCollectedParticle &particle, bool beamEvent); + bool SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const ROOT::Math::XYZPoint &pos, + const ROOT::Math::PxPyPzEVector &mom); + bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, + bool postSensitive, bool entering, bool exiting); + ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, + const ROOT::Math::PxPyPzEVector &mom) const; + static bool IsSensitiveVolume(const std::string &volumeName); + + ClassDefOverride(AtSimpleSimulationTask, 1); +}; + +#endif diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx index 2bbd73316..e9fe8fdab 100644 --- a/AtDigitization/AtTestSimulation.cxx +++ b/AtDigitization/AtTestSimulation.cxx @@ -1,322 +1,3 @@ #include "AtTestSimulation.h" -#include "AtDetectorList.h" -#include "AtMCTrack.h" -#include "AtSimParticleCollector.h" -#include "AtSimpleSimulation.h" -#include "AtTpc/AtTpc.h" -#include "AtVertexPropagator.h" - -#include -#include -#include -#include -#include // for InitStatus, kSUCCESS - -#include -#include // for Math, XYZPoint -#include // for LorentzVector -#include // for PxPyPzEVector -#include -#include -#include -#include -#include - -#include -#include -#include -using namespace ROOT::Math; - -// --------------------------------------------------------------------------- -// Helper: extract (Z, A) from a PDG code. -// -// Heavy ions: PDG = 1000000000 + Z*10000 + A*10 + I (I = isomer level, usually 0) -// Light particles (proton, alpha, etc.): use TDatabasePDG charge/mass. -// --------------------------------------------------------------------------- -namespace { -std::pair GetZAFromPDG(int pdg) -{ - if (pdg > 1000000000) { - int A = (pdg / 10) % 1000; - int Z = (pdg / 10000) % 1000; - return {Z, A}; - } - // Fall back to PDG database - TParticlePDG *p = TDatabasePDG::Instance()->GetParticle(pdg); - if (p) { - // Charge() returns units of |e|/3 - int Z = static_cast(std::round(p->Charge() / 3.0)); - // Mass in GeV/c² → convert to amu (1 amu ≈ 0.9315 GeV/c²) - int A = static_cast(std::round(p->Mass() / 0.9315)); - return {Z, std::max(A, 1)}; - } - return {0, 0}; -} -} // namespace - -void AtTestSimulation::RegisterMCTrackBranch() -{ - auto *ioMan = FairRootManager::Instance(); - if (ioMan == nullptr) { - LOG(fatal) << "The IO manager was not instantiated before AtTestSimulation::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 AtTestSimulation::FillMCTracks() -{ - if (fMCTrackArray == nullptr) - return; - - fMCTrackArray->Clear("C"); - - for (const auto &p : fCollector.GetParticles()) { - new ((*fMCTrackArray)[p.trackID]) AtMCTrack(p.pdgCode, -1, p.px, p.py, p.pz, p.vx, p.vy, p.vz, 0.0, 0); - } -} - -InitStatus AtTestSimulation::Init() -{ - if (fDetector == nullptr) { - LOG(info) << "AtTestSimulation: using standalone AtSimpleSimulation branch writer"; - fSimulation->RegisterBranch(); - } else { - LOG(info) << "AtTestSimulation: using detector-coupled transport adapter"; - } - - if (fPrimGen) { - // FairPrimaryGenerator::GenerateEvent() requires a non-null FairMCEventHeader. - fMCHeader = std::make_unique(); - fPrimGen->SetEvent(fMCHeader.get()); - fPrimGen->Init(); - } - - if (!fPrimaryTrackSourceFile.empty()) { - fPrimaryTrackFile = TFile::Open(fPrimaryTrackSourceFile.c_str(), "READ"); - if (fPrimaryTrackFile == nullptr || fPrimaryTrackFile->IsZombie()) { - LOG(fatal) << "AtTestSimulation: cannot open primary track source " << fPrimaryTrackSourceFile; - return kFATAL; - } - - fPrimaryTrackTree = dynamic_cast(fPrimaryTrackFile->Get("cbmsim")); - if (fPrimaryTrackTree == nullptr) { - LOG(fatal) << "AtTestSimulation: missing cbmsim tree in primary track source " << fPrimaryTrackSourceFile; - return kFATAL; - } - - fPrimaryTrackTree->SetBranchAddress("MCTrack", &fPrimaryTrackInput); - LOG(info) << "AtTestSimulation: replaying primary MC tracks from " << fPrimaryTrackSourceFile; - } - - RegisterMCTrackBranch(); - - return kSUCCESS; -} - -bool AtTestSimulation::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; -} - -void AtTestSimulation::Exec(Option_t *) -{ - fSimulation->NewEvent(); - bool loadedEvent = false; - bool isBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); - - if (fPrimaryTrackTree != nullptr) { - isBeamEvent = (fSourceEventIndex % 2) == 0; - AtVertexPropagator::Instance()->SetIsBeamEvent(isBeamEvent); - loadedEvent = LoadPrimaryTracksFromSource(); - } else if (fPrimGen != nullptr) { - fCollector.Clear(); - fPrimGen->GenerateEvent(&fCollector); - loadedEvent = true; - } - - if (!loadedEvent) - return; - - FillMCTracks(); - - if (fPrimaryTrackTree != nullptr && isBeamEvent) - return; - - for (const auto &p : fCollector.GetParticles()) { - auto [Z, A] = GetZAFromPDG(p.pdgCode); - if (Z == 0 && A == 0) { - continue; // skip unknown particles - } - - // FairRoot uses GeV/c for momentum and cm for position; AtSimpleSimulation uses MeV/c and mm. - XYZPoint pos(p.vx * 10., p.vy * 10., p.vz * 10.); // cm → mm - PxPyPzEVector mom(p.px * 1000., p.py * 1000., p.pz * 1000., p.e * 1000.); // GeV → MeV - - try { - if (fDetector != nullptr && !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"; - if (fDetector != nullptr) { - const bool beamTrack = isBeamEvent && p.trackID == 0; - if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && - !SubmitInitialSensitivePoint(p.trackID, p.pdgCode, beamTrack, pos, mom)) - continue; - fSimulation->TransportParticle( - Z, A, pos, mom, - [this, trackID = p.trackID, isBeamEvent](const AtSimpleSimulation::TransportStep &step) { - const bool preSensitive = IsSensitiveVolume(step.preVolumeName); - const bool postSensitive = IsSensitiveVolume(step.postVolumeName); - const bool entering = !preSensitive && postSensitive; - const bool exiting = preSensitive && !postSensitive; - const bool isBeamTrack = isBeamEvent && trackID == 0; - const bool keepTransporting = - ProcessDetectorStep(step, trackID, isBeamTrack, preSensitive, postSensitive, entering, exiting); - if (exiting && !postSensitive) - return false; - return keepTransporting; - }); - } else { - fSimulation->SimulateParticle(Z, A, pos, mom); - } - } catch (const std::invalid_argument &ex) { - // Legacy direct simulation only supports drift-volume starts. The detector-coupled path - // also rejects tracks that begin outside the imported geometry. - LOG(debug) << "AtTestSimulation: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); - } - } -} - -bool AtTestSimulation::SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const XYZPoint &pos, - const PxPyPzEVector &mom) -{ - if (fDetector == nullptr) - return true; - - AtTpc::StepState detectorStep; - detectorStep.trackID = trackID; - detectorStep.pdg = pdg; - detectorStep.volumeName = fSimulation->GetVolumeNameAt(pos).c_str(); - detectorStep.volumeID = kAtTpc; - detectorStep.detCopyID = 0; - detectorStep.beamTrack = beamTrack; - detectorStep.entering = true; - detectorStep.exiting = false; - detectorStep.stopping = (mom.E() - mom.M() <= 1e-3); - detectorStep.disappeared = false; - detectorStep.energyLoss = 0.0; - detectorStep.timeNs = 0.0; - detectorStep.trackLength = 0.0; - detectorStep.totalEnergy = mom.E() / 1000.; - detectorStep.trackMass = mom.M() / 1000.; - detectorStep.pos.SetXYZT(pos.X() / 10., pos.Y() / 10., pos.Z() / 10., 0.0); - detectorStep.mom.SetXYZT(mom.Px() / 1000., mom.Py() / 1000., mom.Pz() / 1000., mom.E() / 1000.); - detectorStep.posOut = detectorStep.pos; - detectorStep.momOut = detectorStep.mom; - - return !fDetector->ProcessStep(detectorStep); -} - -bool AtTestSimulation::ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, - bool preSensitive, bool postSensitive, bool entering, bool exiting) -{ - if (!preSensitive && !postSensitive) - return true; - - AtTpc::StepState detectorStep; - detectorStep.trackID = trackID; - detectorStep.pdg = step.pdg; - detectorStep.volumeName = postSensitive ? step.postVolumeName.c_str() : step.preVolumeName.c_str(); - detectorStep.volumeID = kAtTpc; - detectorStep.detCopyID = 0; - detectorStep.beamTrack = beamTrack; - detectorStep.entering = entering; - detectorStep.exiting = exiting; - detectorStep.stopping = postSensitive && (step.postMomentum.E() - step.postMomentum.M() <= 1e-3); - detectorStep.disappeared = false; - detectorStep.energyLoss = step.energyLoss / 1000.; - detectorStep.timeNs = 0.; - detectorStep.trackLength = step.length / 10.; - - const auto &refPos = postSensitive ? step.postPosition : step.prePosition; - const auto &refMom = postSensitive ? step.postMomentum : step.preMomentum; - detectorStep.totalEnergy = refMom.E() / 1000.; - detectorStep.trackMass = step.trackMass / 1000.; - detectorStep.pos.SetXYZT(refPos.X() / 10., refPos.Y() / 10., refPos.Z() / 10., 0.); - detectorStep.mom.SetXYZT(refMom.Px() / 1000., refMom.Py() / 1000., refMom.Pz() / 1000., refMom.E() / 1000.); - detectorStep.posOut.SetXYZT(step.postPosition.X() / 10., step.postPosition.Y() / 10., step.postPosition.Z() / 10., 0.); - detectorStep.momOut.SetXYZT(step.postMomentum.Px() / 1000., step.postMomentum.Py() / 1000., step.postMomentum.Pz() / 1000., - step.postMomentum.E() / 1000.); - - const bool stopTransport = fDetector->ProcessStep(detectorStep); - return !stopTransport; -} - -XYZPoint AtTestSimulation::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"); - - constexpr double stepMm = 1.0; - constexpr int maxSteps = 5000; - auto probe = pos; - for (int i = 0; i < maxSteps; ++i) { - probe += dir * stepMm; - if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(probe))) - return probe; - } - - throw std::invalid_argument("Particle does not intersect a sensitive detector volume"); -} - -bool AtTestSimulation::IsSensitiveVolume(const std::string &volumeName) -{ - return volumeName.find("drift_volume") != std::string::npos || volumeName.find("window") != std::string::npos || - volumeName.find("cell") != std::string::npos; -} - -void AtTestSimulation::Finish() -{ - if (fPrimaryTrackFile != nullptr) { - fPrimaryTrackFile->Close(); - delete fPrimaryTrackFile; - fPrimaryTrackFile = nullptr; - fPrimaryTrackTree = nullptr; - fPrimaryTrackInput = nullptr; - } -} - ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h index bd73b936f..417e2dd93 100644 --- a/AtDigitization/AtTestSimulation.h +++ b/AtDigitization/AtTestSimulation.h @@ -1,84 +1,20 @@ #ifndef AtTestSimulation_h #define AtTestSimulation_h -#include "AtSimParticleCollector.h" -#include "AtMCTrack.h" -#include "AtSimpleSimulation.h" // for AtSimpleSimulation +#include "AtSimpleSimulationGeneratorTask.h" -#include // for THashConsistencyHolder, ClassDefOver... -#include +#include -#include "FairTask.h" - -#include // for unique_ptr -#include -#include // for move -#include - -class AtTpc; -class FairPrimaryGenerator; class TBuffer; class TClass; class TMemberInspector; -class TFile; -class TTree; - -/** - * @brief FairTask wrapper for AtSimpleSimulation. - * - * When a FairPrimaryGenerator is provided via SetPrimaryGenerator(), it is called each event to - * generate particles. The generated particles are collected by AtSimParticleCollector (bypassing - * the Geant4/VMC stack entirely) and forwarded to AtSimpleSimulation::SimulateParticle(). - * - * When no generator is set, the task does nothing (no hardcoded particles). - */ -class AtTestSimulation : public FairTask { -protected: - std::unique_ptr fSimulation{nullptr}; //! - FairPrimaryGenerator *fPrimGen{nullptr}; //! - AtTpc *fDetector{nullptr}; //! - AtSimParticleCollector fCollector; //! - TClonesArray *fMCTrackArray{nullptr}; //! - std::string fPrimaryTrackSourceFile; //! - TFile *fPrimaryTrackFile{nullptr}; //! - TTree *fPrimaryTrackTree{nullptr}; //! - TClonesArray *fPrimaryTrackInput{nullptr}; //! - Long64_t fSourceEventIndex{0}; //! - - // Owned MCEventHeader required by FairPrimaryGenerator::GenerateEvent() - std::unique_ptr fMCHeader; //! +class AtTestSimulation : public AtSimpleSimulationGeneratorTask { public: - AtTestSimulation(std::unique_ptr sim) : fSimulation(std::move(sim)) {} - virtual ~AtTestSimulation() = default; - - /** - * @brief Set the primary generator used to populate particles each event. - * - * The generator is NOT owned by this task — the caller retains ownership. - * It must remain valid for the lifetime of the task. - */ - void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } - void SetPrimaryTrackSource(const std::string &fileName) { fPrimaryTrackSourceFile = fileName; } - void SetDetector(AtTpc *detector) { fDetector = detector; } - - virtual InitStatus Init() override; - virtual void Exec(Option_t *option) override; - virtual void Finish() override; - AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } - -private: - void RegisterMCTrackBranch(); - void FillMCTracks(); - bool LoadPrimaryTracksFromSource(); - bool SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const ROOT::Math::XYZPoint &pos, - const ROOT::Math::PxPyPzEVector &mom); - bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, - bool postSensitive, bool entering, bool exiting); - ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom) const; - static bool IsSensitiveVolume(const std::string &volumeName); + using AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask; + ~AtTestSimulation() override = default; - ClassDefOverride(AtTestSimulation, 2); + ClassDefOverride(AtTestSimulation, 3); }; #endif /* AtTestSimulation_h */ diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index fe85b54c6..fa469cf58 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -37,6 +37,9 @@ AtTriggerTask.cxx AtVectorResponse.cxx AtSimpleSimulation.cxx +AtSimpleSimulationTask.cxx +AtSimpleSimulationGeneratorTask.cxx +AtSimpleSimulationReplayTask.cxx AtTestSimulation.cxx AtSimParticleCollector.cxx ) diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index 478da35a0..c88586a66 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -97,7 +97,7 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed auto *eventLoopDriver = new FairPrimaryGenerator(); run->SetGenerator(eventLoopDriver); - auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + auto *simTask = new AtSimpleSimulationReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C index 64805bc1e..0cd4ebc2c 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -98,7 +98,8 @@ void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42, run->SetGenerator(new FairPrimaryGenerator()); - auto *simTask = new AtTestSimulation(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + auto *simTask = + new AtSimpleSimulationReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); From c99f65538a7d35ae6bb327d53e97321d9a3b177f Mon Sep 17 00:00:00 2001 From: Adam Anthony Date: Tue, 7 Apr 2026 17:42:47 -0400 Subject: [PATCH 14/18] Fix SimpleSim integration issues from code review - Fix beam/reaction flag inversion in generator task by capturing IsBeamEvent() before GenerateEvent() toggles it - Guard ProcessStep exit-stop with fStopOnReactionVolumeExit flag so Geant4 path behavior is preserved (defaults off) - Replace 15+ raw unit conversion factors with named constants - Unify sensitive volume logic via AtTpc::IsSensitiveVolume() - Remove vestigial AtTestSimulation class and #define private hack - Defer geometry check in AtSimpleSimulation default constructor so it can use FairRunSim's gGeoManager - Escalate missing energy-loss model from LOG(debug) to LOG(fatal) - Add SimpleSim migration guide and update simulation pipeline docs Co-Authored-By: Claude Opus 4.6 --- .claude/CLAUDE.md | 1 + AtDetectors/AtTpc/AtTpc.cxx | 18 +-- AtDetectors/AtTpc/AtTpc.h | 8 ++ AtDetectors/AtTpc/AtTpcTest.cxx | 14 ++- AtDigitization/AtDigiLinkDef.h | 1 - AtDigitization/AtSimTest.cxx | 26 +++-- AtDigitization/AtSimpleSimulation.cxx | 8 +- .../AtSimpleSimulationGeneratorTask.cxx | 5 +- AtDigitization/AtSimpleSimulationTask.cxx | 45 +++++--- AtDigitization/AtTestSimulation.cxx | 3 - AtDigitization/AtTestSimulation.h | 20 ---- AtDigitization/CMakeLists.txt | 1 - docs/index.md | 1 + docs/subsystems/simplesim-migration.md | 105 ++++++++++++++++++ docs/subsystems/simulation-pipeline.md | 81 ++++++++------ .../AtSimpleSimMigrationDraft.md | 43 ++----- .../AtSimValidation/simpleSim_fixed.C | 2 +- macro/e12014/adam/simulation/simpleSim.C | 2 +- 18 files changed, 251 insertions(+), 133 deletions(-) delete mode 100644 AtDigitization/AtTestSimulation.cxx delete mode 100644 AtDigitization/AtTestSimulation.h create mode 100644 docs/subsystems/simplesim-migration.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ac23bb6a2..f3a092f4b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -22,6 +22,7 @@ Quick topic links: | 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) | +| SimpleSim migration | [subsystems/simplesim-migration.md](../docs/subsystems/simplesim-migration.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) | diff --git a/AtDetectors/AtTpc/AtTpc.cxx b/AtDetectors/AtTpc/AtTpc.cxx index f497aa8b5..133898e8e 100644 --- a/AtDetectors/AtTpc/AtTpc.cxx +++ b/AtDetectors/AtTpc/AtTpc.cxx @@ -230,9 +230,9 @@ bool AtTpc::ProcessStep(const StepState &step) return true; } - // For this detector geometry, leaving the active gas means hitting the surrounding wall. - // The validation transport should terminate at the first exit from the reaction volume. - if (step.exiting && IsReactionVolume(fVolName)) + // For SimpleSim transport, leaving the active gas means transport should stop. + // Guarded by flag to preserve Geant4 behavior where products may continue into boundary volumes. + if (fStopOnReactionVolumeExit && step.exiting && IsReactionVolume(fVolName)) return true; return false; @@ -329,12 +329,16 @@ void AtTpc::ConstructGeometry() } } -Bool_t AtTpc::CheckIfSensitive(std::string name) +bool AtTpc::IsSensitiveVolume(const std::string &name) { + return name.find("drift_volume") != std::string::npos || name.find("window") != std::string::npos || + name.find("cell") != std::string::npos; +} - TString tsname = name; - if (tsname.Contains("drift_volume") || tsname.Contains("window") || tsname.Contains("cell")) { - LOG(info) << " AtTPC geometry: Sensitive volume found: " << tsname; +Bool_t AtTpc::CheckIfSensitive(std::string name) +{ + if (IsSensitiveVolume(name)) { + LOG(info) << " AtTPC geometry: Sensitive volume found: " << name; return kTRUE; } return kFALSE; diff --git a/AtDetectors/AtTpc/AtTpc.h b/AtDetectors/AtTpc/AtTpc.h index 0891e0bf9..f5fd1cd60 100644 --- a/AtDetectors/AtTpc/AtTpc.h +++ b/AtDetectors/AtTpc/AtTpc.h @@ -78,6 +78,7 @@ class AtTpc : public FairDetector { Double32_t fELossAcc; TLorentzVector InPos; bool fIsBeamTrack = false; + bool fStopOnReactionVolumeExit{false}; /** container for data points */ @@ -117,6 +118,13 @@ class AtTpc : public FairDetector { */ bool ProcessStep(const StepState &step); + /// When true, ProcessStep returns true (stop transport) when any particle exits a reaction volume. + /// Used by SimpleSim; defaults to false to preserve Geant4 behavior. + void SetStopOnReactionVolumeExit(bool val) { fStopOnReactionVolumeExit = val; } + + /// Canonical check for whether a volume name is a sensitive detector volume. + static bool IsSensitiveVolume(const std::string &name); + private: std::pair DecodePdG(Int_t PdG_Code); diff --git a/AtDetectors/AtTpc/AtTpcTest.cxx b/AtDetectors/AtTpc/AtTpcTest.cxx index b14657303..390ed36f1 100644 --- a/AtDetectors/AtTpc/AtTpcTest.cxx +++ b/AtDetectors/AtTpc/AtTpcTest.cxx @@ -112,8 +112,9 @@ TEST_F(AtTpcTest, ReactionEventTrackZeroExitDoesNotResetVertexState) EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 1.0); } -TEST_F(AtTpcTest, ExitingReactionVolumeStopsTransport) +TEST_F(AtTpcTest, ExitingReactionVolumeStopsTransportWhenFlagSet) { + detector.SetStopOnReactionVolumeExit(true); auto step = MakeStep(1, 2212, "drift_volume", 0.0, 0.95, 20.0); step.exiting = true; step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); @@ -123,6 +124,17 @@ TEST_F(AtTpcTest, ExitingReactionVolumeStopsTransport) EXPECT_TRUE(stopTransport); } +TEST_F(AtTpcTest, ExitingReactionVolumeDoesNotStopByDefault) +{ + auto step = MakeStep(1, 2212, "drift_volume", 0.0, 0.95, 20.0); + step.exiting = true; + step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); + + const bool stopTransport = detector.ProcessStep(step); + + EXPECT_FALSE(stopTransport); +} + TEST_F(AtTpcTest, NonBeamTracksUseStoredMetadata) { AtVertexPropagator::Instance()->SetTrackEnergy(1, 7.5); diff --git a/AtDigitization/AtDigiLinkDef.h b/AtDigitization/AtDigiLinkDef.h index 550797aed..57eedcea5 100644 --- a/AtDigitization/AtDigiLinkDef.h +++ b/AtDigitization/AtDigiLinkDef.h @@ -24,6 +24,5 @@ #pragma link C++ class AtSimpleSimulationTask +; #pragma link C++ class AtSimpleSimulationGeneratorTask +; #pragma link C++ class AtSimpleSimulationReplayTask +; -#pragma link C++ class AtTestSimulation +; #pragma link C++ class AtSimpleSimulation -!; #endif diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx index 53e5b537b..dfe08fd18 100644 --- a/AtDigitization/AtSimTest.cxx +++ b/AtDigitization/AtSimTest.cxx @@ -15,11 +15,7 @@ #include "AtMCPoint.h" #include "AtSimpleSimulation.h" -#define private public -#define protected public -#include "AtTestSimulation.h" -#undef protected -#undef private +#include "AtSimpleSimulationGeneratorTask.h" #include "AtELossModel.h" #include "AtMCTrack.h" @@ -84,6 +80,20 @@ class AtSimTest : public ::testing::Test { } }; +// --------------------------------------------------------------------------- +// Test helper: exposes protected members for testing without #define hacks. +// --------------------------------------------------------------------------- +class TestableSimTask : public AtSimpleSimulationGeneratorTask { +public: + using AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask; + using AtSimpleSimulationTask::fCollector; + using AtSimpleSimulationTask::fDetector; + using AtSimpleSimulationTask::fMCTrackArray; + using AtSimpleSimulationTask::FillMCTracks; + using AtSimpleSimulationTask::SubmitInitialSensitivePoint; + EventState LoadEvent() override { return {}; } +}; + // --------------------------------------------------------------------------- // Test 1 — ZeroFieldStraightLine // @@ -268,7 +278,7 @@ TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) { auto sim = std::make_unique(); - AtTestSimulation task(std::move(sim)); + TestableSimTask task(std::move(sim)); task.fMCTrackArray = new TClonesArray("AtMCTrack"); Int_t ntr = -1; @@ -296,7 +306,7 @@ TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) { auto sim = std::make_unique(); - AtTestSimulation task(std::move(sim)); + TestableSimTask task(std::move(sim)); task.fMCTrackArray = new TClonesArray("AtMCTrack"); Int_t ntr = -1; @@ -315,7 +325,7 @@ TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) { auto sim = std::make_unique(); - AtTestSimulation task(std::move(sim)); + TestableSimTask task(std::move(sim)); AtTpc detector; task.fDetector = &detector; diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index aaa411aa3..d526f0757 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -80,10 +80,10 @@ AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) } AtSimpleSimulation::AtSimpleSimulation() { - if (gGeoManager == nullptr) - LOG(fatal) << "No geometry file loaded!"; - - fGeoManager = gGeoManager; + // Defer geometry check until first use. FairRunSim::Init() sets up + // gGeoManager, which may not have happened yet at construction time. + // GetVolume() re-syncs with gGeoManager on each call. + fGeoManager = nullptr; fNavigator = nullptr; } diff --git a/AtDigitization/AtSimpleSimulationGeneratorTask.cxx b/AtDigitization/AtSimpleSimulationGeneratorTask.cxx index b1cff87d9..1cd2a9fc2 100644 --- a/AtDigitization/AtSimpleSimulationGeneratorTask.cxx +++ b/AtDigitization/AtSimpleSimulationGeneratorTask.cxx @@ -27,10 +27,13 @@ AtSimpleSimulationTask::EventState AtSimpleSimulationGeneratorTask::LoadEvent() 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 = AtVertexPropagator::Instance()->IsBeamEvent(); + state.beamEvent = wasBeamEvent; state.transportPrimaries = true; return state; } diff --git a/AtDigitization/AtSimpleSimulationTask.cxx b/AtDigitization/AtSimpleSimulationTask.cxx index 54658b4be..049be8212 100644 --- a/AtDigitization/AtSimpleSimulationTask.cxx +++ b/AtDigitization/AtSimpleSimulationTask.cxx @@ -24,6 +24,12 @@ 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) { @@ -52,6 +58,7 @@ InitStatus AtSimpleSimulationTask::Init() fSimulation->RegisterBranch(); } else { LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; + fDetector->SetStopOnReactionVolumeExit(true); } auto sourceStatus = InitEventSource(); @@ -127,8 +134,9 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic if (Z == 0 && A == 0) return; - XYZPoint pos(particle.vx * 10., particle.vy * 10., particle.vz * 10.); - PxPyPzEVector mom(particle.px * 1000., particle.py * 1000., particle.pz * 1000., particle.e * 1000.); + XYZPoint pos(particle.vx * kCmToMm, particle.vy * kCmToMm, particle.vz * kCmToMm); + PxPyPzEVector mom(particle.px * kGeVToMeV, particle.py * kGeVToMeV, particle.pz * kGeVToMeV, + particle.e * kGeVToMeV); try { if (fDetector != nullptr && !IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos))) @@ -161,7 +169,7 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic return keepTransporting; }); } catch (const std::invalid_argument &ex) { - LOG(debug) << "AtSimpleSimulationTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); + LOG(fatal) << "AtSimpleSimulationTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); } } @@ -185,10 +193,10 @@ bool AtSimpleSimulationTask::SubmitInitialSensitivePoint(int trackID, int pdg, b detectorStep.energyLoss = 0.0; detectorStep.timeNs = 0.0; detectorStep.trackLength = 0.0; - detectorStep.totalEnergy = mom.E() / 1000.; - detectorStep.trackMass = mom.M() / 1000.; - detectorStep.pos.SetXYZT(pos.X() / 10., pos.Y() / 10., pos.Z() / 10., 0.0); - detectorStep.mom.SetXYZT(mom.Px() / 1000., mom.Py() / 1000., mom.Pz() / 1000., mom.E() / 1000.); + detectorStep.totalEnergy = mom.E() * kMeVToGeV; + detectorStep.trackMass = mom.M() * kMeVToGeV; + detectorStep.pos.SetXYZT(pos.X() * kMmToCm, pos.Y() * kMmToCm, pos.Z() * kMmToCm, 0.0); + detectorStep.mom.SetXYZT(mom.Px() * kMeVToGeV, mom.Py() * kMeVToGeV, mom.Pz() * kMeVToGeV, mom.E() * kMeVToGeV); detectorStep.posOut = detectorStep.pos; detectorStep.momOut = detectorStep.mom; @@ -212,19 +220,21 @@ bool AtSimpleSimulationTask::ProcessDetectorStep(const AtSimpleSimulation::Trans detectorStep.exiting = exiting; detectorStep.stopping = postSensitive && (step.postMomentum.E() - step.postMomentum.M() <= 1e-3); detectorStep.disappeared = false; - detectorStep.energyLoss = step.energyLoss / 1000.; + detectorStep.energyLoss = step.energyLoss * kMeVToGeV; detectorStep.timeNs = 0.; - detectorStep.trackLength = step.length / 10.; + detectorStep.trackLength = step.length * kMmToCm; const auto &refPos = postSensitive ? step.postPosition : step.prePosition; const auto &refMom = postSensitive ? step.postMomentum : step.preMomentum; - detectorStep.totalEnergy = refMom.E() / 1000.; - detectorStep.trackMass = step.trackMass / 1000.; - detectorStep.pos.SetXYZT(refPos.X() / 10., refPos.Y() / 10., refPos.Z() / 10., 0.); - detectorStep.mom.SetXYZT(refMom.Px() / 1000., refMom.Py() / 1000., refMom.Pz() / 1000., refMom.E() / 1000.); - detectorStep.posOut.SetXYZT(step.postPosition.X() / 10., step.postPosition.Y() / 10., step.postPosition.Z() / 10., 0.); - detectorStep.momOut.SetXYZT(step.postMomentum.Px() / 1000., step.postMomentum.Py() / 1000., - step.postMomentum.Pz() / 1000., step.postMomentum.E() / 1000.); + 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; @@ -250,8 +260,7 @@ XYZPoint AtSimpleSimulationTask::FindSensitiveEntry(const XYZPoint &pos, const P bool AtSimpleSimulationTask::IsSensitiveVolume(const std::string &volumeName) { - return volumeName.find("drift_volume") != std::string::npos || volumeName.find("window") != std::string::npos || - volumeName.find("cell") != std::string::npos; + return AtTpc::IsSensitiveVolume(volumeName); } ClassImp(AtSimpleSimulationTask); diff --git a/AtDigitization/AtTestSimulation.cxx b/AtDigitization/AtTestSimulation.cxx deleted file mode 100644 index e9fe8fdab..000000000 --- a/AtDigitization/AtTestSimulation.cxx +++ /dev/null @@ -1,3 +0,0 @@ -#include "AtTestSimulation.h" - -ClassImp(AtTestSimulation); diff --git a/AtDigitization/AtTestSimulation.h b/AtDigitization/AtTestSimulation.h deleted file mode 100644 index 417e2dd93..000000000 --- a/AtDigitization/AtTestSimulation.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef AtTestSimulation_h -#define AtTestSimulation_h - -#include "AtSimpleSimulationGeneratorTask.h" - -#include - -class TBuffer; -class TClass; -class TMemberInspector; - -class AtTestSimulation : public AtSimpleSimulationGeneratorTask { -public: - using AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask; - ~AtTestSimulation() override = default; - - ClassDefOverride(AtTestSimulation, 3); -}; - -#endif /* AtTestSimulation_h */ diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index fa469cf58..ead7f55ba 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -40,7 +40,6 @@ AtSimpleSimulation.cxx AtSimpleSimulationTask.cxx AtSimpleSimulationGeneratorTask.cxx AtSimpleSimulationReplayTask.cxx -AtTestSimulation.cxx AtSimParticleCollector.cxx ) 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/subsystems/simplesim-migration.md b/docs/subsystems/simplesim-migration.md new file mode 100644 index 000000000..6897970a4 --- /dev/null +++ b/docs/subsystems/simplesim-migration.md @@ -0,0 +1,105 @@ +# 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. + +## 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 SimpleSim -- uses the geometry already loaded by FairRunSim +auto sim = std::make_unique(); + +// Register energy-loss models for every particle species +auto carbonModel = std::make_shared(gasDensity, gasMaterial); +carbonModel->SetProjectile(16, 6, 16.014701); +sim->AddModel(6, 16, carbonModel, 16.014701); + +auto protonModel = std::make_shared(gasDensity, gasMaterial); +protonModel->SetProjectile(1, 1, 1.0078250322); +sim->AddModel(1, 1, protonModel, 1.0078250322); + +// 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 AtSimpleSimulationGeneratorTask(std::move(sim)); +simTask->SetPrimaryGenerator(primGen); +simTask->SetDetector(tpc); +run->AddTask(simTask); +``` + +## Required configuration + +### Energy-loss models + +Every particle species that will be transported must have a registered energy-loss model. Call `sim->AddModel(Z, A, model)` for each species. If a particle has no model, the simulation terminates with a fatal error. + +Available model types: +- `AtTools::AtELossCATIMA` -- CATIMA-based energy loss (recommended) +- `AtTools::AtELossTable` -- SRIM table lookup + +See [energy-loss.md](energy-loss.md) for details. + +### Detector coupling + +`SetDetector(tpc)` connects SimpleSim to the `AtTpc` detector so that steps are processed through the same hit-recording and reaction-trigger logic used by Geant4. This is required for correct output. + +### Magnetic field + +If the experiment uses a magnetic field, set it on the `AtSimpleSimulation` instance: + +```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. + +### Geometry + +`AtSimpleSimulation()` (default constructor) automatically uses the geometry that `FairRunSim` loads. No separate geometry file is needed. If you need to use 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"); +``` + +## Replay mode + +`AtSimpleSimulationReplayTask` re-transports primary tracks from a prior Geant4 run through SimpleSim. This enables direct A/B comparison with identical kinematics: + +```cpp +auto *simTask = new AtSimpleSimulationReplayTask(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 +- `simpleSim_kinematic.C` / `geant4_kinematic.C` -- full kinematic sweep +- `compareFixed.C`, `compareKinematic.C` -- automated comparison plots diff --git a/docs/subsystems/simulation-pipeline.md b/docs/subsystems/simulation-pipeline.md index e808e922c..b1878c94c 100644 --- a/docs/subsystems/simulation-pipeline.md +++ b/docs/subsystems/simulation-pipeline.md @@ -2,41 +2,50 @@ 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) (AtSimpleSimulationTask) + │ │ + └──────────┬──────────┘ + ▼ + 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 `AtSimpleSimulation` to propagate particles through the geometry with user-configured `AtELossModel` instances, 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. +Two task classes are provided: -In this tree, `AtSimpleSimulation` uses straight-line propagation. Future versions may extend this step to non-linear tracks and more general transport models. +- **`AtSimpleSimulationGeneratorTask`** -- generates events live via a `FairPrimaryGenerator`, using the same generator chain as the Geant4 path. This is the primary task for production use. +- **`AtSimpleSimulationReplayTask`** -- 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 +61,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 +69,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 +80,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 `AtSimpleSimulation` instance (uses the FairRunSim geometry automatically) +- an `AtELossModel` configured for each particle species to be transported +- 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 registered for every (Z, A) pair that will be transported. If a particle has no model, the simulation will terminate with a fatal error. -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/AtSimpleSimMigrationDraft.md b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md index a0eaac58e..74f1b2501 100644 --- a/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md +++ b/macro/Simulation/AtSimValidation/AtSimpleSimMigrationDraft.md @@ -47,12 +47,12 @@ In a Geant macro, the physics generator is usually connected directly to the run run->SetGenerator(primGen); ``` -In a SimpleSim macro, the run gets a dummy event-loop generator, and the real physics generator is passed to `AtTestSimulation`: +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 AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); +auto *simTask = new AtSimpleSimulationGeneratorTask(BuildSimpleSimulation()); simTask->SetPrimaryGenerator(primGen); simTask->SetDetector(tpc); run->AddTask(simTask); @@ -108,9 +108,9 @@ What matters is that you configure: Minimal example: ```cpp -std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +std::unique_ptr BuildSimpleSimulation() { - auto sim = std::make_unique(geoFile.Data()); + auto sim = std::make_unique(); // uses FairRunSim geometry constexpr double gasDensity = 1.664e-4; std::vector> material{{4, 2, 1}}; @@ -146,7 +146,7 @@ SimpleSim pattern: ```cpp run->SetGenerator(new FairPrimaryGenerator()); -auto *simTask = new AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); +auto *simTask = new AtSimpleSimulationGeneratorTask(BuildSimpleSimulation()); simTask->SetPrimaryGenerator(primGen); simTask->SetDetector(tpc); run->AddTask(simTask); @@ -155,30 +155,10 @@ run->AddTask(simTask); Important: - `FairRunSim` still needs a generator object for the event loop -- the real physics generator is now passed into `AtTestSimulation` +- 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. Use the right geometry file for SimpleSim - -This is one of the easiest mistakes to make. - -For the detector module: - -- keep the usual detector geometry file, for example `ATTPC_He1bar.root` - -For `AtSimpleSimulation`: - -- use the importable geometry-manager file, for example `ATTPC_He1bar_geomanager.root` - -Typical pattern: - -```cpp -tpc->SetGeometryFileName((dir + "/geometry/ATTPC_He1bar.root").Data()); - -TString simpleSimGeoFile = dir + "/geometry/ATTPC_He1bar_geomanager.root"; -auto *simTask = new AtTestSimulation(BuildSimpleSimulation(simpleSimGeoFile)); -``` - -### 6. Keep the detector coupled +### 5. Keep the detector coupled Always connect the detector: @@ -202,12 +182,11 @@ For most user macros, the migration is: 3. Add SimpleSim configuration. 4. Replace `run->SetGenerator(primGen)` with: - `run->SetGenerator(new FairPrimaryGenerator())` - - `AtTestSimulation` + - `AtSimpleSimulationGeneratorTask` - `simTask->SetPrimaryGenerator(primGen)` - `simTask->SetDetector(tpc)` - `run->AddTask(simTask)` -5. Use the `*_geomanager.root` geometry for SimpleSim. -6. Add energy-loss models for all transported species. +5. Add energy-loss models for all transported species. 7. Run a small sample first. 8. Verify the output before scaling up. @@ -248,7 +227,7 @@ Local comparison macros in this directory already do that: ## Practical Notes - `AtSimpleSimulation` needs explicit energy-loss models. -- `AtTestSimulation` skips particles that start outside `drift_volume`. +- 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. diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index c88586a66..cbd9995b8 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -93,7 +93,7 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed 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 AtTestSimulation. + // actual physics generator for the SimpleSim path is owned by the SimpleSim task. auto *eventLoopDriver = new FairPrimaryGenerator(); run->SetGenerator(eventLoopDriver); diff --git a/macro/e12014/adam/simulation/simpleSim.C b/macro/e12014/adam/simulation/simpleSim.C index 325e9e89a..e8b5f91d4 100644 --- a/macro/e12014/adam/simulation/simpleSim.C +++ b/macro/e12014/adam/simulation/simpleSim.C @@ -40,7 +40,7 @@ void simpleSim() eloss->LoadSrimTable("./../PbinHeFull.txt"); sim->AddModel(82, 208, eloss); - AtTestSimulation *simTask = new AtTestSimulation(std::move(sim)); + auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); AtClusterizeLineTask *clusterizer = new AtClusterizeLineTask(); clusterizer->SetPersistence(kFALSE); From 3cbdf6bb634e6b0b3fccea5b4d1b9ef4a0bcd4b6 Mon Sep 17 00:00:00 2001 From: Adam Anthony Date: Tue, 7 Apr 2026 22:21:27 -0400 Subject: [PATCH 15/18] Add factory-based validation macros and update docs for model factories New macros demonstrate drop-in SimpleSim replacement of Geant4 transport using AtELossModelFactory. Each starts from the corresponding Geant4 macro with minimal changes: dummy generator, factory setup, and task creation. Updated energy-loss, simplesim-migration, simulation-pipeline, integration review, and modules docs to cover the factory pattern. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simplesim-integration-review.md | 383 ++++++++++++++++++ docs/reference/modules.md | 2 +- docs/subsystems/energy-loss.md | 37 ++ docs/subsystems/simplesim-migration.md | 34 +- docs/subsystems/simulation-pipeline.md | 4 +- .../simpleSim_fixed_bethebloch.C | 112 +++++ .../AtSimValidation/simpleSim_fixed_factory.C | 112 +++++ .../simpleSim_kinematic_factory.C | 114 ++++++ 8 files changed, 788 insertions(+), 10 deletions(-) create mode 100644 docs/development/simplesim-integration-review.md create mode 100644 macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C create mode 100644 macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C create mode 100644 macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C diff --git a/docs/development/simplesim-integration-review.md b/docs/development/simplesim-integration-review.md new file mode 100644 index 000000000..d514cd084 --- /dev/null +++ b/docs/development/simplesim-integration-review.md @@ -0,0 +1,383 @@ +# SimpleSim FairRoot Integration Review + +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 layer runs `AtSimpleSimulation` (an RK4/straight-line particle propagator with energy loss) inside FairRoot's event loop, so that its output feeds the same downstream digitization chain (`AtClusterizeTask` -> `AtPulseTask`) as Geant4 transport. + +### Abstractions introduced + +- **`AtTpc::StepState` + `ProcessStep()`** -- The central architectural move. `AtTpc::ProcessHits()` was refactored to extract all VMC/`gMC` queries into a plain data struct (`StepState`), then delegate to a new `ProcessStep(const StepState&)` method. This decouples the detector's hit-recording and reaction-trigger logic from the VMC transport engine. SimpleSim can call `ProcessStep()` directly without a running VMC. + +- **`AtSimParticleCollector`** -- A minimal `FairGenericStack` stub that captures `PushTrack()` calls. This lets existing `FairPrimaryGenerator` + `AtReactionGenerator` chains run unchanged: the generators push particles to what they think is the VMC stack, but the particles land in a simple vector instead. + +- **`AtSimpleSimulationTask` (abstract base)** -- Template Method pattern: `Init()` -> `InitEventSource()`, `Exec()` -> `LoadEvent()` -> `TransportCurrentEvent()`, `Finish()` -> `FinishEventSource()`. Owns the simulation, the particle collector, the MCTrack branch, and the detector-stepping logic. + +- **`AtSimpleSimulationGeneratorTask`** -- Generates events live via a `FairPrimaryGenerator`. + +- **`AtSimpleSimulationReplayTask`** -- Reads primary MCTracks from a prior Geant4 run and re-transports them through SimpleSim, enabling direct A/B comparison. + +### What it leaves to the user + +FairRunSim boilerplate (cave, geometry, materials, dummy generator, parameter I/O), energy loss model configuration per particle species, and magnetic/electric field setup. + +## 2. API Design Assessment + +### Discoverability + +The user cannot configure and run this without reading source code or the validation macros. There is no documentation of `AtSimpleSimulationGeneratorTask` or `AtSimpleSimulationReplayTask` in the docs directory -- the simulation pipeline doc (`subsystems/simulation-pipeline.md`) still describes SimpleSim as a standalone path, not as a FairTask. The macros in `macro/Simulation/AtSimValidation/` are the de facto documentation. + +A user encountering this for the first time must discover: +- That they need `FairRunSim` with a dummy `FairPrimaryGenerator` +- That `SetDetector(tpc)` is required for detector-coupled output +- That the replay task needs a Geant4 truth file with MCTrack branch on a "cbmsim" tree +- That they must configure energy loss models for every particle species they want transported + +None of this is documented outside the macros. + +### Error surface + +- **Forgetting `SetDetector()`:** The task falls through to legacy `SimulateParticle()` mode, which hardcodes "drift_volume" containment and writes its own `AtTpcPoint` branch directly. The user gets output that looks correct but bypasses the detector contract entirely. This should probably be an error or at minimum a warning -- the two paths produce structurally different output. + +- **Missing energy loss model:** Throws `std::invalid_argument`, which is caught and logged at `debug` level in `TransportParticle()`. The particle is silently skipped. A user might not notice reaction products disappearing. + +- **Geometry mismatch:** The simulation loads its own geometry file (e.g. `ATTPC_He1bar_geomanager.root`) while `FairRunSim` loads geometry for the detector module. If these don't match, volume lookups in SimpleSim and hit positions in `AtTpc` will silently disagree. + +- **Replay mode with wrong event count:** If the user requests more events than exist in the source file, `LoadPrimaryTracksFromSource()` returns `false`, `LoadEvent()` returns `hasEvent=false`, and `Exec()` silently returns. FairRoot happily runs empty events. + +### Consistency + +Mostly consistent. The pattern of `FairTask` subclass + `Init()`/`Exec()` is standard. Using `FairPrimaryGenerator` with a custom stack is clever and preserves the generator API. The `SetDetector()`/`SetSensitiveDetector()` naming follows FairRoot conventions. + +However, requiring `FairRunSim` with `run->SetName("TGeant3")` and a dummy generator to use a non-Geant transport is dissonant. The user is setting up Geant3 to not use Geant3. + +### Completeness + +- No way to set the B-field from a `FairField` object -- the user must manually translate `AtConstField` parameters to `sim->SetMagneticField(XYZVector(...))`. In the Geant4 path, `run->SetField()` handles this. +- No integration with `FairRunSim::SetStoreTraj()`. +- No event header population (event number, vertex, etc.). +- The generator task has no way to set a seed or control reproducibility independently of `gRandom`. + +## 3. Separation of Concerns + +**Class responsibilities are well-defined.** The base task handles transport mechanics and detector stepping. Subclasses handle only event sourcing. `AtSimParticleCollector` handles only particle capture. This is clean. + +**The generator/replay split is well-motivated.** The replay task enables controlled validation against Geant4 with identical kinematics. This is a legitimate use case that would be awkward to express as a mode flag on a single class. + +**FairRoot logic is mostly separated from physics logic.** The physics lives in `AtSimpleSimulation`; the FairRoot adaptation lives in the task hierarchy. The boundary is at `TransportParticle()` with its `StepCallback`. + +### Entanglement issues + +1. **Unit conversion is scattered across the boundary.** `AtSimpleSimulationTask::TransportParticle()` converts cm->mm and GeV->MeV with raw `* 10.` and `* 1000.` factors. `ProcessDetectorStep()` converts back with `/ 10.` and `/ 1000.`. `SubmitInitialSensitivePoint()` does the same. These magic numbers appear in ~15 places across the file. A single wrong factor produces wrong physics with no error. + +2. **Sensitive volume identification is duplicated.** `AtSimpleSimulationTask::IsSensitiveVolume()` is a static method with hardcoded volume name checks (`"drift_volume"`, `"window"`, `"cell"`) that duplicates `AtTpc::CheckIfSensitive()`. If the detector geometry adds a new sensitive volume, both must be updated independently. + +3. **`AtTpc::ProcessStep()` now embeds transport-termination logic** (stop on exiting reaction volume) that was not in the original Geant4 path. This is a behavioral change visible to all callers of `ProcessStep()`, including `ProcessHits()`, meaning the Geant4 path's behavior was changed too. + +## 4. Framework Integration Quality + +**The `StepState` refactoring of `AtTpc` is the strongest part of this branch.** It correctly preserves all the fields that `ProcessHits()` used to extract from `gMC`, and the Geant4 path still works through `ProcessHits()` -> populate `StepState` -> `ProcessStep()`. The test coverage of the detector contract (`AtTpcTest.cxx`) is solid. + +**Composition with upstream:** The generator task correctly reuses `FairPrimaryGenerator` with `AtSimParticleCollector` as the stack. The beam/reaction alternation via `AtVertexPropagator` is preserved. + +**Composition with downstream:** The task writes `AtTpcPoint` and `MCTrack` branches in the same format as the Geant4 path, so `AtClusterizeTask` and `AtPulseTask` should work unchanged. + +### Implicit assumptions that could break + +- **`correctPosOut()` is only applied in the Geant4 path.** When `ProcessHits()` calls `ProcessStep()`, exit positions have been corrected. When SimpleSim calls `ProcessStep()` directly, they haven't. This means the two paths produce slightly different hit positions near volume boundaries. + +- **The replay task hardcodes `fSourceEventIndex % 2 == 0` for beam event detection** (`AtSimpleSimulationReplayTask.cxx:45`). This assumes the source file always uses strict even/odd alternation. If the source was generated with a non-alternating generator, this will misidentify beam events. + +- **`RegisterMCTrackBranch()` reuses an existing "MCTrack" branch if one exists** (`AtSimpleSimulationTask.cxx:94-98`). In the replay scenario, the source file has an MCTrack branch, and FairRoot may expose it through `FairRootManager`. The task would then write into someone else's array. + +- **LinkDef streamer suffixes are wrong.** The task classes use `+;` (full streamer, for disk-persisted objects) but tasks should use `-!;` per project conventions. + +## 5. Tradeoffs and Direction + +### Tradeoffs made + +1. **Reuse FairRunSim infrastructure vs. standalone run.** The design chose to embed SimpleSim inside `FairRunSim` as a `FairTask`. This provides access to geometry, I/O, and the event loop -- but forces users to set up a dummy VMC engine they're explicitly trying to avoid. A standalone `FairRunAna`-based path would have been cleaner for the user but would have required reimplementing geometry loading. + +2. **Detector coupling vs. standalone MCPoint writing.** The task supports both: with `SetDetector()`, it feeds steps through `AtTpc::ProcessStep()`; without, it uses the legacy `SimulateParticle()` path that writes MCPoints directly. This provides flexibility but creates two output formats with subtly different semantics (the detector path accumulates energy loss per track, applies reaction triggers, etc.). + +3. **FairPrimaryGenerator reuse via fake stack.** This is the right call. It preserves the full generator ecosystem without modification. The tradeoff is that `AtSimParticleCollector` must implement a large interface of stubs, but the stubs are trivial. + +### Assessment + +For validation purposes (comparing SimpleSim to Geant4), these tradeoffs are reasonable. For production use by physics users, the FairRunSim boilerplate burden is too high -- but production use isn't the stated goal yet. + +This is moving toward a genuine pipeline replacement pattern, not a workaround. The `StepState` abstraction is the right seam. The design would need one more iteration -- extracting the sensitive-volume contract and making the FairRunSim dependency optional -- to be a clean, general-purpose alternative transport. + +## 6. Weak Points / Design Smells + +In order of severity: + +1. **Behavioral change to the Geant4 path.** `AtTpc::ProcessStep()` adds `if (step.exiting && IsReactionVolume(fVolName)) return true;` -- this stops the track when exiting the reaction volume. Previously in `ProcessHits()`, the track was not stopped on exit; only `resetVertex()` was called. Now `ProcessHits()` calls `gMC->StopTrack()` whenever `ProcessStep()` returns true. This changes Geant4 simulation behavior and needs careful validation or should be behind a flag. + +2. **Silent fallback to legacy mode.** When `fDetector` is null, the task silently switches to `SimulateParticle()` which writes MCPoints directly with different semantics (no reaction trigger, no beam/reaction alternation, hardcoded "drift_volume" containment). This is a footgun for users who forget `SetDetector()`. + +3. **Unit conversion by magic number.** The mm<->cm and MeV<->GeV conversions are scattered across 15+ call sites as raw `* 10.`, `/ 10.`, `* 1000.`, `/ 1000.`. A single wrong factor is a silent physics error. These should be named constants or conversion functions. + +4. **Duplicated sensitive volume logic.** `IsSensitiveVolume()` in the task and `CheckIfSensitive()` / `IsReactionVolume()` in the detector are independent implementations of the same concept. They also differ: `IsReactionVolume` checks `drift_volume` and `cell`; `IsSensitiveVolume` also checks `window`. This means the task considers windows sensitive but the detector's reaction logic doesn't -- by design or by accident? + +5. **`AtTestSimulation` is now vestigial.** After the refactoring, it's an empty class that inherits `AtSimpleSimulationGeneratorTask` with no additions. The test file accesses its internals via `#define private public`. This class should either be removed (tests use the generator task directly) or given a clear purpose. + +## 7. Missed Opportunities + +1. **The `StepState` contract could have been an interface.** Instead of a struct on `AtTpc`, `StepState` could live in a shared header and define the transport-neutral contract between any transport engine and any sensitive detector. This would make the pattern reusable beyond `AtTpc`. + +2. **`FindSensitiveEntry()` could use the geometry.** The brute-force 1mm linear scan up to 5m is slow and fragile. `TGeoManager::FindNextBoundary()` would find the volume crossing analytically. + +3. **A builder or factory for SimpleSim runs** would eliminate the FairRunSim boilerplate. A function like `AtSimpleSimRun::Create(geoFile, generators, elossModels)` that internally sets up FairRunSim with the right dummy objects would make the user-facing API dramatically simpler. + + **Update:** Energy loss model configuration has been addressed. `AtELossModelFactory` (with `AtELossFactoryCATIMA` and `AtELossFactoryBetheBloch` implementations) provides automatic model creation from geometry materials via `AtSimpleSimulation::SetModelFactory()`. See [energy-loss.md](../subsystems/energy-loss.md#model-factories). The FairRunSim boilerplate reduction (builder pattern) remains an open opportunity. + +4. **The detector-coupled and standalone modes should be separate paths, not a runtime branch in `Init()`.** The current `if (fDetector != nullptr)` split in `TransportParticle()` combines two fundamentally different output contracts in one method. + +## 8. Overall Judgment + +**This is a good integration design with one excellent core idea and several execution issues.** + +### Where it succeeds + +The `StepState` / `ProcessStep()` refactoring of `AtTpc` is the key insight. By extracting all VMC queries into a plain data struct and making the detector's step-processing logic transport-agnostic, this branch creates a clean, testable seam that any future transport engine can target. The `AtSimParticleCollector` adapter is similarly well-conceived -- it lets the full generator ecosystem work unchanged without any modifications to existing generators. + +The test coverage is notably good: the detector contract, the task internals, the MCTrack fill logic, and the physics (straight-line and Larmor radius) all have meaningful tests. + +### Where it falls short + +- The behavioral change to the Geant4 path (stopping on reaction volume exit) is the most consequential issue. It needs to be validated or guarded. +- The user experience is still FairRoot-heavy: users set up a Geant3 run to not use Geant3. A thin convenience layer would make a large difference. +- Unit conversion by magic number across 15+ call sites is a maintenance and correctness risk. +- The dual standalone/detector-coupled modes create an implicit contract that will confuse users. + +### Is this sustainable? + +Yes, with one more iteration. The `StepState` contract is the right foundation. The task hierarchy is extensible. The main work remaining is: (a) fix the Geant4 behavioral regression, (b) unify or document the sensitive volume logic, (c) centralize unit conversions, and (d) consider a convenience API that hides FairRunSim setup from physics users. + +The direction is coherent and this is not a workaround -- it is a genuine architectural step toward transport-engine independence. + +--- + +# SimpleSim Integration Correctness Review + +Scope: whether the integration correctly fulfills FairRoot contracts and produces output that downstream tasks can consume without modification. Does not revisit API design or architecture (covered above). + +## 1. Framework Contract Compliance + +### 1a. Beam/reaction event flag inversion in generator task (CRITICAL) + +In `AtSimpleSimulationGeneratorTask::LoadEvent()` (line 33): + +```cpp +fPrimGen->GenerateEvent(&fCollector); +state.beamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); +``` + +Inside `GenerateEvent`, `AtReactionGenerator::ReadEvent()` reads the flag, then calls `EndEvent()` which **toggles it** before returning. By the time `LoadEvent` reads the flag, it has been inverted. The result: + +- **Event 0 (beam)**: `state.beamEvent = false` (wrong) +- **Event 1 (reaction)**: `state.beamEvent = true` (wrong) + +This propagates through the full chain: + +``` +LoadEvent().beamEvent + -> TransportCurrentEvent(beamEvent) + -> TransportParticle(particle, beamEvent) + -> beamTrack = beamEvent && particle.trackID == 0 + -> StepState.beamTrack + -> AtTpc::ProcessStep -> fIsBeamTrack +``` + +`fIsBeamTrack` controls: +- `trackEnteringVolume`: whether `InPos` (beam entry position) is recorded +- `getTrackParametersWhileExiting`: whether `resetVertex()` is called on beam exit +- `reactionOccursHere()`: `isPrimaryBeam = fIsBeamTrack` -- gates the reaction trigger entirely + +With the flag inverted: +- **Event 0 (beam)**: `fIsBeamTrack=false` -> reaction never fires -> vertex never set +- **Event 1 (reaction)**: `fIsBeamTrack=true` for trackID=0 -> reaction fires for FairBoxGenerator's beam particle -> calls `ResetVertex()` then `SetVertex()` at the wrong position + +The vertex propagation chain breaks. `AtTPC2Body::GenerateReaction` on Event 1 reads the vertex from `AtVertexPropagator` -- which was never properly set by Event 0. Products start from wrong positions. + +**Fix**: Capture the flag before calling `GenerateEvent()`: + +```cpp +bool wasBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); +fPrimGen->GenerateEvent(&fCollector); +state.beamEvent = wasBeamEvent; +``` + +**Note**: The validation macros use `AtSimpleSimulationReplayTask`, which sets the flag explicitly and avoids this bug. The generator task is not exercised by any macro on this branch. + +### 1b. ProcessStep exit-stop changes Geant4 behavior + +`AtTpc::ProcessStep()` (line 235-236) adds: + +```cpp +if (step.exiting && IsReactionVolume(fVolName)) + return true; +``` + +This stops **all** particles on exit from drift_volume/cell, not just the beam. In the old code, only `startReactionEvent()` called `gMC->StopTrack()` (beam-only). Now in the Geant4 path, reaction products exiting the active gas are also stopped. While `AtClusterize` only processes drift_volume hits (so this is benign for standard digitization), it changes Geant4 behavior for any analysis reading raw `AtTpcPoint` data that expects window-region hits from exiting products. + +### 1c. MCTrack branch registration + +`AtSimpleSimulationTask::RegisterMCTrackBranch()` checks for an existing `MCTrack` branch and reuses it, or creates one. In the FairRunSim setup, `AtStack` also registers `MCTrack`. The task handles this correctly (line 94-98), reusing the existing branch if present. + +## 2. Integration Correctness + +### 2a. Unit conversions -- correct + +All conversion points were traced: + +| Direction | Quantity | Conversion | Location | +|-----------|----------|------------|----------| +| Collector -> SimpleSim | position | cm x 10 -> mm | `TransportParticle` L130 | +| Collector -> SimpleSim | momentum | GeV x 1000 -> MeV | `TransportParticle` L131 | +| SimpleSim -> StepState | energyLoss | MeV / 1000 -> GeV | `ProcessDetectorStep` L215 | +| SimpleSim -> StepState | trackLength | mm / 10 -> cm | `ProcessDetectorStep` L216 | +| SimpleSim -> StepState | position | mm / 10 -> cm | `ProcessDetectorStep` L223 | +| SimpleSim -> StepState | momentum | MeV / 1000 -> GeV | `ProcessDetectorStep` L224 | +| SimpleSim -> StepState | totalEnergy | MeV / 1000 -> GeV | `ProcessDetectorStep` L221 | + +All conversions are consistent and correct. The `StepState` unit annotations (`// GeV`, `// cm`, etc.) match the values produced. + +### 2b. Generator invocation + +`AtSimParticleCollector` inherits `FairGenericStack` and intercepts `PushTrack()`. This captures all the particles that generators would normally push onto the VMC stack, preserving their cm/GeV units. The 18-param and 19-param `PushTrack` overloads are both handled. + +`AtSimParticleCollector` does not support `PopNextTrack` / `PopPrimaryForTracking` (return nullptr) or `GetCurrentTrack` (returns nullptr). These are acceptable since no VMC transport runs, but could break generators that call `GetStack()->GetCurrentTrack()` during `ReadEvent`. + +### 2c. Edge cases + +**Particles starting outside the active volume**: `FindSensitiveEntry()` probes forward in 1 mm steps along the momentum direction, up to 5 m. This is reasonable but imprecise -- the entry point could miss by up to 1 mm, and energy loss in non-sensitive material between the actual start and the probe hit is not accounted for (SimpleSim applies the same dE/dx model everywhere). + +**Missing energy-loss model**: `TransportParticle` catches `std::invalid_argument` and logs at `debug` level. The particle is silently skipped. A user might not notice reaction products disappearing. + +**Particles that never stop**: The curved-track path has a `kMaxCurvedTransportSteps = 200000` guard. The straight-line path stops when exiting the geometry or KE < 1 keV. Both are adequate. + +**Zero-length tracks**: `SubmitInitialSensitivePoint` creates a hit with `energyLoss=0` and `trackLength=0`. `AtClusterize` skips zero-loss hits, so this contributes no electrons. It correctly sets up entering-volume state in `AtTpc` without producing a physics contribution. + +### 2d. `correctPosOut()` not applied in SimpleSim path + +In the Geant4 `ProcessHits`, exit positions are refined using `TGeoManager` boundary correction (`correctPosOut()`). In the SimpleSim path, exit positions come directly from the propagator's last step with no boundary refinement. This causes minor position imprecision at volume boundaries (up to one step size, typically ~1 mm). + +### 2e. Time field is always zero + +`ProcessDetectorStep` sets `detectorStep.timeNs = 0.` for all steps. `AtClusterize` does not use the time field, so this doesn't affect current downstream processing. Any analysis reading `AtMCPoint::GetTime()` would see zero. + +### 2f. Replay task doesn't set AtVertexPropagator track metadata + +In the Geant4 path, `AtTPC2Body` populates `SetTrackEnergy(trackID, ...)` and `SetTrackAngle(trackID, ...)`. The replay task reads raw MCTracks and pushes them directly to the collector without running any generator, so these are never set. `AtTpc::addHit()` reads them for `EIni` / `AIni` fields in `AtMCPoint` -- they'll be 0.0 for all points. `AtClusterize` doesn't use them, but any analysis reading these fields sees wrong values. + +## 3. Pipeline Trace + +Tracing a single primary proton through the SimpleSim **generator** path (illustrating the flag bug from 1a): + +1. **FairPrimaryGenerator::GenerateEvent** runs generators. `FairBoxGenerator` pushes the beam particle (trackID=0). `AtTPC2Body::GenerateReaction` reads vertex from `AtVertexPropagator`, computes kinematics, pushes products (trackID=1,2). `AtReactionGenerator::EndEvent` toggles the flag. + +2. **LoadEvent** reads the inverted flag. On Event 0 (beam): `beamEvent=false`. + +3. **TransportCurrentEvent(false)**: For the beam (trackID=0): `beamTrack = false && (0==0) = false`. + +4. **FindSensitiveEntry**: If the beam starts outside the drift volume (typical -- generated at z=-100 cm), probes forward to find the entry. + +5. **SubmitInitialSensitivePoint**: Creates an entering `StepState` with `beamTrack=false`. `AtTpc::ProcessStep` sets `fIsBeamTrack=false`, calls `trackEnteringVolume` (but `InPos` is NOT set because `fIsBeamTrack` is false). + +6. **TransportParticle callback**: For each RK4 step inside the drift volume, `ProcessDetectorStep` builds a `StepState` (units converted correctly) and calls `ProcessStep`. Energy loss accumulates in `fELossAcc`. + +7. **reactionOccursHere**: `isPrimaryBeam = fIsBeamTrack = false` -> **never fires**. The beam particle traverses the entire volume or stops without setting the vertex. + +8. **Exit**: When the proton exits the drift volume, `step.exiting && IsReactionVolume` -> `ProcessStep` returns true -> transport stops. + +9. **Vertex**: `AtVertexPropagator::SetVertex()` is never called. The vertex remains at default (0,0,0). + +10. **Event 1 (reaction)**: `AtTPC2Body::GenerateReaction` reads vertex -> (0,0,0) -> products start from wrong position. + +For the **replay** path, this trace does not apply -- the replay task sets the beam flag explicitly and reads particle kinematics from the Geant4 truth file, bypassing the vertex propagation chain entirely. + +## 4. Failure Mode Assessment + +### Flag inversion (generator task only) + +**What breaks**: The reaction vertex is never set on beam events and incorrectly triggered on reaction events. Products start from wrong positions. + +**Sensitivity**: Every downstream observable that depends on vertex position -- track angles, reaction kinematics, Q-value reconstruction. + +**Visibility**: **Silently wrong**. The simulation runs to completion and produces output with correct-looking structure but wrong physics. A comparison with Geant4 truth would reveal it immediately (vertex z mismatch), but a standalone run would not obviously fail. + +**Mitigation on this branch**: Both validation macros use the `ReplayTask` (which avoids the bug), so the current validation pipeline does not exercise this failure. + +### ProcessStep exit-stop in Geant4 path + +**What breaks**: Reaction products stopped at the drift volume boundary. Secondaries from stopped products are lost. + +**Sensitivity**: Low for standard digitization (only drift_volume hits used). Could matter for efficiency studies or background estimates. + +**Visibility**: Visible as fewer hits in boundary volumes (`window`) when comparing old vs. new Geant4 output. + +## 5. High-Risk Findings + +### 1. Beam/reaction event flag inversion in generator task + +- **What**: `LoadEvent` reads `IsBeamEvent()` after `EndEvent()` has toggled it inside `GenerateEvent()`, yielding the inverted value. +- **Why**: `AtReactionGenerator::ReadEvent()` calls `EndEvent()` before returning to `GenerateEvent()`, but `LoadEvent` reads the flag after `GenerateEvent()` returns. +- **Impact**: Vertex never set, reaction products at wrong positions, silently wrong physics. +- **Confidence**: Very high -- mechanically verified from the call sequence. +- **File**: `AtSimpleSimulationGeneratorTask.cxx:33` + +### 2. ProcessStep stops all particles exiting reaction volumes (Geant4 side-effect) + +- **What**: The `step.exiting && IsReactionVolume(fVolName)` return in `ProcessStep` applies to both SimpleSim and Geant4 paths. +- **Why**: `ProcessStep` is shared code, but this exit-stop was added for SimpleSim transport semantics. +- **Impact**: Non-beam Geant4 tracks stopped at drift volume boundary; minor data loss for boundary analyses. +- **Confidence**: High -- visible in the diff and `ProcessHits` calls `ProcessStep`. +- **File**: `AtTpc.cxx:235-236` + +### 3. Replay task doesn't populate AtVertexPropagator track energy/angle + +- **What**: `SetTrackEnergy` / `SetTrackAngle` are never called in the replay path. +- **Why**: No generator runs; particles are read from file. +- **Impact**: `EIni` / `AIni` fields in `AtMCPoint` are 0.0. Not used by digitization, but wrong for direct analysis which is not an issue for this use case. +- **Confidence**: High. +- **File**: `AtSimpleSimulationReplayTask.cxx:40-55`, `AtTpc.cxx:261-280` +- **Do not patch - not an issue** + +### 4. Sensitive volume identification is duplicated and divergent + +- **What**: `AtSimpleSimulationTask::IsSensitiveVolume()` checks `"drift_volume"`, `"window"`, `"cell"`. `AtTpc::IsReactionVolume()` checks only `"drift_volume"` and `"cell"`. `AtTpc::CheckIfSensitive()` checks all three. +- **Why**: Three independent implementations of the same concept. +- **Impact**: If detector geometry adds a new sensitive volume, all three must be updated independently. +- **Confidence**: Medium -- the current set of volumes is consistent, but the maintenance risk is real. + +## 6. Suggested Validation Tests + +### For Finding #1 (flag inversion) + +**Unit test**: Create an `AtSimpleSimulationGeneratorTask` with a generator that calls `EndEvent()`. Verify that `LoadEvent().beamEvent` matches the pre-toggle flag value: + +``` +Event 0: expect beamEvent == true (currently returns false) +Event 1: expect beamEvent == false (currently returns true) +``` + +**Integration test**: Run the generator task with `AtTPC2Body` and verify that `AtVertexPropagator::GetVz()` is non-zero after the beam event's transport completes. + +### For Finding #2 (exit-stop behavior) + +**A/B comparison**: Run the Geant4 path before and after this branch. Count `AtTpcPoint` entries with `GetVolName() == "window"` from non-beam tracks. The new code should produce fewer (or zero) such hits. + +### For Finding #3 (missing metadata) + +**Comparison test**: Compare `AtMCPoint::GetEIni()` and `GetAIni()` between Geant4 output and replay-task output for the same events. Geant4 should have non-zero values; replay should have all zeros. + +### For general output correctness + +**Bragg curve comparison**: For a fixed-angle single event, compare the dE/dx vs. range profile between Geant4 and SimpleSim outputs. This catches unit errors, energy loss model discrepancies, and step-size artifacts simultaneously. The existing `compareFixed.C` and `compareKinematic.C` macros appear designed for this. + +**Vertex position match**: For the generator task (once the flag bug is fixed), verify that the vertex Z from `AtVertexPropagator::GetVz()` matches between Geant4 and SimpleSim to within the step-size uncertainty (~1 mm). 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..ee7ad9974 100644 --- a/docs/subsystems/energy-loss.md +++ b/docs/subsystems/energy-loss.md @@ -10,6 +10,9 @@ ATTPCROOT provides several energy-loss utilities in `AtTools/`. The modern model | `AtELossCATIMA` | CATIMA-backed implementation | | `AtELossTable` | table-backed implementation, typically from SRIM-style data | | `AtELossBetheBloch` | standalone Bethe-Bloch helper for simpler analytic use cases | +| `AtELossModelFactory` | abstract factory for creating models from ROOT geometry materials | +| `AtELossFactoryCATIMA` | CATIMA-backed factory; stores a `catima::Config` applied to all created models | +| `AtELossFactoryBetheBloch` | Bethe-Bloch-backed factory; stateless | | `AtELossManager` | older lookup-table utility; do not treat it as the generic `AtELossModel` entry point | ## `AtELossModel` Interface @@ -52,6 +55,40 @@ CATIMA is fetched by CMake if it is not already available locally. It is the onl - `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. +## Model Factories + +`AtELossModelFactory` is an abstract interface for automatically creating energy loss models from ROOT geometry materials (`TGeoMaterial` / `TGeoMixture`). When used with `AtSimpleSimulation::SetModelFactory()`, models are created on demand as new particle species are encountered during transport — no manual `AddModel()` calls needed. + +Two concrete factories are provided: + +- **`AtELossFactoryCATIMA`** -- wraps CATIMA. Stores a `catima::Config` that is applied to every model it creates. This is the recommended factory for most use cases. +- **`AtELossFactoryBetheBloch`** -- uses the analytic Bethe-Bloch formula with effective Z/A for mixtures and Bloch approximation for mean ionization energy. Lighter weight but less accurate than CATIMA, especially for straggling. + +### Usage + +```cpp +auto sim = std::make_unique(); +auto factory = std::make_shared(); +// Optional: factory->SetConfig(myCatimaConfig); +sim->SetModelFactory(factory); +``` + +When a particle species (Z, A) is first encountered without a registered model, the factory extracts the material composition and density from the geometry at the particle's position and creates an appropriate `AtELossModel`. + +Manually registered models via `AddModel()` take precedence -- the factory is only consulted for species without an explicit model. + +### Factory vs manual registration + +Use the **factory** approach when you want simpler setup or are running exploratory simulations where you may not know all particle species in advance. Use **manual `AddModel()`** when you need specific nuclear masses, custom material compositions, or non-standard density values. + +### Utility methods + +`AtELossModelFactory` provides static utility methods usable by any code that works with ROOT geometry materials: + +- `ExtractComposition(material)` -- extracts `(A, Z, stoichiometry)` tuples from a `TGeoMaterial` or `TGeoMixture` +- `WeightFractionsToStoichiometry(weights, atomicMasses)` -- converts weight fractions to integer stoichiometry +- `EffectiveMeanIonization(material)` -- computes effective mean ionization energy via Bragg's additivity rule + ## Legacy Lookup-Table Path `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(...)`. diff --git a/docs/subsystems/simplesim-migration.md b/docs/subsystems/simplesim-migration.md index 6897970a4..b2dd0f498 100644 --- a/docs/subsystems/simplesim-migration.md +++ b/docs/subsystems/simplesim-migration.md @@ -53,11 +53,29 @@ run->AddTask(simTask); ### Energy-loss models -Every particle species that will be transported must have a registered energy-loss model. Call `sim->AddModel(Z, A, model)` for each species. If a particle has no model, the simulation terminates with a fatal error. +Every particle species that will be transported needs an energy-loss model. There are two approaches: -Available model types: -- `AtTools::AtELossCATIMA` -- CATIMA-based energy loss (recommended) -- `AtTools::AtELossTable` -- SRIM table lookup +#### Factory-based registration (recommended) + +Set a model factory and let SimpleSim auto-create models from the geometry materials: + +```cpp +auto sim = std::make_unique(); +sim->SetModelFactory(std::make_shared()); +``` + +This is the simplest approach -- no per-species configuration needed. Models are created on demand when new particle species are encountered during transport. + +#### Manual registration + +For full control, register models explicitly with `sim->AddModel(Z, A, model)` for each species. If a particle has no model and no factory is set, the simulation terminates with a fatal error. Manually registered models take precedence over the factory. + +#### Available model types + +- `AtTools::AtELossFactoryCATIMA` -- CATIMA factory, auto-creates from geometry (recommended) +- `AtTools::AtELossFactoryBetheBloch` -- Bethe-Bloch factory, lighter analytic alternative +- `AtTools::AtELossCATIMA` -- CATIMA model for manual registration +- `AtTools::AtELossTable` -- SRIM table lookup for manual registration See [energy-loss.md](energy-loss.md) for details. @@ -100,6 +118,8 @@ The source file must contain a `cbmsim` TTree with an `MCTrack` branch from a pr Working examples are in `macro/Simulation/AtSimValidation/`: -- `simpleSim_fixed.C` / `geant4_fixed.C` -- fixed-angle comparison -- `simpleSim_kinematic.C` / `geant4_kinematic.C` -- full kinematic sweep -- `compareFixed.C`, `compareKinematic.C` -- automated comparison plots +- `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` -- factory CATIMA drop-in variants +- `simpleSim_fixed_bethebloch.C` -- factory Bethe-Bloch 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 b1878c94c..56a17fc58 100644 --- a/docs/subsystems/simulation-pipeline.md +++ b/docs/subsystems/simulation-pipeline.md @@ -90,9 +90,9 @@ For the **Geant4/VMC** path, a simulation run needs: For the **SimpleSim** path, the run additionally needs: - an `AtSimpleSimulation` instance (uses the FairRunSim geometry automatically) -- an `AtELossModel` configured for each particle species to be transported +- energy loss models for each particle species, either registered manually via `AddModel()` or auto-created via `SetModelFactory()` - the detector set via `SetDetector(tpc)` on the SimpleSim task -Energy loss models must be registered for every (Z, A) pair that will be transported. If a particle has no model, the simulation will terminate with a fatal error. +Energy loss models must be available for every (Z, A) pair that will be transported. Models can be registered manually via `AddModel()`, or a factory can be set via `SetModelFactory()` to auto-create models from geometry materials on demand. If a particle has no model and no factory is set, the simulation will terminate with a fatal error. 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/simpleSim_fixed_bethebloch.C b/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C new file mode 100644 index 000000000..7e2b7e851 --- /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 sim = std::make_unique(); + sim->SetModelFactory(std::make_shared()); + + auto *simTask = new AtSimpleSimulationGeneratorTask(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..55122fa30 --- /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 sim = std::make_unique(); + sim->SetModelFactory(std::make_shared()); + + auto *simTask = new AtSimpleSimulationGeneratorTask(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_factory.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C new file mode 100644 index 000000000..6b48922ed --- /dev/null +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C @@ -0,0 +1,114 @@ +// 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 sim = std::make_unique(); + sim->SetModelFactory(std::make_shared()); + + auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); + simTask->SetPrimaryGenerator(BuildElasticGenerator(0.0, 180.0)); + 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"; +} From d99f90d3725c5eff2804dccdc61b8478737bc1fd Mon Sep 17 00:00:00 2001 From: Adam Anthony Date: Fri, 10 Apr 2026 15:24:17 -0400 Subject: [PATCH 16/18] Add energy loss model factory and unify SimpleSim transport API Introduce AtELossModelFactory (abstract), AtELossFactoryBetheBloch, and AtELossFactoryCATIMA to auto-create energy loss models from ROOT geometry materials at transport time. This eliminates the need to manually register a model for every particle species before simulation. Consolidate the duplicate SimulateParticle/TransportParticle code paths into a single PropagateParticle method, removing ~100 lines of duplicated transport logic. The standalone SimulateParticle API now delegates to PropagateParticle with a volume-boundary callback. Require detector coupling in AtSimpleSimulationTask (standalone hit writing removed from the task; direct callers still use SimulateParticle). Add automatic B-field extraction from FairRun for drop-in Geant4 replacement. Update integration review docs and CLAUDE.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/CLAUDE.md | 37 +- .gitignore | 1 + AtDigitization/AtSimpleSimulation.cxx | 208 ++++----- AtDigitization/AtSimpleSimulation.h | 38 +- AtDigitization/AtSimpleSimulationTask.cxx | 118 ++++- AtDigitization/AtSimpleSimulationTask.h | 15 + AtTools/AtELossFactoryBetheBloch.cxx | 79 ++++ AtTools/AtELossFactoryBetheBloch.h | 26 ++ AtTools/AtELossFactoryCATIMA.cxx | 36 ++ AtTools/AtELossFactoryCATIMA.h | 30 ++ AtTools/AtELossModelFactory.cxx | 123 +++++ AtTools/AtELossModelFactory.h | 64 +++ AtTools/AtELossModelFactoryTest.cxx | 177 ++++++++ AtTools/AtToolsLinkDef.h | 3 + AtTools/CMakeLists.txt | 5 + .../simplesim-integration-review.md | 422 +++++++++--------- .../simpleSim_kinematic_factory.C | 9 +- 17 files changed, 1003 insertions(+), 388 deletions(-) create mode 100644 AtTools/AtELossFactoryBetheBloch.cxx create mode 100644 AtTools/AtELossFactoryBetheBloch.h create mode 100644 AtTools/AtELossFactoryCATIMA.cxx create mode 100644 AtTools/AtELossFactoryCATIMA.h create mode 100644 AtTools/AtELossModelFactory.cxx create mode 100644 AtTools/AtELossModelFactory.h create mode 100644 AtTools/AtELossModelFactoryTest.cxx diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index f3a092f4b..65ab3554b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,29 +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. - -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 | -|-------|------| -| 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) | -| SimpleSim migration | [subsystems/simplesim-migration.md](../docs/subsystems/simplesim-migration.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 @@ -34,6 +14,8 @@ Quick topic links: 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 @@ -58,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 b9d05db67..96a8d423d 100755 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ data event.dat pulser-files.txt .codex +.cache/* # Executables *.exe diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index d526f0757..890274c94 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -2,6 +2,7 @@ #include "AtSimpleSimulation.h" #include "AtELossModel.h" +#include "AtELossModelFactory.h" #include "AtKinematics.h" #include "AtMCPoint.h" #include "AtPropagator.h" @@ -11,11 +12,15 @@ #include #include // for TClonesArray +#include #include +#include +#include #include #include #include #include // for TObject +#include #include // for sqrt #include // for invalid_argument @@ -159,12 +164,33 @@ AtSimpleSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const std::function func) { auto modelIt = fModels.find({A, Z}); + if (modelIt == fModels.end() && fModelFactory) { + TryAutoCreateModel(Z, A, iniPos); + 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)) + if (!IsInVolume(fStandaloneVolumeName, 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); + const auto &volName = fStandaloneVolumeName; + + return PropagateParticle( + modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, [this, &func, &volName](const TransportStep &step) { + // Record hits only when inside the configured standalone volume + if (IsInVolume(volName, step.postPosition)) + 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 (!IsInVolume(volName, step.postPosition)) + return false; + + return true; + }); } std::pair @@ -172,134 +198,27 @@ AtSimpleSimulation::TransportParticle(int Z, int A, const XYZPoint &iniPos, cons StepCallback callback) { auto modelIt = fModels.find({A, Z}); + if (modelIt == fModels.end() && fModelFactory) { + TryAutoCreateModel(Z, A, iniPos); + 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 (GetVolume(iniPos) == nullptr) throw std::invalid_argument("Position of particle is outside the loaded geometry"); - return TransportParticle(modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, callback); + return PropagateParticle(modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, callback); } std::pair -AtSimpleSimulation::SimulateParticle(const ParticleInfo &info, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func) +AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, + const PxPyPzEVector &iniMom, const StepCallback &callback) { - // This is a new track fTrackID++; // ----------------------------------------------------------------------- // Curved-track path: use AtPropagator when E/B fields are non-zero // ----------------------------------------------------------------------- - if (fEField.Mag2() != 0 || fBField.Mag2() != 0) { - auto wrapModel = std::make_unique(info.model); - AtTools::AtPropagator prop(info.charge, info.mass, std::move(wrapModel)); - prop.SetEField(fEField); - prop.SetBField(fBField); - prop.SetState(iniPos, iniMom.Vect()); - - AtTools::AtRK4AdaptiveStepper stepper; - stepper.fInitialStep = fMaxPropStep; - stepper.fMaxStep = fMaxPropStep; - const double minAcceptedStepMm = stepper.fMinStep * 1e3 * kMinStepGuardScale; - double length = 0; - int numSteps = 0; - int minStepSteps = 0; - - while (IsInVolume("drift_volume", prop.GetPosition())) { - if (++numSteps > kMaxCurvedTransportSteps) { - LOG(warning) << "Aborting curved SimpleSim track after " << numSteps - << " steps without leaving drift_volume"; - break; - } - - double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - if (KE <= fCurvedStopTol) - break; - - auto mom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); - if (isnan(prop.GetPosition().X()) || isnan(prop.GetMomentum().X())) { - LOG(error) << "Failed to simulate a point with nan!"; - return {{0, 0, 0}, {0, 0, 0, 0}}; - } - if (!func(prop.GetPosition(), mom4)) - break; - - double KE_before = KE; - prop.PropagateOneStep(stepper); - - auto &state = prop.GetState(); - if (state.status != AtTools::AtPropagator::StepStateStatus::kSuccess) - break; - - double KE_after = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - double eLoss = KE_before - KE_after; - if (eLoss < 0) - eLoss = 0; // magnetic field does no work - - double stepDist = (prop.GetPosition() - state.fLastPos).R(); // mm - if (stepDist <= minAcceptedStepMm || state.hUsed <= stepper.fMinStep * kMinStepGuardScale) { - if (++minStepSteps > kMaxMinStepCurvedSteps) { - LOG(warning) << "Aborting curved SimpleSim track after " << minStepSteps - << " minimum-size steps at position " << prop.GetPosition() << " with KE " - << KE_after << " MeV"; - break; - } - } else { - minStepSteps = 0; - } - length += stepDist; - - auto newMom4 = AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass); - AddHit(eLoss, prop.GetPosition(), newMom4, length); - } - - return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; - } - - // ----------------------------------------------------------------------- - // Straight-line fast path (zero field) - // ----------------------------------------------------------------------- - auto &model = info.model; - 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. - 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}; -} - -std::pair -AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - const StepCallback &callback) -{ - fTrackID++; - if (fEField.Mag2() != 0 || fBField.Mag2() != 0) { auto wrapModel = std::make_unique(info.model); AtTools::AtPropagator prop(info.charge, info.mass, std::move(wrapModel)); @@ -317,7 +236,7 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X while (GetVolume(prop.GetPosition()) != nullptr) { if (++numSteps > kMaxCurvedTransportSteps) { - LOG(warning) << "Aborting curved SimpleSim transport track after " << numSteps + LOG(warning) << "Aborting curved SimpleSim track after " << numSteps << " steps without leaving the geometry"; break; } @@ -331,7 +250,7 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X auto preVolumeName = GetVolumeName(posBefore); if (isnan(posBefore.X()) || isnan(prop.GetMomentum().X())) { - LOG(error) << "Failed to transport a point with nan!"; + LOG(error) << "Failed to propagate a point with nan!"; return {{0, 0, 0}, {0, 0, 0, 0}}; } @@ -346,12 +265,12 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X double KE_after = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); double eLoss = KE - KE_after; if (eLoss < 0) - eLoss = 0; + eLoss = 0; // magnetic field does no work - double stepDist = (posAfter - state.fLastPos).R(); + double stepDist = (posAfter - state.fLastPos).R(); // mm if (stepDist <= minAcceptedStepMm || state.hUsed <= stepper.fMinStep * kMinStepGuardScale) { if (++minStepSteps > kMaxMinStepCurvedSteps) { - LOG(warning) << "Aborting curved SimpleSim transport track after " << minStepSteps + LOG(warning) << "Aborting curved SimpleSim track after " << minStepSteps << " minimum-size steps at position " << posAfter << " with KE " << KE_after << " MeV for PDG " << pdg << " track " << fTrackID; break; @@ -382,6 +301,9 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; } + // ----------------------------------------------------------------------- + // Straight-line fast path (zero field) + // ----------------------------------------------------------------------- auto &model = info.model; auto pos = iniPos; auto mom = iniMom; @@ -389,7 +311,7 @@ AtSimpleSimulation::TransportParticle(const ParticleInfo &info, int pdg, const X while (GetVolume(pos) != nullptr && mom.E() - mom.M() > 1e-3) { if (isnan(pos.X()) || isnan(mom.X())) { - LOG(error) << "Failed to transport a point with nan!"; + LOG(error) << "Failed to propagate a point with nan!"; return {{0, 0, 0}, {0, 0, 0, 0}}; } @@ -444,7 +366,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(fStandaloneVolumeName.c_str()); if (fSCModel) { // In the simulation z = 0 is the window and z=1000 is the pad plane. @@ -460,6 +382,46 @@ void AtSimpleSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzE // mcPoint->Print(nullptr); } +void AtSimpleSimulation::TryAutoCreateModel(int Z, int A, const XYZPoint &pos) +{ + if (!fModelFactory) + return; + + TGeoVolume *volume = GetVolume(pos); + if (volume == nullptr) { + LOG(warning) << "TryAutoCreateModel: position " << pos << " is outside geometry; cannot determine material"; + return; + } + + TGeoMedium *medium = volume->GetMedium(); + if (medium == nullptr) { + LOG(warning) << "TryAutoCreateModel: volume " << volume->GetName() << " has no medium"; + return; + } + + TGeoMaterial *material = medium->GetMaterial(); + if (material == nullptr) { + LOG(warning) << "TryAutoCreateModel: medium " << medium->GetName() << " has no material"; + return; + } + + // Look up mass in amu from PDG database for precision; fall back to A + double massAmu = static_cast(A); + int pdg = GetPDGFromZA(Z, A); + TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdg); + if (particle != nullptr) + massAmu = particle->Mass() / 0.931494; // GeV/c² -> amu + + auto model = fModelFactory->CreateModel(Z, A, massAmu, material); + if (model) { + AddModel(Z, A, model, massAmu); + LOG(info) << "Auto-created energy loss model for Z=" << Z << " A=" << A << " in " << material->GetName(); + } else { + LOG(warning) << "Factory failed to create energy loss model for Z=" << Z << " A=" << A << " in " + << material->GetName(); + } +} + void AtSimpleSimulation::RegisterBranch(std::string branchName, bool perc) { auto ioMan = FairRootManager::Instance(); diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index 6e08bead8..292489d1f 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -20,6 +20,7 @@ #include // for pair namespace AtTools { class AtELossModel; +class AtELossModelFactory; } // namespace AtTools class TGeoVolume; class TGeoManager; @@ -56,6 +57,7 @@ class AtSimpleSimulation { using PxPyPzEVector = ROOT::Math::PxPyPzEVector; std::map fModels; + std::shared_ptr fModelFactory{nullptr}; SpaceChargeModel fSCModel{nullptr}; double fDistStep{1.}; // Distance step in mm for straight-line propagation std::mutex fGeoMutex; @@ -66,6 +68,7 @@ class AtSimpleSimulation { XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) double fCurvedStopTol{0.1}; ///< Curved-track stop tolerance in MeV; avoids pathological late stopping tails + std::string fStandaloneVolumeName{"drift_volume"}; ///< Volume name for standalone SimulateParticle hit recording // Variables to across an entire event static thread_local int fTrackID; @@ -88,6 +91,8 @@ class AtSimpleSimulation { using StepCallback = std::function; + // ---- Construction ---- + /** * Assumes that the IO manager has been initialized (it will attempt to construct the branch needed here). */ @@ -96,8 +101,15 @@ class AtSimpleSimulation { AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicitly deleted because of std::mutex ~AtSimpleSimulation() = default; + // ---- Standalone hit-writing API ---- + // Used by AtMCFission and direct callers. These methods manage a thread-local + // TClonesArray of AtMCPoints and write hits directly during transport. + void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); + // ---- Transport API ---- + // Shared by both standalone and detector-coupled paths. + /** * Register an energy loss model for a particle species. Charge is derived as Z*e and * mass as A * 931.494 MeV/c². Use the overload with massAmu for higher accuracy. @@ -109,6 +121,13 @@ class AtSimpleSimulation { */ void AddModel(int Z, int A, ModelPtr model, double massAmu); + /** + * Set a model factory for automatic energy loss model creation. + * When set, if a particle species (Z, A) is encountered without a registered model, + * the factory will be used to create one from the geometry material at the particle's position. + */ + void SetModelFactory(std::shared_ptr factory) { fModelFactory = std::move(factory); } + void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = model; } void SetDistanceStep(double step) { fDistStep = step; } ///< Step size in mm (straight-line path) @@ -117,6 +136,7 @@ class AtSimpleSimulation { /// Maximum step size (m) for the RK4 adaptive stepper in curved-track mode (default: 1e-3 m = 1 mm). void SetMaxPropagationStep(double stepM) { fMaxPropStep = stepM; } void SetCurvedStopTolerance(double stopTolMeV) { fCurvedStopTol = stopTolMeV; } + void SetStandaloneVolumeName(const std::string &name) { fStandaloneVolumeName = name; } void NewEvent(); @@ -129,6 +149,10 @@ class AtSimpleSimulation { int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); + // ---- Detector-coupled transport API ---- + // Used by AtSimpleSimulationTask for Geant4 drop-in replacement. Transport steps are + // delivered via callback; hit recording is handled by the detector (AtTpc). + /** * Transport a particle through the loaded geometry without writing detector hits. * This is intended for detector-coupled adapters that want transport state but keep hit @@ -149,17 +173,19 @@ class AtSimpleSimulation { std::string GetVolumeName(const XYZPoint &point); /** - * Core simulation loop. Selects straight-line or curved-track path based on field settings. + * Core transport loop. Propagates a particle through the geometry, invoking callback at each step. + * Continues while the particle is inside the geometry (GetVolume != nullptr) and KE > threshold. + * The callback controls early stopping by returning false. + * Selects curved-track (RK4) or straight-line path based on field settings. */ - std::pair SimulateParticle( - const ParticleInfo &info, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); - - std::pair TransportParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, + std::pair PropagateParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, const StepCallback &callback); void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); TGeoVolume *GetVolume(const XYZPoint &pos); + + /// Attempt to auto-create an energy loss model using fModelFactory and the geometry material at pos. + void TryAutoCreateModel(int Z, int A, const XYZPoint &pos); }; #endif // AT_SIMPLE_SIMULATION_H diff --git a/AtDigitization/AtSimpleSimulationTask.cxx b/AtDigitization/AtSimpleSimulationTask.cxx index 049be8212..d59d8a0ea 100644 --- a/AtDigitization/AtSimpleSimulationTask.cxx +++ b/AtDigitization/AtSimpleSimulationTask.cxx @@ -5,15 +5,21 @@ #include "AtSimpleSimulation.h" #include "AtTpc/AtTpc.h" +#include #include #include +#include #include #include +#include #include #include #include #include +#include +#include +#include #include #include @@ -54,12 +60,16 @@ AtSimpleSimulationTask::AtSimpleSimulationTask(std::unique_ptrRegisterBranch(); - } else { - LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; - fDetector->SetStopOnReactionVolumeExit(true); + LOG(fatal) << "AtSimpleSimulationTask requires a sensitive detector. " + << "Call SetDetector(tpc) before Init(). " + << "For standalone simulation, use AtSimpleSimulation::SimulateParticle() directly."; + return kFATAL; } + LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; + fDetector->SetStopOnReactionVolumeExit(true); + + if (fAutoConfigureField) + ConfigureFieldFromFairRun(); auto sourceStatus = InitEventSource(); if (sourceStatus != kSUCCESS) @@ -90,6 +100,94 @@ InitStatus AtSimpleSimulationTask::InitEventSource() { return kSUCCESS; } void AtSimpleSimulationTask::FinishEventSource() {} +void AtSimpleSimulationTask::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) << "AtSimpleSimulationTask: no FairRun instance; skipping field auto-config"; + return; + } + + auto *field = run->GetField(); + if (field == nullptr) { + LOG(info) << "AtSimpleSimulationTask: 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) << "AtSimpleSimulationTask: auto-configured B field from FairRun: (" << bx_T << ", " << by_T << ", " << bz_T + << ") T (sampled at drift volume center)"; + + // Warn if field is not constant (SimpleSim assumes uniform) + if (field->GetType() != 0) + LOG(warning) << "AtSimpleSimulationTask: SimpleSim assumes uniform fields, but the FairRun field type is " + << field->GetType() << " (non-constant). Using field value sampled at drift volume center."; + + // 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) << "AtSimpleSimulationTask: drift volume extends beyond the constant field region. " + << "Field at corner (" << corner[0] << ", " << corner[1] << ", " << corner[2] + << ") cm differs from center value."; + break; + } + } + } + } +} + void AtSimpleSimulationTask::RegisterMCTrackBranch() { auto *ioMan = FairRootManager::Instance(); @@ -139,17 +237,12 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic particle.e * kGeVToMeV); try { - if (fDetector != nullptr && !IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos))) + 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"; - if (fDetector == nullptr) { - fSimulation->SimulateParticle(Z, A, pos, mom); - return; - } - const bool beamTrack = beamEvent && particle.trackID == 0; if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && !SubmitInitialSensitivePoint(particle.trackID, particle.pdgCode, beamTrack, pos, mom)) @@ -176,9 +269,6 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic bool AtSimpleSimulationTask::SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const XYZPoint &pos, const PxPyPzEVector &mom) { - if (fDetector == nullptr) - return true; - AtTpc::StepState detectorStep; detectorStep.trackID = trackID; detectorStep.pdg = pdg; diff --git a/AtDigitization/AtSimpleSimulationTask.h b/AtDigitization/AtSimpleSimulationTask.h index 7df297f97..c054c0274 100644 --- a/AtDigitization/AtSimpleSimulationTask.h +++ b/AtDigitization/AtSimpleSimulationTask.h @@ -11,6 +11,10 @@ #include #include +namespace AtTools { +class AtELossModelFactory; +} // namespace AtTools + class AtTpc; class TBuffer; class TClass; @@ -31,21 +35,32 @@ class AtSimpleSimulationTask : public FairTask { void SetSensitiveDetector(AtTpc *detector) { fDetector = detector; } void SetDetector(AtTpc *detector) { SetSensitiveDetector(detector); } + /// Set a model factory for automatic energy loss model creation. See AtSimpleSimulation::SetModelFactory. + void SetModelFactory(std::shared_ptr factory) + { + fSimulation->SetModelFactory(std::move(factory)); + } + 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; } + AtSimpleSimulation *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(); diff --git a/AtTools/AtELossFactoryBetheBloch.cxx b/AtTools/AtELossFactoryBetheBloch.cxx new file mode 100644 index 000000000..f95168f5f --- /dev/null +++ b/AtTools/AtELossFactoryBetheBloch.cxx @@ -0,0 +1,79 @@ +#include "AtELossFactoryBetheBloch.h" + +#include "AtELossBetheBloch.h" + +#include + +#include + +#include + +namespace AtTools { + +namespace { +constexpr double kAmuToMeV = 931.494; // MeV/c² per amu +} // namespace + +std::shared_ptr +AtELossFactoryBetheBloch::CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) +{ + if (material == nullptr) { + LOG(error) << "AtELossFactoryBetheBloch::CreateModel: null material"; + return nullptr; + } + + double density = material->GetDensity(); // g/cm³ + double projMass = projMassAmu * kAmuToMeV; + + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + // Pure material + int matZ = static_cast(std::round(material->GetZ())); + int matA = static_cast(std::round(material->GetA())); + double I_eV = 13.5 * matZ; // Bloch approximation + + auto model = std::make_shared(projZ, projMass, matZ, matA, density, I_eV); + LOG(info) << "AtELossFactoryBetheBloch: created model for Z=" << projZ << " A=" << projA << " in " + << material->GetName() << " (pure Z=" << matZ << ", density=" << density << " g/cm³)"; + return model; + } + + // Mixture: compute effective Z and A via electron-density weighting + // = sum(w_i * Z_i / A_i) + // = sum(w_i * Z_i / A_i) / sum(w_i / A_i) + // = sum(w_i) / sum(w_i / A_i) = 1 / sum(w_i / A_i) [since sum(w_i)=1] + 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) << "AtELossFactoryBetheBloch::CreateModel: invalid mixture composition"; + return nullptr; + } + + int effZ = static_cast(std::round(sumWZoverA / sumWoverA)); + int effA = static_cast(std::round(1.0 / sumWoverA)); + double I_eV = EffectiveMeanIonization(material); + + // Ensure effective values are at least 1 + effZ = std::max(effZ, 1); + effA = std::max(effA, 1); + + auto model = std::make_shared(projZ, projMass, effZ, effA, density, I_eV); + LOG(info) << "AtELossFactoryBetheBloch: created model for Z=" << projZ << " A=" << projA << " 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/AtELossFactoryBetheBloch.h b/AtTools/AtELossFactoryBetheBloch.h new file mode 100644 index 000000000..75ab4bee0 --- /dev/null +++ b/AtTools/AtELossFactoryBetheBloch.h @@ -0,0 +1,26 @@ +#ifndef ATELOSSFACTORYBETHEBLOCH_H +#define ATELOSSFACTORYBETHEBLOCH_H + +#include "AtELossModelFactory.h" + +namespace AtTools { + +/** + * Factory that creates AtELossBetheBloch models from ROOT geometry materials. + * + * For pure materials, uses Z/A/density directly. + * For mixtures, computes effective Z/A via electron-density weighting and + * effective mean ionization energy via Bragg's additivity rule. + * + * This factory is essentially stateless -- all information comes from the + * projectile and material arguments to CreateModel(). + */ +class AtELossFactoryBetheBloch : public AtELossModelFactory { +public: + std::shared_ptr + CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) override; +}; + +} // namespace AtTools + +#endif // ATELOSSFACTORYBETHEBLOCH_H diff --git a/AtTools/AtELossFactoryCATIMA.cxx b/AtTools/AtELossFactoryCATIMA.cxx new file mode 100644 index 000000000..e4a78c487 --- /dev/null +++ b/AtTools/AtELossFactoryCATIMA.cxx @@ -0,0 +1,36 @@ +#include "AtELossFactoryCATIMA.h" + +#include "AtELossCATIMA.h" + +#include + +#include + +namespace AtTools { + +std::shared_ptr +AtELossFactoryCATIMA::CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) +{ + if (material == nullptr) { + LOG(error) << "AtELossFactoryCATIMA::CreateModel: null material"; + return nullptr; + } + + double density = material->GetDensity(); // g/cm³ + auto composition = ExtractComposition(material); + + if (composition.empty()) { + LOG(error) << "AtELossFactoryCATIMA::CreateModel: could not extract composition from " << material->GetName(); + return nullptr; + } + + auto model = std::make_shared(density, composition); + model->SetProjectile(projA, projZ, projMassAmu); + model->SetConfig(fConfig); + + LOG(info) << "AtELossFactoryCATIMA: created model for Z=" << projZ << " A=" << projA << " in " + << material->GetName() << " (density=" << density << " g/cm³, " << composition.size() << " elements)"; + return model; +} + +} // namespace AtTools diff --git a/AtTools/AtELossFactoryCATIMA.h b/AtTools/AtELossFactoryCATIMA.h new file mode 100644 index 000000000..65fcef83e --- /dev/null +++ b/AtTools/AtELossFactoryCATIMA.h @@ -0,0 +1,30 @@ +#ifndef ATELOSSFACTORYCATIMA_H +#define ATELOSSFACTORYCATIMA_H + +#include "AtELossModelFactory.h" + +#include + +namespace AtTools { + +/** + * Factory that creates AtELossCATIMA models from ROOT geometry materials. + * + * Stores a catima::Config that is applied to every model it creates. This allows + * the user to configure CATIMA options (z_effective model, calculation method, etc.) + * once, and have them consistently applied to all auto-created models. + */ +class AtELossFactoryCATIMA : public AtELossModelFactory { + catima::Config fConfig{catima::default_config}; + +public: + void SetConfig(catima::Config cfg) { fConfig = cfg; } + catima::Config GetConfig() const { return fConfig; } + + std::shared_ptr + CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) override; +}; + +} // namespace AtTools + +#endif // ATELOSSFACTORYCATIMA_H diff --git a/AtTools/AtELossModelFactory.cxx b/AtTools/AtELossModelFactory.cxx new file mode 100644 index 000000000..ed591f979 --- /dev/null +++ b/AtTools/AtELossModelFactory.cxx @@ -0,0 +1,123 @@ +#include "AtELossModelFactory.h" + +#include + +#include + +#include +#include +#include + +namespace AtTools { + +std::vector> AtELossModelFactory::ExtractComposition(const TGeoMaterial *material) +{ + if (material == nullptr) { + LOG(error) << "AtELossModelFactory::ExtractComposition: null material"; + return {}; + } + + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + // Pure material: single element + int Z = static_cast(std::round(material->GetZ())); + int A = static_cast(std::round(material->GetA())); + return {{A, Z, 1}}; + } + + int nElem = mixture->GetNelements(); + if (nElem <= 0) + return {}; + + 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]; + } + + auto stoich = WeightFractionsToStoichiometry(weights, masses); + + 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 result; +} + +std::vector +AtELossModelFactory::WeightFractionsToStoichiometry(const std::vector &weights, + const std::vector &atomicMasses) +{ + if (weights.size() != atomicMasses.size() || weights.empty()) + return {}; + + // Compute molar ratios: n_i = w_i / A_i + std::vector molar(weights.size()); + for (size_t i = 0; i < weights.size(); ++i) { + if (atomicMasses[i] <= 0) { + LOG(error) << "AtELossModelFactory::WeightFractionsToStoichiometry: non-positive atomic mass"; + return {}; + } + molar[i] = weights[i] / atomicMasses[i]; + } + + // Normalize by smallest non-zero molar ratio + 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; + }); + + if (minMolar <= 0) + return std::vector(weights.size(), 1); + + std::vector 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 AtELossModelFactory::EffectiveMeanIonization(const TGeoMaterial *material) +{ + if (material == nullptr) + return 0; + + const auto *mixture = dynamic_cast(material); + if (mixture == nullptr) { + // Pure material: Bloch approximation + int Z = static_cast(std::round(material->GetZ())); + return 13.5 * Z; // eV + } + + // Bragg additivity: ln(I_eff) = sum(f_i * Z_i/A_i * ln(I_i)) / sum(f_i * Z_i/A_i) + int nElem = mixture->GetNelements(); + double numerator = 0; + double denominator = 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 || Z <= 0) + continue; + + double I_i = 13.5 * Z; // Bloch approximation per element, eV + double frac = w * Z / A; + numerator += frac * std::log(I_i); + denominator += frac; + } + + if (denominator <= 0) + return 13.5; // fallback + + return std::exp(numerator / denominator); +} + +} // namespace AtTools diff --git a/AtTools/AtELossModelFactory.h b/AtTools/AtELossModelFactory.h new file mode 100644 index 000000000..14c94132c --- /dev/null +++ b/AtTools/AtELossModelFactory.h @@ -0,0 +1,64 @@ +#ifndef ATELOSSMODELFACTORY_H +#define ATELOSSMODELFACTORY_H + +#include "AtELossModel.h" + +#include +#include +#include + +class TGeoMaterial; + +namespace AtTools { + +/** + * Abstract factory for creating energy loss models from ROOT geometry materials. + * + * Subclasses hold model-type-specific configuration (e.g. catima::Config for CATIMA) + * and produce configured AtELossModel instances on demand for any (projectile, material) + * combination. This allows AtSimpleSimulation to auto-create models at transport time + * for particle species that were not explicitly registered. + */ +class AtELossModelFactory { +public: + virtual ~AtELossModelFactory() = default; + + /** + * Create an energy loss model for the given projectile in the given target material. + * @param projZ Projectile atomic number + * @param projA Projectile mass number + * @param projMassAmu Projectile mass in atomic mass units + * @param material Target material from ROOT geometry (TGeoMaterial or TGeoMixture) + * @return Configured model ready for use, or nullptr on failure + */ + virtual std::shared_ptr + CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) = 0; + + // ---- Shared utility functions for extracting material info from TGeo ---- + + /** + * Extract elemental composition from a TGeoMaterial as (A, Z, stoichiometry) tuples. + * For TGeoMixture: weight fractions are converted to integer stoichiometry. + * For pure TGeoMaterial: returns a single element with stoichiometry 1. + */ + static std::vector> ExtractComposition(const TGeoMaterial *material); + + /** + * Convert weight fractions and atomic masses to approximate integer stoichiometry. + * Algorithm: n_i = w_i / A_i (molar ratio), normalize by smallest, round to nearest int. + */ + static std::vector + WeightFractionsToStoichiometry(const std::vector &weights, const std::vector &atomicMasses); + + /** + * Compute effective mean ionization energy (eV) for a material using Bragg's additivity rule: + * ln(I_eff) = sum(f_i * Z_i/A_i * ln(I_i)) / sum(f_i * Z_i/A_i) + * where f_i are weight fractions and I_i = 13.5*Z_i eV (Bloch approximation). + * For a pure material, returns 13.5*Z eV directly. + */ + static double EffectiveMeanIonization(const TGeoMaterial *material); +}; + +} // namespace AtTools + +#endif // ATELOSSMODELFACTORY_H diff --git a/AtTools/AtELossModelFactoryTest.cxx b/AtTools/AtELossModelFactoryTest.cxx new file mode 100644 index 000000000..5b6a9575e --- /dev/null +++ b/AtTools/AtELossModelFactoryTest.cxx @@ -0,0 +1,177 @@ +#include "AtELossModelFactory.h" + +#include "AtELossFactoryBetheBloch.h" +#include "AtELossModel.h" + +#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(AtELossModelFactoryUtils, WaterStoichiometry) +{ + // H2O: H weight fraction ~0.1119, O ~0.8881 + std::vector weights = {0.111898, 0.888102}; + std::vector masses = {1.008, 15.999}; + auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 2u); + EXPECT_EQ(stoich[0], 2); // H + EXPECT_EQ(stoich[1], 1); // O +} + +TEST(AtELossModelFactoryUtils, CO2Stoichiometry) +{ + // CO2: C ~0.2729, O ~0.7271 + std::vector weights = {0.272916, 0.727084}; + std::vector masses = {12.011, 15.999}; + auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 2u); + EXPECT_EQ(stoich[0], 1); // C + EXPECT_EQ(stoich[1], 2); // O +} + +TEST(AtELossModelFactoryUtils, PureElementStoichiometry) +{ + std::vector weights = {1.0}; + std::vector masses = {4.003}; + auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); + + ASSERT_EQ(stoich.size(), 1u); + EXPECT_EQ(stoich[0], 1); +} + +TEST(AtELossModelFactoryUtils, EmptyInput) +{ + auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry({}, {}); + EXPECT_TRUE(stoich.empty()); +} + +// ---- ExtractComposition tests (heap-allocated materials for TGeoManager ownership) ---- + +TEST_F(GeoFixture, ExtractPureMaterial) +{ + auto *mat = new TGeoMaterial("He_extract", 4.003, 2, 1.664e-4); + auto comp = AtELossModelFactory::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) +{ + // AddElement signature: AddElement(A, Z, weight) + auto *mix = new TGeoMixture("HeCO2_extract", 3, 1.0e-3); + mix->AddElement(4, 2, 0.90); // He + mix->AddElement(12, 6, 0.03); // C + mix->AddElement(16, 8, 0.07); // O + + auto comp = AtELossModelFactory::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(AtELossModelFactoryUtils, ExtractNullMaterial) +{ + auto comp = AtELossModelFactory::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 = AtELossModelFactory::EffectiveMeanIonization(mat); + EXPECT_DOUBLE_EQ(I, 13.5); // 13.5 * Z=1 +} + +TEST_F(GeoFixture, PureHeliumIonization) +{ + auto *mat = new TGeoMaterial("He_ionize", 4.003, 2, 1.664e-4); + double I = AtELossModelFactory::EffectiveMeanIonization(mat); + EXPECT_DOUBLE_EQ(I, 27.0); // 13.5 * Z=2 +} + +TEST_F(GeoFixture, MixtureIonization) +{ + // Pure hydrogen mixture: should give I = 13.5 eV + auto *mix = new TGeoMixture("H2_ionize", 1, 8.376e-5); + mix->AddElement(1, 1, 1.0); + double I = AtELossModelFactory::EffectiveMeanIonization(mix); + EXPECT_NEAR(I, 13.5, 0.5); +} + +TEST(AtELossModelFactoryUtils, NullIonization) +{ + double I = AtELossModelFactory::EffectiveMeanIonization(nullptr); + EXPECT_DOUBLE_EQ(I, 0.0); +} + +// ---- BetheBloch factory CreateModel test ---- + +TEST_F(GeoFixture, CreateFromPureMaterial) +{ + auto *mat = new TGeoMaterial("H_bbfactory", 1.008, 1, 6.5643e-5); + AtELossFactoryBetheBloch factory; + + auto model = factory.CreateModel(1, 1, 1.007825, mat); + ASSERT_NE(model, nullptr); + + double dedx = model->GetdEdx(10.0); + EXPECT_GT(dedx, 0.0); + + double range = model->GetRange(10.0); + EXPECT_GT(range, 0.0); + EXPECT_LT(range, 1e8); +} + +TEST_F(GeoFixture, CreateFromMixture) +{ + auto *mix = new TGeoMixture("HeCO2_bbfactory", 3, 1.0e-3); + mix->AddElement(4, 2, 0.90); + mix->AddElement(12, 6, 0.03); + mix->AddElement(16, 8, 0.07); + + AtELossFactoryBetheBloch factory; + auto model = factory.CreateModel(2, 4, 4.002603, mix); + ASSERT_NE(model, nullptr); + + double dedx = model->GetdEdx(10.0); + EXPECT_GT(dedx, 0.0); +} + +TEST_F(GeoFixture, NullMaterial) +{ + AtELossFactoryBetheBloch factory; + auto model = factory.CreateModel(1, 1, 1.007825, nullptr); + EXPECT_EQ(model, nullptr); +} diff --git a/AtTools/AtToolsLinkDef.h b/AtTools/AtToolsLinkDef.h index 55d4a74a2..2bce4e70f 100644 --- a/AtTools/AtToolsLinkDef.h +++ b/AtTools/AtToolsLinkDef.h @@ -21,6 +21,9 @@ #pragma link C++ class AtTools::AtELossTable - !; #pragma link C++ class AtTools::AtELossCATIMA - !; #pragma link C++ class AtTools::AtELossBetheBloch - !; +#pragma link C++ class AtTools::AtELossModelFactory - !; +#pragma link C++ class AtTools::AtELossFactoryBetheBloch - !; +#pragma link C++ class AtTools::AtELossFactoryCATIMA - !; #pragma link C++ class AtSpaceChargeModel - !; #pragma link C++ class AtLineChargeModel - !; diff --git a/AtTools/CMakeLists.txt b/AtTools/CMakeLists.txt index 7324f2e1d..2e88b4ffb 100644 --- a/AtTools/CMakeLists.txt +++ b/AtTools/CMakeLists.txt @@ -41,11 +41,14 @@ set(SRCS AtELossBetheBloch.cxx AtPropagator.cxx + AtELossModelFactory.cxx + AtELossFactoryBetheBloch.cxx ) Set(DEPENDENCIES ROOT::XMLParser ROOT::Core + ROOT::Geom FairRoot::Base FairRoot::FairTools @@ -65,6 +68,7 @@ endif() if(CATIMA_FOUND) set(SRCS ${SRCS} AtELossCATIMA.cxx + AtELossFactoryCATIMA.cxx ) set(DEPENDENCIES ${DEPENDENCIES} CATIMA::catima @@ -82,6 +86,7 @@ set(TEST_SRCS AtELossTableTest.cxx AtELossBetheBlochTest.cxx AtPropagatorTest.cxx + AtELossModelFactoryTest.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 index d514cd084..1560be0cb 100644 --- a/docs/development/simplesim-integration-review.md +++ b/docs/development/simplesim-integration-review.md @@ -6,157 +6,128 @@ Scope: integration layer design only. The physics of `AtSimpleSimulation` itself ## 1. Architectural Overview -The integration layer runs `AtSimpleSimulation` (an RK4/straight-line particle propagator with energy loss) inside FairRoot's event loop, so that its output feeds the same downstream digitization chain (`AtClusterizeTask` -> `AtPulseTask`) as Geant4 transport. +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). -### Abstractions introduced +Three key abstractions bridge the gap: -- **`AtTpc::StepState` + `ProcessStep()`** -- The central architectural move. `AtTpc::ProcessHits()` was refactored to extract all VMC/`gMC` queries into a plain data struct (`StepState`), then delegate to a new `ProcessStep(const StepState&)` method. This decouples the detector's hit-recording and reaction-trigger logic from the VMC transport engine. SimpleSim can call `ProcessStep()` directly without a running VMC. +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. -- **`AtSimParticleCollector`** -- A minimal `FairGenericStack` stub that captures `PushTrack()` calls. This lets existing `FairPrimaryGenerator` + `AtReactionGenerator` chains run unchanged: the generators push particles to what they think is the VMC stack, but the particles land in a simple vector instead. +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. -- **`AtSimpleSimulationTask` (abstract base)** -- Template Method pattern: `Init()` -> `InitEventSource()`, `Exec()` -> `LoadEvent()` -> `TransportCurrentEvent()`, `Finish()` -> `FinishEventSource()`. Owns the simulation, the particle collector, the MCTrack branch, and the detector-stepping logic. +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. -- **`AtSimpleSimulationGeneratorTask`** -- Generates events live via a `FairPrimaryGenerator`. +The design solves: letting physics users swap Geant4 for a fast, controllable transport without changing their generators, geometry, or downstream analysis. -- **`AtSimpleSimulationReplayTask`** -- Reads primary MCTracks from a prior Geant4 run and re-transports them through SimpleSim, enabling direct A/B comparison. - -### What it leaves to the user - -FairRunSim boilerplate (cave, geometry, materials, dummy generator, parameter I/O), energy loss model configuration per particle species, and magnetic/electric field setup. +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 -The user cannot configure and run this without reading source code or the validation macros. There is no documentation of `AtSimpleSimulationGeneratorTask` or `AtSimpleSimulationReplayTask` in the docs directory -- the simulation pipeline doc (`subsystems/simulation-pipeline.md`) still describes SimpleSim as a standalone path, not as a FairTask. The macros in `macro/Simulation/AtSimValidation/` are the de facto documentation. - -A user encountering this for the first time must discover: -- That they need `FairRunSim` with a dummy `FairPrimaryGenerator` -- That `SetDetector(tpc)` is required for detector-coupled output -- That the replay task needs a Geant4 truth file with MCTrack branch on a "cbmsim" tree -- That they must configure energy loss models for every particle species they want transported +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. -None of this is documented outside the macros. +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 -- **Forgetting `SetDetector()`:** The task falls through to legacy `SimulateParticle()` mode, which hardcodes "drift_volume" containment and writes its own `AtTpcPoint` branch directly. The user gets output that looks correct but bypasses the detector contract entirely. This should probably be an error or at minimum a warning -- the two paths produce structurally different output. +Mostly narrow, one silent footgun. -- **Missing energy loss model:** Throws `std::invalid_argument`, which is caught and logged at `debug` level in `TransportParticle()`. The particle is silently skipped. A user might not notice reaction products disappearing. - -- **Geometry mismatch:** The simulation loads its own geometry file (e.g. `ATTPC_He1bar_geomanager.root`) while `FairRunSim` loads geometry for the detector module. If these don't match, volume lookups in SimpleSim and hit positions in `AtTpc` will silently disagree. - -- **Replay mode with wrong event count:** If the user requests more events than exist in the source file, `LoadPrimaryTracksFromSource()` returns `false`, `LoadEvent()` returns `hasEvent=false`, and `Exec()` silently returns. FairRoot happily runs empty events. +- 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 -Mostly consistent. The pattern of `FairTask` subclass + `Init()`/`Exec()` is standard. Using `FairPrimaryGenerator` with a custom stack is clever and preserves the generator API. The `SetDetector()`/`SetSensitiveDetector()` naming follows FairRoot conventions. - -However, requiring `FairRunSim` with `run->SetName("TGeant3")` and a dummy generator to use a non-Geant transport is dissonant. The user is setting up Geant3 to not use Geant3. +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 -- No way to set the B-field from a `FairField` object -- the user must manually translate `AtConstField` parameters to `sim->SetMagneticField(XYZVector(...))`. In the Geant4 path, `run->SetField()` handles this. -- No integration with `FairRunSim::SetStoreTraj()`. -- No event header population (event number, vertex, etc.). -- The generator task has no way to set a seed or control reproducibility independently of `gRandom`. +Two gaps: -## 3. Separation of Concerns - -**Class responsibilities are well-defined.** The base task handles transport mechanics and detector stepping. Subclasses handle only event sourcing. `AtSimParticleCollector` handles only particle capture. This is clean. - -**The generator/replay split is well-motivated.** The replay task enables controlled validation against Geant4 with identical kinematics. This is a legitimate use case that would be awkward to express as a mode flag on a single class. +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(...)`. -**FairRoot logic is mostly separated from physics logic.** The physics lives in `AtSimpleSimulation`; the FairRoot adaptation lives in the task hierarchy. The boundary is at `TransportParticle()` with its `StepCallback`. +## 3. Separation of Concerns -### Entanglement issues +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. -1. **Unit conversion is scattered across the boundary.** `AtSimpleSimulationTask::TransportParticle()` converts cm->mm and GeV->MeV with raw `* 10.` and `* 1000.` factors. `ProcessDetectorStep()` converts back with `/ 10.` and `/ 1000.`. `SubmitInitialSensitivePoint()` does the same. These magic numbers appear in ~15 places across the file. A single wrong factor produces wrong physics with no error. +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. -2. **Sensitive volume identification is duplicated.** `AtSimpleSimulationTask::IsSensitiveVolume()` is a static method with hardcoded volume name checks (`"drift_volume"`, `"window"`, `"cell"`) that duplicates `AtTpc::CheckIfSensitive()`. If the detector geometry adds a new sensitive volume, both must be updated independently. +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. -3. **`AtTpc::ProcessStep()` now embeds transport-termination logic** (stop on exiting reaction volume) that was not in the original Geant4 path. This is a behavioral change visible to all callers of `ProcessStep()`, including `ProcessHits()`, meaning the Geant4 path's behavior was changed too. +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 `StepState` refactoring of `AtTpc` is the strongest part of this branch.** It correctly preserves all the fields that `ProcessHits()` used to extract from `gMC`, and the Geant4 path still works through `ProcessHits()` -> populate `StepState` -> `ProcessStep()`. The test coverage of the detector contract (`AtTpcTest.cxx`) is solid. - -**Composition with upstream:** The generator task correctly reuses `FairPrimaryGenerator` with `AtSimParticleCollector` as the stack. The beam/reaction alternation via `AtVertexPropagator` is preserved. +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. -**Composition with downstream:** The task writes `AtTpcPoint` and `MCTrack` branches in the same format as the Geant4 path, so `AtClusterizeTask` and `AtPulseTask` should work unchanged. +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 -- **`correctPosOut()` is only applied in the Geant4 path.** When `ProcessHits()` calls `ProcessStep()`, exit positions have been corrected. When SimpleSim calls `ProcessStep()` directly, they haven't. This means the two paths produce slightly different hit positions near volume boundaries. - -- **The replay task hardcodes `fSourceEventIndex % 2 == 0` for beam event detection** (`AtSimpleSimulationReplayTask.cxx:45`). This assumes the source file always uses strict even/odd alternation. If the source was generated with a non-alternating generator, this will misidentify beam events. - -- **`RegisterMCTrackBranch()` reuses an existing "MCTrack" branch if one exists** (`AtSimpleSimulationTask.cxx:94-98`). In the replay scenario, the source file has an MCTrack branch, and FairRoot may expose it through `FairRootManager`. The task would then write into someone else's array. - -- **LinkDef streamer suffixes are wrong.** The task classes use `+;` (full streamer, for disk-persisted objects) but tasks should use `-!;` per project conventions. +- `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 -### Tradeoffs made - -1. **Reuse FairRunSim infrastructure vs. standalone run.** The design chose to embed SimpleSim inside `FairRunSim` as a `FairTask`. This provides access to geometry, I/O, and the event loop -- but forces users to set up a dummy VMC engine they're explicitly trying to avoid. A standalone `FairRunAna`-based path would have been cleaner for the user but would have required reimplementing geometry loading. +### Key tradeoffs made -2. **Detector coupling vs. standalone MCPoint writing.** The task supports both: with `SetDetector()`, it feeds steps through `AtTpc::ProcessStep()`; without, it uses the legacy `SimulateParticle()` path that writes MCPoints directly. This provides flexibility but creates two output formats with subtly different semantics (the detector path accumulates energy loss per track, applies reaction triggers, etc.). +| 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 | -3. **FairPrimaryGenerator reuse via fake stack.** This is the right call. It preserves the full generator ecosystem without modification. The tradeoff is that `AtSimParticleCollector` must implement a large interface of stubs, but the stubs are trivial. +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. -### Assessment - -For validation purposes (comparing SimpleSim to Geant4), these tradeoffs are reasonable. For production use by physics users, the FairRunSim boilerplate burden is too high -- but production use isn't the stated goal yet. - -This is moving toward a genuine pipeline replacement pattern, not a workaround. The `StepState` abstraction is the right seam. The design would need one more iteration -- extracting the sensitive-volume contract and making the FairRunSim dependency optional -- to be a clean, general-purpose alternative transport. +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 severity: +In order of importance: -1. **Behavioral change to the Geant4 path.** `AtTpc::ProcessStep()` adds `if (step.exiting && IsReactionVolume(fVolName)) return true;` -- this stops the track when exiting the reaction volume. Previously in `ProcessHits()`, the track was not stopped on exit; only `resetVertex()` was called. Now `ProcessHits()` calls `gMC->StopTrack()` whenever `ProcessStep()` returns true. This changes Geant4 simulation behavior and needs careful validation or should be behind a flag. +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. **Silent fallback to legacy mode.** When `fDetector` is null, the task silently switches to `SimulateParticle()` which writes MCPoints directly with different semantics (no reaction trigger, no beam/reaction alternation, hardcoded "drift_volume" containment). This is a footgun for users who forget `SetDetector()`. +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. **Unit conversion by magic number.** The mm<->cm and MeV<->GeV conversions are scattered across 15+ call sites as raw `* 10.`, `/ 10.`, `* 1000.`, `/ 1000.`. A single wrong factor is a silent physics error. These should be named constants or conversion functions. +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. **Duplicated sensitive volume logic.** `IsSensitiveVolume()` in the task and `CheckIfSensitive()` / `IsReactionVolume()` in the detector are independent implementations of the same concept. They also differ: `IsReactionVolume` checks `drift_volume` and `cell`; `IsSensitiveVolume` also checks `window`. This means the task considers windows sensitive but the detector's reaction logic doesn't -- by design or by accident? +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. **`AtTestSimulation` is now vestigial.** After the refactoring, it's an empty class that inherits `AtSimpleSimulationGeneratorTask` with no additions. The test file accesses its internals via `#define private public`. This class should either be removed (tests use the generator task directly) or given a clear purpose. +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 `StepState` contract could have been an interface.** Instead of a struct on `AtTpc`, `StepState` could live in a shared header and define the transport-neutral contract between any transport engine and any sensitive detector. This would make the pattern reusable beyond `AtTpc`. - -2. **`FindSensitiveEntry()` could use the geometry.** The brute-force 1mm linear scan up to 5m is slow and fragile. `TGeoManager::FindNextBoundary()` would find the volume crossing analytically. +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. -3. **A builder or factory for SimpleSim runs** would eliminate the FairRunSim boilerplate. A function like `AtSimpleSimRun::Create(geoFile, generators, elossModels)` that internally sets up FairRunSim with the right dummy objects would make the user-facing API dramatically simpler. +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. - **Update:** Energy loss model configuration has been addressed. `AtELossModelFactory` (with `AtELossFactoryCATIMA` and `AtELossFactoryBetheBloch` implementations) provides automatic model creation from geometry materials via `AtSimpleSimulation::SetModelFactory()`. See [energy-loss.md](../subsystems/energy-loss.md#model-factories). The FairRunSim boilerplate reduction (builder pattern) remains an open opportunity. - -4. **The detector-coupled and standalone modes should be separate paths, not a runtime branch in `Init()`.** The current `if (fDetector != nullptr)` split in `TransportParticle()` combines two fundamentally different output contracts in one method. +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 with one excellent core idea and several execution issues.** +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 `StepState` / `ProcessStep()` refactoring of `AtTpc` is the key insight. By extracting all VMC queries into a plain data struct and making the detector's step-processing logic transport-agnostic, this branch creates a clean, testable seam that any future transport engine can target. The `AtSimParticleCollector` adapter is similarly well-conceived -- it lets the full generator ecosystem work unchanged without any modifications to existing generators. - -The test coverage is notably good: the detector contract, the task internals, the MCTrack fill logic, and the physics (straight-line and Larmor radius) all have meaningful tests. +- 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 behavioral change to the Geant4 path (stopping on reaction volume exit) is the most consequential issue. It needs to be validated or guarded. -- The user experience is still FairRoot-heavy: users set up a Geant3 run to not use Geant3. A thin convenience layer would make a large difference. -- Unit conversion by magic number across 15+ call sites is a maintenance and correctness risk. -- The dual standalone/detector-coupled modes create an implicit contract that will confuse users. +- 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, with one more iteration. The `StepState` contract is the right foundation. The task hierarchy is extensible. The main work remaining is: (a) fix the Geant4 behavioral regression, (b) unify or document the sensitive volume logic, (c) centralize unit conversions, and (d) consider a convenience API that hides FairRunSim setup from physics users. +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. @@ -164,220 +135,241 @@ The direction is coherent and this is not a workaround -- it is a genuine archit # SimpleSim Integration Correctness Review -Scope: whether the integration correctly fulfills FairRoot contracts and produces output that downstream tasks can consume without modification. Does not revisit API design or architecture (covered above). +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. -## 1. Framework Contract Compliance +Scope: correctness of the integration layer. The physics of `AtSimpleSimulation` and the design quality are reviewed separately above. -### 1a. Beam/reaction event flag inversion in generator task (CRITICAL) +## 1. Integration Summary -In `AtSimpleSimulationGeneratorTask::LoadEvent()` (line 33): +### Geant4 path (framework level) -```cpp -fPrimGen->GenerateEvent(&fCollector); -state.beamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); -``` +`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. -Inside `GenerateEvent`, `AtReactionGenerator::ReadEvent()` reads the flag, then calls `EndEvent()` which **toggles it** before returning. By the time `LoadEvent` reads the flag, it has been inverted. The result: +### SimpleSim path -- **Event 0 (beam)**: `state.beamEvent = false` (wrong) -- **Event 1 (reaction)**: `state.beamEvent = true` (wrong) +`AtSimpleSimulationTask` (a `FairTask`) runs inside the same `FairRunSim` event loop, after a no-op Geant4 transport (dummy generator produces zero primaries). On each `Exec()`: -This propagates through the full chain: +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. -``` -LoadEvent().beamEvent - -> TransportCurrentEvent(beamEvent) - -> TransportParticle(particle, beamEvent) - -> beamTrack = beamEvent && particle.trackID == 0 - -> StepState.beamTrack - -> AtTpc::ProcessStep -> fIsBeamTrack -``` +### Integration layer responsibilities -`fIsBeamTrack` controls: -- `trackEnteringVolume`: whether `InPos` (beam entry position) is recorded -- `getTrackParametersWhileExiting`: whether `resetVertex()` is called on beam exit -- `reactionOccursHere()`: `isPrimaryBeam = fIsBeamTrack` -- gates the reaction trigger entirely +- 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 -With the flag inverted: -- **Event 0 (beam)**: `fIsBeamTrack=false` -> reaction never fires -> vertex never set -- **Event 1 (reaction)**: `fIsBeamTrack=true` for trackID=0 -> reaction fires for FairBoxGenerator's beam particle -> calls `ResetVertex()` then `SetVertex()` at the wrong position +## 2. Framework Contract Compliance -The vertex propagation chain breaks. `AtTPC2Body::GenerateReaction` on Event 1 reads the vertex from `AtVertexPropagator` -- which was never properly set by Event 0. Products start from wrong positions. +**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. -**Fix**: Capture the flag before calling `GenerateEvent()`: +**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. -```cpp -bool wasBeamEvent = AtVertexPropagator::Instance()->IsBeamEvent(); -fPrimGen->GenerateEvent(&fCollector); -state.beamEvent = wasBeamEvent; -``` +**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. -**Note**: The validation macros use `AtSimpleSimulationReplayTask`, which sets the flag explicitly and avoids this bug. The generator task is not exercised by any macro on this branch. +**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. -### 1b. ProcessStep exit-stop changes Geant4 behavior +## 3. Integration Correctness -`AtTpc::ProcessStep()` (line 235-236) adds: +### Finding 1 (HIGH): `entering` flag misses inter-sensitive-volume boundaries + +**Location:** `AtSimpleSimulationTask.cxx:255` ```cpp -if (step.exiting && IsReactionVolume(fVolName)) - return true; +const bool entering = !preSensitive && postSensitive; +const bool exiting = preSensitive && !postSensitive; ``` -This stops **all** particles on exit from drift_volume/cell, not just the beam. In the old code, only `startReactionEvent()` called `gMC->StopTrack()` (beam-only). Now in the Geant4 path, reaction products exiting the active gas are also stopped. While `AtClusterize` only processes drift_volume hits (so this is benign for standard digitization), it changes Geant4 behavior for any analysis reading raw `AtTpcPoint` data that expects window-region hits from exiting products. +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`. -### 1c. MCTrack branch registration +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. -`AtSimpleSimulationTask::RegisterMCTrackBranch()` checks for an existing `MCTrack` branch and reuses it, or creates one. In the FairRunSim setup, `AtStack` also registers `MCTrack`. The task handles this correctly (line 94-98), reusing the existing branch if present. +**Consequences:** -## 2. Integration Correctness +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.** -### 2a. Unit conversions -- correct +**Suggested fix:** -All conversion points were traced: +```cpp +const bool volumeChanged = step.preVolumeName != step.postVolumeName; +const bool entering = (!preSensitive && postSensitive) || (volumeChanged && postSensitive); +const bool exiting = (preSensitive && !postSensitive) || (volumeChanged && preSensitive); +``` -| Direction | Quantity | Conversion | Location | -|-----------|----------|------------|----------| -| Collector -> SimpleSim | position | cm x 10 -> mm | `TransportParticle` L130 | -| Collector -> SimpleSim | momentum | GeV x 1000 -> MeV | `TransportParticle` L131 | -| SimpleSim -> StepState | energyLoss | MeV / 1000 -> GeV | `ProcessDetectorStep` L215 | -| SimpleSim -> StepState | trackLength | mm / 10 -> cm | `ProcessDetectorStep` L216 | -| SimpleSim -> StepState | position | mm / 10 -> cm | `ProcessDetectorStep` L223 | -| SimpleSim -> StepState | momentum | MeV / 1000 -> GeV | `ProcessDetectorStep` L224 | -| SimpleSim -> StepState | totalEnergy | MeV / 1000 -> GeV | `ProcessDetectorStep` L221 | +**Confidence: High** -- the mechanism is clear from code inspection. -All conversions are consistent and correct. The `StepState` unit annotations (`// GeV`, `// cm`, etc.) match the values produced. +### Finding 2 (MEDIUM): `timeNs` always zero -### 2b. Generator invocation +**Location:** `AtSimpleSimulationTask.cxx:284,314` -`AtSimParticleCollector` inherits `FairGenericStack` and intercepts `PushTrack()`. This captures all the particles that generators would normally push onto the VMC stack, preserving their cm/GeV units. The 18-param and 19-param `PushTrack` overloads are both handled. +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. -`AtSimParticleCollector` does not support `PopNextTrack` / `PopPrimaryForTracking` (return nullptr) or `GetCurrentTrack` (returns nullptr). These are acceptable since no VMC transport runs, but could break generators that call `GetStack()->GetCurrentTrack()` during `ReadEvent`. +`AtClusterizeTask` and `AtPulseTask` compute drift time from position, so the main digitization chain is unaffected. -### 2c. Edge cases +**Confidence: High.** -**Particles starting outside the active volume**: `FindSensitiveEntry()` probes forward in 1 mm steps along the momentum direction, up to 5 m. This is reasonable but imprecise -- the entry point could miss by up to 1 mm, and energy loss in non-sensitive material between the actual start and the probe hit is not accounted for (SimpleSim applies the same dE/dx model everywhere). +### Finding 3 (LOW): Mass inconsistency between generator and transport in straight-line path -**Missing energy-loss model**: `TransportParticle` catches `std::invalid_argument` and logs at `debug` level. The particle is silently skipped. A user might not notice reaction products disappearing. +**Location:** `AtSimpleSimulation.cxx:312,323-326` -**Particles that never stop**: The curved-track path has a `kMaxCurvedTransportSteps = 200000` guard. The straight-line path stops when exiting the geometry or KE < 1 keV. Both are adequate. +In the straight-line propagation path: -**Zero-length tracks**: `SubmitInitialSensitivePoint` creates a hit with `energyLoss=0` and `trackLength=0`. `AtClusterize` skips zero-loss hits, so this contributes no electrons. It correctly sets up entering-volume state in `AtTpc` without producing a physics contribution. +```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 +``` -### 2d. `correctPosOut()` not applied in SimpleSim path +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. -In the Geant4 `ProcessHits`, exit positions are refined using `TGeoManager` boundary correction (`correctPosOut()`). In the SimpleSim path, exit positions come directly from the propagator's last step with no boundary refinement. This causes minor position imprecision at volume boundaries (up to one step size, typically ~1 mm). +The curved path correctly uses `info.mass` throughout via `AtTools::Kinematics::KE(momentum, info.mass)`. -### 2e. Time field is always zero +**Confidence: Medium** -- the mass difference is real but the practical impact is small. -`ProcessDetectorStep` sets `detectorStep.timeNs = 0.` for all steps. `AtClusterize` does not use the time field, so this doesn't affect current downstream processing. Any analysis reading `AtMCPoint::GetTime()` would see zero. +### Finding 4 (LOW): MCTrack metadata sparse -### 2f. Replay task doesn't set AtVertexPropagator track metadata +**Location:** `AtSimpleSimulationTask.cxx:218` -In the Geant4 path, `AtTPC2Body` populates `SetTrackEnergy(trackID, ...)` and `SetTrackAngle(trackID, ...)`. The replay task reads raw MCTracks and pushes them directly to the collector without running any generator, so these are never set. `AtTpc::addHit()` reads them for `EIni` / `AIni` fields in `AtMCPoint` -- they'll be 0.0 for all points. `AtClusterize` doesn't use them, but any analysis reading these fields sees wrong values. +```cpp +new ((*fMCTrackArray)[particle.trackID]) AtMCTrack(particle.pdgCode, -1, + particle.px, particle.py, particle.pz, + particle.vx, particle.vy, particle.vz, 0.0, 0); +``` -## 3. Pipeline Trace +`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. -Tracing a single primary proton through the SimpleSim **generator** path (illustrating the flag bug from 1a): +**Confidence: High.** -1. **FairPrimaryGenerator::GenerateEvent** runs generators. `FairBoxGenerator` pushes the beam particle (trackID=0). `AtTPC2Body::GenerateReaction` reads vertex from `AtVertexPropagator`, computes kinematics, pushes products (trackID=1,2). `AtReactionGenerator::EndEvent` toggles the flag. +### Finding 5 (LOW): No `correctPosOut()` equivalent -2. **LoadEvent** reads the inverted flag. On Event 0 (beam): `beamEvent=false`. +**Location:** `AtTpc.cxx:131-158` vs `AtSimpleSimulationTask.cxx:296-331` -3. **TransportCurrentEvent(false)**: For the beam (trackID=0): `beamTrack = false && (0==0) = false`. +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. -4. **FindSensitiveEntry**: If the beam starts outside the drift volume (typical -- generated at z=-100 cm), probes forward to find the entry. +**Confidence: Medium.** -5. **SubmitInitialSensitivePoint**: Creates an entering `StepState` with `beamTrack=false`. `AtTpc::ProcessStep` sets `fIsBeamTrack=false`, calls `trackEnteringVolume` (but `InPos` is NOT set because `fIsBeamTrack` is false). +### Unit conversions -- correct throughout -6. **TransportParticle callback**: For each RK4 step inside the drift volume, `ProcessDetectorStep` builds a `StepState` (units converted correctly) and calls `ProcessStep`. Energy loss accumulates in `fELossAcc`. +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. -7. **reactionOccursHere**: `isPrimaryBeam = fIsBeamTrack = false` -> **never fires**. The beam particle traverses the entire volume or stops without setting the vertex. +### Generator invocation -- correct -8. **Exit**: When the proton exits the drift volume, `step.exiting && IsReactionVolume` -> `ProcessStep` returns true -> transport stops. +`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. -9. **Vertex**: `AtVertexPropagator::SetVertex()` is never called. The vertex remains at default (0,0,0). +## 4. Pipeline Trace -10. **Event 1 (reaction)**: `AtTPC2Body::GenerateReaction` reads vertex -> (0,0,0) -> products start from wrong position. +**Single primary particle: proton from AtTPC2Body reaction** -For the **replay** path, this trace does not apply -- the replay task sets the beam flag explicitly and reads particle kinematics from the Geant4 truth file, bypassing the vertex propagation chain entirely. +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. -## 4. Failure Mode Assessment +2. **Unit conversion**: `TransportParticle` converts to mm/MeV: `pos = vertex * 10`, `mom = (p*1000, E*1000)`. -### Flag inversion (generator task only) +3. **Sensitive entry**: Vertex is inside drift_volume (that's where the reaction happened). `IsSensitiveVolume` returns true, no `FindSensitiveEntry` needed. -**What breaks**: The reaction vertex is never set on beam events and incorrectly triggered on reaction events. Products start from wrong positions. +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. -**Sensitivity**: Every downstream observable that depends on vertex position -- track angles, reaction kinematics, Q-value reconstruction. +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. -**Visibility**: **Silently wrong**. The simulation runs to completion and produces output with correct-looking structure but wrong physics. A comparison with Geant4 truth would reveal it immediately (vertex z mismatch), but a standalone run would not obviously fail. +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. -**Mitigation on this branch**: Both validation macros use the `ReplayTask` (which avoids the bug), so the current validation pipeline does not exercise this failure. +7. **Exit**: When the proton exits drift_volume to non-sensitive material: `exiting = true`, callback returns `false`, transport stops. -### ProcessStep exit-stop in Geant4 path +8. **Tree fill**: After `Exec()` returns, `FairMCApplication::FinishEvent()` calls `FairRootManager::Fill()`, writing the `AtTpcPoint` and `MCTrack` branches. Then `AtTpc::EndOfEvent()` clears the collection. -**What breaks**: Reaction products stopped at the drift volume boundary. Secondaries from stopped products are lost. +**Where output could differ from Geant4:** -**Sensitivity**: Low for standard digitization (only drift_volume hits used). Could matter for efficiency studies or background estimates. +- **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). -**Visibility**: Visible as fewer hits in boundary volumes (`window`) when comparing old vs. new Geant4 output. +## 5. Failure Mode Assessment -## 5. High-Risk Findings +| 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 | -### 1. Beam/reaction event flag inversion in generator task +**Most sensitive downstream stages:** -- **What**: `LoadEvent` reads `IsBeamEvent()` after `EndEvent()` has toggled it inside `GenerateEvent()`, yielding the inverted value. -- **Why**: `AtReactionGenerator::ReadEvent()` calls `EndEvent()` before returning to `GenerateEvent()`, but `LoadEvent` reads the flag after `GenerateEvent()` returns. -- **Impact**: Vertex never set, reaction products at wrong positions, silently wrong physics. -- **Confidence**: Very high -- mechanically verified from the call sequence. -- **File**: `AtSimpleSimulationGeneratorTask.cxx:33` +- **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. -### 2. ProcessStep stops all particles exiting reaction volumes (Geant4 side-effect) +## 6. High-Risk Findings (Top 5) -- **What**: The `step.exiting && IsReactionVolume(fVolName)` return in `ProcessStep` applies to both SimpleSim and Geant4 paths. -- **Why**: `ProcessStep` is shared code, but this exit-stop was added for SimpleSim transport semantics. -- **Impact**: Non-beam Geant4 tracks stopped at drift volume boundary; minor data loss for boundary analyses. -- **Confidence**: High -- visible in the diff and `ProcessHits` calls `ProcessStep`. -- **File**: `AtTpc.cxx:235-236` +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.** -### 3. Replay task doesn't populate AtVertexPropagator track energy/angle +2. **`timeNs` always zero** -- All MC points lack transport time. Silently wrong for any code checking time. **Confidence: High.** -- **What**: `SetTrackEnergy` / `SetTrackAngle` are never called in the replay path. -- **Why**: No generator runs; particles are read from file. -- **Impact**: `EIni` / `AIni` fields in `AtMCPoint` are 0.0. Not used by digitization, but wrong for direct analysis which is not an issue for this use case. -- **Confidence**: High. -- **File**: `AtSimpleSimulationReplayTask.cxx:40-55`, `AtTpc.cxx:261-280` -- **Do not patch - not an issue** +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. Sensitive volume identification is duplicated and divergent +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.** -- **What**: `AtSimpleSimulationTask::IsSensitiveVolume()` checks `"drift_volume"`, `"window"`, `"cell"`. `AtTpc::IsReactionVolume()` checks only `"drift_volume"` and `"cell"`. `AtTpc::CheckIfSensitive()` checks all three. -- **Why**: Three independent implementations of the same concept. -- **Impact**: If detector geometry adds a new sensitive volume, all three must be updated independently. -- **Confidence**: Medium -- the current set of volumes is consistent, but the maintenance risk is real. +5. **No `correctPosOut()` equivalent** -- Exit positions overshoot the volume boundary by up to one step size (~1 mm). **Confidence: Medium.** -## 6. Suggested Validation Tests +## 7. Suggested Validation Tests -### For Finding #1 (flag inversion) +**Contract test: entering fires at every volume boundary** -**Unit test**: Create an `AtSimpleSimulationGeneratorTask` with a generator that calls `EndEvent()`. Verify that `LoadEvent().beamEvent` matches the pre-toggle flag value: +``` +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** ``` -Event 0: expect beamEvent == true (currently returns false) -Event 1: expect beamEvent == false (currently returns true) +For each particle: sum(MCPoint.eLoss) + final KE ~= initial KE. +Tolerance: ~1% for step discretization. +This catches unit conversion errors and mass mismatches. ``` -**Integration test**: Run the generator task with `AtTPC2Body` and verify that `AtVertexPropagator::GetVz()` is non-zero after the beam event's transport completes. +**Edge case: particle starts outside geometry** -### For Finding #2 (exit-stop behavior) +``` +Inject a particle at (0, 0, -500) mm (outside any volume). +Assert: TransportParticle throws std::invalid_argument. +``` -**A/B comparison**: Run the Geant4 path before and after this branch. Count `AtTpcPoint` entries with `GetVolName() == "window"` from non-beam tracks. The new code should produce fewer (or zero) such hits. +**Edge case: missing energy-loss model** -### For Finding #3 (missing metadata) +``` +Transport a particle species (Z, A) without registering a model or factory. +Assert: throws std::invalid_argument with descriptive message. +``` -**Comparison test**: Compare `AtMCPoint::GetEIni()` and `GetAIni()` between Geant4 output and replay-task output for the same events. Geant4 should have non-zero values; replay should have all zeros. +**Edge case: zero-momentum particle** -### For general output correctness +``` +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. +``` -**Bragg curve comparison**: For a fixed-angle single event, compare the dE/dx vs. range profile between Geant4 and SimpleSim outputs. This catches unit errors, energy loss model discrepancies, and step-size artifacts simultaneously. The existing `compareFixed.C` and `compareKinematic.C` macros appear designed for this. +**Replay fidelity test:** -**Vertex position match**: For the generator task (once the flag bug is fixed), verify that the vertex Z from `AtVertexPropagator::GetVz()` matches between Geant4 and SimpleSim to within the step-size uncertainty (~1 mm). +``` +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/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C index 6b48922ed..71ceeea4e 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C @@ -1,11 +1,10 @@ // 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 -#include - namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) { @@ -22,8 +21,7 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th auto *primGen = new FairPrimaryGenerator(); - auto *ionGen = - new AtTPCIonGenerator("Ion", z, a, q, m, px, py, pz, beamExcitation, beamMass, nominalEnergy); + 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); @@ -93,7 +91,8 @@ void simpleSim_kinematic_factory(Int_t nEvents = 1000, UInt_t seed = 42) sim->SetModelFactory(std::make_shared()); auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); - simTask->SetPrimaryGenerator(BuildElasticGenerator(0.0, 180.0)); + auto *primGen = BuildElasticGenerator(0.0, 180.0); + simTask->SetPrimaryGenerator(primGen); simTask->SetDetector(tpc); run->AddTask(simTask); From 415123414c7d7222e7d5c81897961dfe9be90bbd Mon Sep 17 00:00:00 2001 From: Adam Anthony Date: Fri, 10 Apr 2026 16:10:43 -0400 Subject: [PATCH 17/18] Refactor SimpleSim into transport engine + detector adapter and fix review issues Split AtSimpleSimulation into a pure transport engine (callback-based API) and AtStandaloneSimulation (hit-recording wrapper for MCFitter). Integrate transport with AtTpc::ProcessStep via AtSimpleSimulationTask, which translates SimpleSim TransportSteps into the detector's StepState contract. Review fixes: - Cache TGeoVolume* from loop conditions to eliminate redundant FindNode calls - Unify stop tolerance (fStopTol) and max-step limit (fMaxTransportSteps) across both curved and straight-line transport paths - Add max-step safety valve to straight-line loop - Document ProcessStep enter/exit/accumulate contract in AtTpc.h - Remove redundant GetVolumeNameAt calls and duplicated comment in task - Clarify navigator abandonment on gGeoManager change - Make AtSimParticleCollector stubs LOG(fatal) instead of silently returning nullptr Co-Authored-By: Claude Opus 4.6 (1M context) --- AtDetectors/AtTpc/AtTpc.h | 13 +- AtDigitization/AtDigiLinkDef.h | 1 + AtDigitization/AtSimParticleCollector.cxx | 12 + AtDigitization/AtSimParticleCollector.h | 10 +- AtDigitization/AtSimTest.cxx | 34 ++- AtDigitization/AtSimpleSimulation.cxx | 255 +++++++----------- AtDigitization/AtSimpleSimulation.h | 104 +++---- .../AtSimpleSimulationReplayTask.cxx | 10 +- AtDigitization/AtSimpleSimulationTask.cxx | 166 ++++++++---- AtDigitization/AtSimpleSimulationTask.h | 19 +- AtDigitization/AtStandaloneSimulation.cxx | 95 +++++++ AtDigitization/AtStandaloneSimulation.h | 67 +++++ AtDigitization/CMakeLists.txt | 1 + AtReconstruction/AtFitter/AtMCFission.cxx | 2 +- AtReconstruction/AtFitter/AtMCFitter.cxx | 2 +- AtReconstruction/AtFitter/AtMCFitter.h | 4 +- AtTools/AtPropagator.h | 8 +- AtTools/AtPropagatorTest.cxx | 20 +- 18 files changed, 489 insertions(+), 334 deletions(-) create mode 100644 AtDigitization/AtStandaloneSimulation.cxx create mode 100644 AtDigitization/AtStandaloneSimulation.h diff --git a/AtDetectors/AtTpc/AtTpc.h b/AtDetectors/AtTpc/AtTpc.h index f5fd1cd60..f323c79f6 100644 --- a/AtDetectors/AtTpc/AtTpc.h +++ b/AtDetectors/AtTpc/AtTpc.h @@ -114,7 +114,18 @@ class AtTpc : public FairDetector { /** * Process a detector step from a transport-neutral snapshot. - * Returns true when the transport should stop at this step because the reaction fired. + * + * 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 a reaction 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 or beam exited + * a reaction volume with fStopOnReactionVolumeExit enabled). */ bool ProcessStep(const StepState &step); diff --git a/AtDigitization/AtDigiLinkDef.h b/AtDigitization/AtDigiLinkDef.h index 57eedcea5..37e27b85d 100644 --- a/AtDigitization/AtDigiLinkDef.h +++ b/AtDigitization/AtDigiLinkDef.h @@ -25,4 +25,5 @@ #pragma link C++ class AtSimpleSimulationGeneratorTask +; #pragma link C++ class AtSimpleSimulationReplayTask +; #pragma link C++ class AtSimpleSimulation -!; +#pragma link C++ class AtStandaloneSimulation -!; #endif diff --git a/AtDigitization/AtSimParticleCollector.cxx b/AtDigitization/AtSimParticleCollector.cxx index 50eefa76b..c307fd5d2 100644 --- a/AtDigitization/AtSimParticleCollector.cxx +++ b/AtDigitization/AtSimParticleCollector.cxx @@ -1,11 +1,23 @@ #include "AtSimParticleCollector.h" +#include + 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 index a9304bca5..4de5f2a50 100644 --- a/AtDigitization/AtSimParticleCollector.h +++ b/AtDigitization/AtSimParticleCollector.h @@ -33,6 +33,11 @@ struct AtCollectedParticle { * energy calculation) runs unchanged; the resulting particles land here rather than in Geant4. * * Only primary particles to be tracked (toBeDone == 1) are stored. + * + * @note GetCurrentTrack(), PopNextTrack(), and PopPrimaryForTracking() are not implemented + * and will LOG(fatal) if called. Generators that only call PushTrack() work correctly. If a + * generator needs to inspect the stack, these stubs must be extended to return synthesized + * TParticle objects. */ class AtSimParticleCollector : public FairGenericStack { std::vector fParticles; @@ -61,13 +66,14 @@ class AtSimParticleCollector : public FairGenericStack { is, -1); } - // ---- TVirtualMCStack pure-virtual stubs (never called outside VMC context) ---- + // ---- TVirtualMCStack pure-virtual stubs ---- + // These are not implemented and will LOG(fatal) if called. See class-level @note. virtual TParticle *PopNextTrack(Int_t &itrack); virtual TParticle *PopPrimaryForTracking(Int_t i); + virtual TParticle *GetCurrentTrack() const; virtual void SetCurrentTrack(Int_t itrack) { fCurrentTrack = itrack; } virtual Int_t GetNtrack() const { return static_cast(fParticles.size()); } virtual Int_t GetNprimary() const { return static_cast(fParticles.size()); } - virtual TParticle *GetCurrentTrack() const { return nullptr; } virtual Int_t GetCurrentTrackNumber() const { return fCurrentTrack; } virtual Int_t GetCurrentParentTrackNumber() const { return -1; } diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx index dfe08fd18..4e4fd5d86 100644 --- a/AtDigitization/AtSimTest.cxx +++ b/AtDigitization/AtSimTest.cxx @@ -16,6 +16,7 @@ #include "AtMCPoint.h" #include "AtSimpleSimulation.h" #include "AtSimpleSimulationGeneratorTask.h" +#include "AtStandaloneSimulation.h" #include "AtELossModel.h" #include "AtMCTrack.h" @@ -90,7 +91,7 @@ class TestableSimTask : public AtSimpleSimulationGeneratorTask { using AtSimpleSimulationTask::fDetector; using AtSimpleSimulationTask::fMCTrackArray; using AtSimpleSimulationTask::FillMCTracks; - using AtSimpleSimulationTask::SubmitInitialSensitivePoint; + using AtSimpleSimulationTask::SubmitDetectorStep; EventState LoadEvent() override { return {}; } }; @@ -102,11 +103,12 @@ class TestableSimTask : public AtSimpleSimulationGeneratorTask { // --------------------------------------------------------------------------- TEST_F(AtSimTest, ZeroFieldStraightLine) { - AtSimpleSimulation sim; - sim.AddModel(1, 1, std::make_shared(1.0 /*MeV/mm*/)); + auto engine = std::make_unique(); + engine->AddModel(1, 1, std::make_shared(1.0 /*MeV/mm*/), 1.007276); // proton mass in amu + AtStandaloneSimulation sim(std::move(engine)); // Proton: KE = 50 MeV → p_z ≈ 310.5 MeV/c, E ≈ 988.3 MeV - const double mass_p = 938.272; // MeV/c² + 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); @@ -162,12 +164,13 @@ TEST_F(AtSimTest, ZeroFieldStraightLine) // --------------------------------------------------------------------------- TEST_F(AtSimTest, MagneticFieldLarmorRadius) { - AtSimpleSimulation sim; + auto engine = std::make_unique(); // Tiny energy-loss rate keeps KE almost constant (avoids infinite loop in // straight-line path, irrelevant here since B≠0 uses AtPropagator). - sim.AddModel(1, 1, std::make_shared(0.0 /*MeV/mm — no drag*/)); + engine->AddModel(1, 1, std::make_shared(0.0 /*MeV/mm — no drag*/)); // Use explicit XYZVector construction to ensure the B field is recognised as non-zero. - sim.SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); // 2 T along Z + engine->SetMagneticField(ROOT::Math::XYZVector(0., 0., 2.0)); // 2 T along Z + AtStandaloneSimulation sim(std::move(engine)); const double mass_p = 938.272; // MeV/c² const double px0 = 100.0; // MeV/c (purely transverse) @@ -232,8 +235,9 @@ TEST_F(AtSimTest, MagneticFieldLarmorRadius) TEST_F(AtSimTest, LegacySimulateParticleStillRejectsStartsOutsideDriftVolume) { - AtSimpleSimulation sim; - sim.AddModel(1, 1, std::make_shared(0.1)); + auto engine = std::make_unique(); + engine->AddModel(1, 1, std::make_shared(0.1)); + AtStandaloneSimulation sim(std::move(engine)); const double mass_p = 938.272; const double E0 = mass_p + 10.0; @@ -262,7 +266,6 @@ TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) bool sawCaveToDrift = false; int callbackCount = 0; - sim.NewEvent(); sim.TransportParticle(1, 1, pos, mom, [&](const AtSimpleSimulation::TransportStep &step) { ++callbackCount; if (step.preVolumeName == "cave" && step.postVolumeName == "drift_volume") @@ -272,7 +275,6 @@ TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) EXPECT_GT(callbackCount, 0); EXPECT_TRUE(sawCaveToDrift); - EXPECT_EQ(sim.GetNumPoints(), 0) << "Detector-coupled transport should not emit legacy MC points"; } TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) @@ -337,7 +339,15 @@ TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) 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)); - const bool keepTransporting = task.SubmitInitialSensitivePoint(0, 1000060160, true, pos, mom); + // Build a synthetic initial step (zero energy loss, entering) + AtSimpleSimulation::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); diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index 890274c94..84c1260e3 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -4,14 +4,10 @@ #include "AtELossModel.h" #include "AtELossModelFactory.h" #include "AtKinematics.h" -#include "AtMCPoint.h" #include "AtPropagator.h" -#include "AtSpaceChargeModel.h" // for AtSpaceChargeModel #include -#include -#include // for TClonesArray #include #include #include @@ -19,49 +15,20 @@ #include #include #include -#include // for TObject #include #include // for sqrt #include // for invalid_argument #include // for pair -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; namespace { -constexpr int kMaxCurvedTransportSteps = 200000; constexpr int kMaxMinStepCurvedSteps = 4096; constexpr double kMinStepGuardScale = 1.01; -} - -// --------------------------------------------------------------------------- -// Thin wrapper so a shared_ptr can be passed to AtPropagator -// (which requires a unique_ptr). -// --------------------------------------------------------------------------- -namespace { -class ELossModelShared : public AtTools::AtELossModel { - std::shared_ptr fImpl; - -public: - explicit ELossModelShared(std::shared_ptr impl) - : AtTools::AtELossModel(0), fImpl(std::move(impl)) - { - } - double GetdEdx(double e) const override { return fImpl->GetdEdx(e); } - double GetRange(double ei, double ef = 0) const override { return fImpl->GetRange(ei, ef); } - double GetEnergyLoss(double ei, double d) const override { return fImpl->GetEnergyLoss(ei, d); } - double GetEnergy(double ei, double d) const override { return fImpl->GetEnergy(ei, d); } - double GetElossStraggling(double ei, double ef) const override { return fImpl->GetElossStraggling(ei, ef); } - double GetdEdxStraggling(double ei, double ef) const override { return fImpl->GetdEdxStraggling(ei, ef); } - double GetRangeVariance(double e) const override { return fImpl->GetRangeVariance(e); } -}; int GetPDGFromZA(int Z, int A) { @@ -83,6 +50,7 @@ AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) fGeoManager = gGeoManager; fNavigator = nullptr; } + AtSimpleSimulation::AtSimpleSimulation() { // Defer geometry check until first use. FairRunSim::Init() sets up @@ -92,47 +60,59 @@ AtSimpleSimulation::AtSimpleSimulation() fNavigator = nullptr; } +AtSimpleSimulation::AtSimpleSimulation(std::shared_ptr factory) : AtSimpleSimulation() +{ + fModelFactory = std::move(factory); +} + +AtSimpleSimulation::AtSimpleSimulation(std::string geoFile, std::shared_ptr factory) + : AtSimpleSimulation(std::move(geoFile)) +{ + fModelFactory = std::move(factory); +} + bool AtSimpleSimulation::ParticleID::operator<(const ParticleID &other) const { if (A < other.A) { return true; } else if (A > other.A) { return false; - } else { - return Z < other.Z; } + return Z < other.Z; } -/// Takes position in mm -TGeoVolume *AtSimpleSimulation::GetVolume(const XYZPoint &point) +TGeoVolume *AtSimpleSimulation::GetVolume(const XYZPoint &pos) { - auto pointCm = point / 10.; - { - std::lock_guard lock(fGeoMutex); - if (gGeoManager == nullptr) - return nullptr; - - if (fGeoManager != gGeoManager || fNavigator == nullptr) { - fGeoManager = gGeoManager; - fNavigator = fGeoManager->AddNavigator(); - } + auto pointCm = pos / 10.; // Convert from mm to cm - TGeoNode *node = fNavigator->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); - if (node == nullptr) { - return nullptr; - } - return node->GetVolume(); + std::lock_guard lock(fGeoMutex); + + if (gGeoManager == nullptr) { + return nullptr; + } + + // Re-sync with gGeoManager if it changed (e.g. FairRunSim::Init loaded geometry). + if (fGeoManager != gGeoManager || fNavigator == nullptr) { + // The old navigator (if any) belongs to the old TGeoManager, which owns and + // will delete it when the manager is destroyed. We abandon it intentionally. + fNavigator = nullptr; + fGeoManager = gGeoManager; + fNavigator = fGeoManager->AddNavigator(); } + + TGeoNode *node = fNavigator->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) { - TGeoVolume *volume = GetVolume(point); if (volume == nullptr || volName != std::string(volume->GetName())) { return false; } - return true; } @@ -152,47 +132,13 @@ void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model) void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model, double massAmu) { - static constexpr double kEperAMU = 931.494; // MeV/c² per amu - static constexpr double kEcharge = 1.602176634e-19; // Coulombs + static constexpr double kEperAMU = 931.494; // MeV/c² per amu + static constexpr double kEcharge = 1.602176634e-19; // Coulombs ParticleID id = {.A = A, .Z = Z}; fModels[id] = {model, Z * kEcharge, massAmu * kEperAMU}; } -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() && fModelFactory) { - TryAutoCreateModel(Z, A, iniPos); - 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(fStandaloneVolumeName, iniPos)) - throw std::invalid_argument("Position of particle is not in active volume but is in " + GetVolumeName(iniPos)); - - const auto &volName = fStandaloneVolumeName; - - return PropagateParticle( - modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, [this, &func, &volName](const TransportStep &step) { - // Record hits only when inside the configured standalone volume - if (IsInVolume(volName, step.postPosition)) - 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 (!IsInVolume(volName, step.postPosition)) - return false; - - return true; - }); -} - std::pair AtSimpleSimulation::TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, StepCallback callback) @@ -210,20 +156,29 @@ AtSimpleSimulation::TransportParticle(int Z, int A, const XYZPoint &iniPos, cons return PropagateParticle(modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, callback); } +// ParticleInfo is taken by value (not const ref) so the transport loop owns its copy of the +// model shared_ptr and mass, independent of any external map modifications during transport. std::pair -AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, - const PxPyPzEVector &iniMom, const StepCallback &callback) +AtSimpleSimulation::PropagateParticle(ParticleInfo info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + const StepCallback &callback) { - fTrackID++; - // ----------------------------------------------------------------------- - // Curved-track path: use AtPropagator when E/B fields are non-zero + // Curved-track path: use AtPropagator when B field is non-zero or a + // field function is provided. E-field alone does not trigger RK4 because + // it is negligible for MeV-scale ion transport. // ----------------------------------------------------------------------- - if (fEField.Mag2() != 0 || fBField.Mag2() != 0) { - auto wrapModel = std::make_unique(info.model); - AtTools::AtPropagator prop(info.charge, info.mass, std::move(wrapModel)); - prop.SetEField(fEField); - prop.SetBField(fBField); + if (fBField.Mag2() != 0 || fFieldFunc != nullptr) { + AtTools::AtPropagator prop(info.charge, info.mass, info.model.get()); + + // Initialize propagator fields + 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; @@ -234,22 +189,31 @@ AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const X int numSteps = 0; int minStepSteps = 0; - while (GetVolume(prop.GetPosition()) != nullptr) { - if (++numSteps > kMaxCurvedTransportSteps) { + TGeoVolume *curVol = nullptr; + while ((curVol = GetVolume(prop.GetPosition())) != nullptr) { + if (++numSteps > fMaxTransportSteps) { LOG(warning) << "Aborting curved SimpleSim track after " << numSteps << " steps without leaving the geometry"; break; } double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - if (KE <= fCurvedStopTol) + if (KE <= fStopTol) break; + // For non-uniform fields, re-query the field at the current position before each RK4 step. + // Within a single step, the field is treated as uniform (piecewise-constant approximation). + 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(); - auto preVolumeName = GetVolumeName(posBefore); + std::string preVolumeName = curVol->GetName(); - if (isnan(posBefore.X()) || isnan(prop.GetMomentum().X())) { + 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}}; } @@ -272,7 +236,7 @@ AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const X if (++minStepSteps > kMaxMinStepCurvedSteps) { LOG(warning) << "Aborting curved SimpleSim track after " << minStepSteps << " minimum-size steps at position " << posAfter << " with KE " << KE_after - << " MeV for PDG " << pdg << " track " << fTrackID; + << " MeV for PDG " << pdg; break; } } else { @@ -282,7 +246,6 @@ AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const X if (callback) { TransportStep step; - step.trackID = fTrackID; step.pdg = pdg; step.preVolumeName = preVolumeName; step.postVolumeName = GetVolumeName(posAfter); @@ -303,33 +266,48 @@ AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const X // ----------------------------------------------------------------------- // Straight-line fast path (zero field) + // KE and momentum are computed using info.mass throughout (not the 4-vector's invariant + // mass) to stay consistent with the curved path and the energy loss model's mass. // ----------------------------------------------------------------------- auto &model = info.model; auto pos = iniPos; auto mom = iniMom; double length = 0; + int numSteps = 0; + + TGeoVolume *curVol = nullptr; + while ((curVol = GetVolume(pos)) != nullptr) { + if (++numSteps > fMaxTransportSteps) { + LOG(warning) << "Aborting straight-line SimpleSim track after " << numSteps + << " steps without leaving the geometry"; + break; + } + + double KE = AtTools::Kinematics::KE(mom.Vect(), info.mass); + if (KE <= fStopTol) + break; - while (GetVolume(pos) != nullptr && mom.E() - mom.M() > 1e-3) { - if (isnan(pos.X()) || isnan(mom.X())) { + 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; - auto preVolumeName = GetVolumeName(posBefore); + std::string preVolumeName = curVol->GetName(); auto dir = mom.Vect().Unit(); - double KE = mom.E() - mom.M(); double eLoss = model->GetEnergyLoss(KE, fDistStep); - auto E = mom.E() - eLoss; - double p = sqrt(E * E - mom.M2()); + double newKE = KE - eLoss; + if (newKE <= 0) + break; + double E = newKE + info.mass; + double p = sqrt(E * E - info.mass * info.mass); mom.SetPxPyPzE(dir.X() * p, dir.Y() * p, dir.Z() * p, E); pos += dir * fDistStep; length += fDistStep; if (callback) { TransportStep step; - step.trackID = fTrackID; step.pdg = pdg; step.preVolumeName = preVolumeName; step.postVolumeName = GetVolumeName(pos); @@ -348,40 +326,6 @@ AtSimpleSimulation::PropagateParticle(const ParticleInfo &info, int pdg, const X return {pos, mom}; } -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."; - - auto *mcPoint = dynamic_cast(fMCPoints.ConstructedAt(fMCPoints.GetEntriesFast(), "C")); - - mcPoint->SetTrackID(fTrackID); - mcPoint->SetLength(length / 10.); // Convert to cm - mcPoint->SetEnergyLoss(ELoss / 1000.); // Convert to GeV - mcPoint->SetVolName(fStandaloneVolumeName.c_str()); - - if (fSCModel) { - // In the simulation z = 0 is the window and z=1000 is the pad plane. - // In the data analysis that is flipped, so we must adjust the z value, apply SC and move back - auto posExpCoord = pos; - posExpCoord.SetZ(1000 - pos.Z()); - auto corrExpCoord = fSCModel->ApplySpaceCharge(posExpCoord); - corrExpCoord.SetZ(1000 + corrExpCoord.Z()); - mcPoint->SetPosition(corrExpCoord / 10.); - } else - mcPoint->SetPosition(pos / 10.); // Convert to cm - mcPoint->SetMomentum(mom.Vect() / 1000.); // Convert to GeV/c - // mcPoint->Print(nullptr); -} - void AtSimpleSimulation::TryAutoCreateModel(int Z, int A, const XYZPoint &pos) { if (!fModelFactory) @@ -407,8 +351,8 @@ void AtSimpleSimulation::TryAutoCreateModel(int Z, int A, const XYZPoint &pos) // Look up mass in amu from PDG database for precision; fall back to A double massAmu = static_cast(A); - int pdg = GetPDGFromZA(Z, A); - TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdg); + int pdgCode = GetPDGFromZA(Z, A); + TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdgCode); if (particle != nullptr) massAmu = particle->Mass() / 0.931494; // GeV/c² -> amu @@ -421,14 +365,3 @@ void AtSimpleSimulation::TryAutoCreateModel(int Z, int A, const XYZPoint &pos) << material->GetName(); } } - -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 292489d1f..e44b77567 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -1,16 +1,12 @@ #ifndef AT_SIMPLE_SIMULATION_H #define AT_SIMPLE_SIMULATION_H -#include "AtMCPoint.h" - #include #include // for XYZPoint #include #include // for XYZVector #include #include // for PxPyPzEVector -#include -#include #include // for function #include @@ -25,15 +21,17 @@ class AtELossModelFactory; class TGeoVolume; class TGeoManager; class TGeoNavigator; -class AtSpaceChargeModel; /** - * Class for simulating simple events using AtELossModels. + * Transport engine for simulating particles using AtELossModels. * Units in this class are MeV (energy), mm (distance) MeV/c (momentum). * - * When E/B fields are set (non-zero), 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. + * When B fields are 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 AtStandaloneSimulation. + * For FairRoot pipeline integration, see AtSimpleSimulationTask. */ class AtSimpleSimulation { protected: @@ -50,31 +48,12 @@ class AtSimpleSimulation { double mass; ///< Particle mass in MeV/c² }; - using SpaceChargeModel = std::shared_ptr; +public: using ModelPtr = std::shared_ptr; using XYZPoint = ROOT::Math::XYZPoint; using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; - std::map fModels; - std::shared_ptr fModelFactory{nullptr}; - SpaceChargeModel fSCModel{nullptr}; - double fDistStep{1.}; // Distance step in mm for straight-line propagation - std::mutex fGeoMutex; - TGeoManager *fGeoManager{nullptr}; - TGeoNavigator *fNavigator{nullptr}; - - XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) - XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) - double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) - double fCurvedStopTol{0.1}; ///< Curved-track stop tolerance in MeV; avoids pathological late stopping tails - std::string fStandaloneVolumeName{"drift_volume"}; ///< Volume name for standalone SimulateParticle hit recording - - // Variables to across an entire event - static thread_local int fTrackID; - static thread_local TClonesArray fMCPoints; - -public: struct TransportStep { int trackID = -1; int pdg = 0; @@ -90,25 +69,18 @@ class AtSimpleSimulation { }; using StepCallback = std::function; + using FieldFunc = std::function(const XYZPoint &)>; // ---- Construction ---- - /** - * Assumes that the IO manager has been initialized (it will attempt to construct the branch needed here). - */ AtSimpleSimulation(std::string geoFile); AtSimpleSimulation(); + AtSimpleSimulation(std::shared_ptr factory); + AtSimpleSimulation(std::string geoFile, std::shared_ptr factory); AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicitly deleted because of std::mutex ~AtSimpleSimulation() = default; - // ---- Standalone hit-writing API ---- - // Used by AtMCFission and direct callers. These methods manage a thread-local - // TClonesArray of AtMCPoints and write hits directly during transport. - - void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); - - // ---- Transport API ---- - // Shared by both standalone and detector-coupled paths. + // ---- Model management ---- /** * Register an energy loss model for a particle species. Charge is derived as Z*e and @@ -128,47 +100,54 @@ class AtSimpleSimulation { */ void SetModelFactory(std::shared_ptr factory) { fModelFactory = std::move(factory); } - void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = model; } - void SetDistanceStep(double step) { fDistStep = step; } ///< Step size in mm (straight-line path) + // ---- Field and step configuration ---- + void SetDistanceStep(double step) { fDistStep = step; } ///< Step size in mm (straight-line path) void SetElectricField(XYZVector eField) { fEField = eField; } ///< Electric field in V/m void SetMagneticField(XYZVector bField) { fBField = bField; } ///< Magnetic field in T /// Maximum step size (m) for the RK4 adaptive stepper in curved-track mode (default: 1e-3 m = 1 mm). void SetMaxPropagationStep(double stepM) { fMaxPropStep = stepM; } - void SetCurvedStopTolerance(double stopTolMeV) { fCurvedStopTol = stopTolMeV; } - void SetStandaloneVolumeName(const std::string &name) { fStandaloneVolumeName = name; } - - void NewEvent(); + /// Kinetic energy (MeV) below which the particle is considered stopped. Applies to both paths. + void SetStopTolerance(double stopTolMeV) { fStopTol = stopTolMeV; } + /// Maximum transport steps before aborting. Applies to both curved and straight-line paths. + void SetMaxTransportSteps(int maxSteps) { fMaxTransportSteps = maxSteps; } /** - * Simulates a particle over a given distance and returns the position and momentum of the particle at the stopping - * point. Uses Z and A to provide a model to the protected version of SimulateParticle. - * When E/B fields are non-zero, AtPropagator is used for curved-track propagation. + * Set a position-dependent field function. When set, the field is queried at each + * transport step instead of using the uniform field vectors. The function takes a + * position in mm and returns (E-field in V/m, B-field in T). */ - std::pair SimulateParticle( - int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func = [](XYZPoint pos, PxPyPzEVector mom) { return true; }); + void SetFieldFunction(FieldFunc func) { fFieldFunc = std::move(func); } - // ---- Detector-coupled transport API ---- - // Used by AtSimpleSimulationTask for Geant4 drop-in replacement. Transport steps are - // delivered via callback; hit recording is handled by the detector (AtTpc). + // ---- Transport API ---- /** * Transport a particle through the loaded geometry without writing detector hits. - * This is intended for detector-coupled adapters that want transport state but keep hit - * semantics in the detector code. + * Steps are delivered via callback; hit recording is the caller's responsibility. */ std::pair TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, StepCallback callback); - AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } - int GetNumPoints() { return fMCPoints.GetEntries(); } - TClonesArray &GetPointsArray() { return fMCPoints; } - SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } + // ---- Geometry queries ---- + bool IsInsideGeometry(const XYZPoint &point) { return GetVolume(point) != nullptr; } std::string GetVolumeNameAt(const XYZPoint &point) { return GetVolumeName(point); } protected: + std::map fModels; + std::shared_ptr fModelFactory{nullptr}; + double fDistStep{1.}; // Distance step in mm for straight-line propagation + std::mutex fGeoMutex; + TGeoManager *fGeoManager{nullptr}; + TGeoNavigator *fNavigator{nullptr}; + + XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) + XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) + double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) + double fStopTol{0.1}; ///< KE stop tolerance in MeV; shared by both transport paths + int fMaxTransportSteps{200000}; ///< Max steps before aborting; shared by both transport paths + FieldFunc fFieldFunc{nullptr}; ///< Optional position-dependent field function + bool IsInVolume(const std::string &volName, const XYZPoint &point); std::string GetVolumeName(const XYZPoint &point); @@ -178,10 +157,9 @@ class AtSimpleSimulation { * The callback controls early stopping by returning false. * Selects curved-track (RK4) or straight-line path based on field settings. */ - std::pair PropagateParticle(const ParticleInfo &info, int pdg, const XYZPoint &iniPos, + std::pair PropagateParticle(ParticleInfo info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, const StepCallback &callback); - void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); TGeoVolume *GetVolume(const XYZPoint &pos); /// Attempt to auto-create an energy loss model using fModelFactory and the geometry material at pos. diff --git a/AtDigitization/AtSimpleSimulationReplayTask.cxx b/AtDigitization/AtSimpleSimulationReplayTask.cxx index 65b11e4bb..79a034ec3 100644 --- a/AtDigitization/AtSimpleSimulationReplayTask.cxx +++ b/AtDigitization/AtSimpleSimulationReplayTask.cxx @@ -1,7 +1,6 @@ #include "AtSimpleSimulationReplayTask.h" #include "AtMCTrack.h" -#include "AtVertexPropagator.h" #include #include @@ -42,15 +41,16 @@ AtSimpleSimulationTask::EventState AtSimpleSimulationReplayTask::LoadEvent() if (fPrimaryTrackTree == nullptr) return {}; - const bool beamEvent = (fSourceEventIndex % 2) == 0; - AtVertexPropagator::Instance()->SetIsBeamEvent(beamEvent); 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 = beamEvent; - state.transportPrimaries = !beamEvent; + state.beamEvent = false; + state.transportPrimaries = true; return state; } diff --git a/AtDigitization/AtSimpleSimulationTask.cxx b/AtDigitization/AtSimpleSimulationTask.cxx index d59d8a0ea..b4bc47816 100644 --- a/AtDigitization/AtSimpleSimulationTask.cxx +++ b/AtDigitization/AtSimpleSimulationTask.cxx @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include #include @@ -59,10 +61,27 @@ AtSimpleSimulationTask::AtSimpleSimulationTask(std::unique_ptrGetListOfModules(); + 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) << "AtSimpleSimulationTask: auto-discovered AtTpc detector '" << det->GetName() + << "' from FairRunSim"; + break; + } + } + } + } + } if (fDetector == nullptr) { LOG(fatal) << "AtSimpleSimulationTask requires a sensitive detector. " - << "Call SetDetector(tpc) before Init(). " - << "For standalone simulation, use AtSimpleSimulation::SimulateParticle() directly."; + << "Call SetDetector(tpc) before Init(), or register an AtTpc with FairRunSim."; return kFATAL; } LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; @@ -81,8 +100,6 @@ InitStatus AtSimpleSimulationTask::Init() void AtSimpleSimulationTask::Exec(Option_t *) { - fSimulation->NewEvent(); - auto eventState = LoadEvent(); if (!eventState.hasEvent) return; @@ -151,10 +168,25 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() LOG(info) << "AtSimpleSimulationTask: auto-configured B field from FairRun: (" << bx_T << ", " << by_T << ", " << bz_T << ") T (sampled at drift volume center)"; - // Warn if field is not constant (SimpleSim assumes uniform) - if (field->GetType() != 0) - LOG(warning) << "AtSimpleSimulationTask: SimpleSim assumes uniform fields, but the FairRun field type is " - << field->GetType() << " (non-constant). Using field value 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) << "AtSimpleSimulationTask: 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) { @@ -243,20 +275,52 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic 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 && particle.trackID == 0; - if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(pos)) && - !SubmitInitialSensitivePoint(particle.trackID, particle.pdgCode, beamTrack, pos, mom)) - return; + + // 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)) { + AtSimpleSimulation::TransportStep initialStep; + initialStep.pdg = particle.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, particle.trackID, beamTrack, true, false)) + return; + } fSimulation->TransportParticle( Z, A, pos, mom, [this, trackID = particle.trackID, beamEvent](const AtSimpleSimulation::TransportStep &step) { const bool preSensitive = IsSensitiveVolume(step.preVolumeName); const bool postSensitive = IsSensitiveVolume(step.postVolumeName); - const bool entering = !preSensitive && postSensitive; - const bool exiting = preSensitive && !postSensitive; + + 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; - const bool keepTransporting = - ProcessDetectorStep(step, trackID, currentBeamTrack, preSensitive, postSensitive, entering, exiting); + + // 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; @@ -266,56 +330,29 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic } } -bool AtSimpleSimulationTask::SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const XYZPoint &pos, - const PxPyPzEVector &mom) +bool AtSimpleSimulationTask::SubmitDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, + bool beamTrack, bool entering, bool exiting) { - AtTpc::StepState detectorStep; - detectorStep.trackID = trackID; - detectorStep.pdg = pdg; - detectorStep.volumeName = fSimulation->GetVolumeNameAt(pos).c_str(); - detectorStep.volumeID = kAtTpc; - detectorStep.detCopyID = 0; - detectorStep.beamTrack = beamTrack; - detectorStep.entering = true; - detectorStep.exiting = false; - detectorStep.stopping = (mom.E() - mom.M() <= 1e-3); - detectorStep.disappeared = false; - detectorStep.energyLoss = 0.0; - detectorStep.timeNs = 0.0; - detectorStep.trackLength = 0.0; - detectorStep.totalEnergy = mom.E() * kMeVToGeV; - detectorStep.trackMass = mom.M() * kMeVToGeV; - detectorStep.pos.SetXYZT(pos.X() * kMmToCm, pos.Y() * kMmToCm, pos.Z() * kMmToCm, 0.0); - detectorStep.mom.SetXYZT(mom.Px() * kMeVToGeV, mom.Py() * kMeVToGeV, mom.Pz() * kMeVToGeV, mom.E() * kMeVToGeV); - detectorStep.posOut = detectorStep.pos; - detectorStep.momOut = detectorStep.mom; - - return !fDetector->ProcessStep(detectorStep); -} - -bool AtSimpleSimulationTask::ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, - bool preSensitive, bool postSensitive, bool entering, bool exiting) -{ - if (!preSensitive && !postSensitive) - return true; + // 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 = postSensitive ? step.postVolumeName.c_str() : step.preVolumeName.c_str(); + detectorStep.volumeName = refVol.c_str(); detectorStep.volumeID = kAtTpc; detectorStep.detCopyID = 0; detectorStep.beamTrack = beamTrack; detectorStep.entering = entering; detectorStep.exiting = exiting; - detectorStep.stopping = postSensitive && (step.postMomentum.E() - step.postMomentum.M() <= 1e-3); + 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; - - const auto &refPos = postSensitive ? step.postPosition : step.prePosition; - const auto &refMom = postSensitive ? step.postMomentum : step.preMomentum; detectorStep.totalEnergy = refMom.E() * kMeVToGeV; detectorStep.trackMass = step.trackMass * kMeVToGeV; detectorStep.pos.SetXYZT(refPos.X() * kMmToCm, refPos.Y() * kMmToCm, refPos.Z() * kMmToCm, 0.); @@ -336,13 +373,26 @@ XYZPoint AtSimpleSimulationTask::FindSensitiveEntry(const XYZPoint &pos, const P if (dir.R() == 0.0) throw std::invalid_argument("Particle momentum is zero; cannot search for detector entry"); - constexpr double stepMm = 1.0; - constexpr int maxSteps = 5000; - auto probe = pos; - for (int i = 0; i < maxSteps; ++i) { - probe += dir * stepMm; - if (IsSensitiveVolume(fSimulation->GetVolumeNameAt(probe))) - return probe; + 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"); diff --git a/AtDigitization/AtSimpleSimulationTask.h b/AtDigitization/AtSimpleSimulationTask.h index c054c0274..034709930 100644 --- a/AtDigitization/AtSimpleSimulationTask.h +++ b/AtDigitization/AtSimpleSimulationTask.h @@ -11,10 +11,6 @@ #include #include -namespace AtTools { -class AtELossModelFactory; -} // namespace AtTools - class AtTpc; class TBuffer; class TClass; @@ -35,12 +31,6 @@ class AtSimpleSimulationTask : public FairTask { void SetSensitiveDetector(AtTpc *detector) { fDetector = detector; } void SetDetector(AtTpc *detector) { SetSensitiveDetector(detector); } - /// Set a model factory for automatic energy loss model creation. See AtSimpleSimulation::SetModelFactory. - void SetModelFactory(std::shared_ptr factory) - { - fSimulation->SetModelFactory(std::move(factory)); - } - InitStatus Init() override; void Exec(Option_t *option) override; void Finish() override; @@ -66,10 +56,11 @@ class AtSimpleSimulationTask : public FairTask { void FillMCTracks(); void TransportCurrentEvent(bool beamEvent); void TransportParticle(const AtCollectedParticle &particle, bool beamEvent); - bool SubmitInitialSensitivePoint(int trackID, int pdg, bool beamTrack, const ROOT::Math::XYZPoint &pos, - const ROOT::Math::PxPyPzEVector &mom); - bool ProcessDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool preSensitive, - bool postSensitive, bool entering, bool exiting); + /// 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 AtSimpleSimulation::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; static bool IsSensitiveVolume(const std::string &volumeName); diff --git a/AtDigitization/AtStandaloneSimulation.cxx b/AtDigitization/AtStandaloneSimulation.cxx new file mode 100644 index 000000000..fbba5ffad --- /dev/null +++ b/AtDigitization/AtStandaloneSimulation.cxx @@ -0,0 +1,95 @@ +#include "AtStandaloneSimulation.h" + +#include "AtMCPoint.h" +#include "AtSimpleSimulation.h" +#include "AtSpaceChargeModel.h" + +#include +#include + +#include + +#include + +thread_local TClonesArray AtStandaloneSimulation::fMCPoints("AtMCPoint"); +thread_local int AtStandaloneSimulation::fTrackID = 0; + +using XYZPoint = ROOT::Math::XYZPoint; +using PxPyPzEVector = ROOT::Math::PxPyPzEVector; + +AtStandaloneSimulation::AtStandaloneSimulation(std::unique_ptr engine) + : fEngine(std::move(engine)) +{ +} + +void AtStandaloneSimulation::NewEvent() +{ + fMCPoints.Clear(); + fTrackID = 0; +} + +void AtStandaloneSimulation::RegisterBranch(std::string branchName, bool pers) +{ + auto ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before attempting to simulate an event."; + return; + } + + ioMan->Register(branchName.c_str(), "AtTPC", &fMCPoints, pers); +} + +std::pair +AtStandaloneSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + std::function func) +{ + 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 AtSimpleSimulation::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 AtStandaloneSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length) +{ + LOG(debug) << "Adding a hit at element " << fMCPoints.GetEntriesFast() << " in TClonesArray."; + + auto *mcPoint = dynamic_cast(fMCPoints.ConstructedAt(fMCPoints.GetEntriesFast(), "C")); + + mcPoint->SetTrackID(fTrackID); + mcPoint->SetLength(length / 10.); // Convert to cm + mcPoint->SetEnergyLoss(ELoss / 1000.); // Convert to GeV + mcPoint->SetVolName(fVolumeName.c_str()); + + if (fSCModel) { + // In the simulation z = 0 is the window and z=1000 is the pad plane. + // In the data analysis that is flipped, so we must adjust the z value, apply SC and move back + auto posExpCoord = pos; + posExpCoord.SetZ(1000 - pos.Z()); + auto corrExpCoord = fSCModel->ApplySpaceCharge(posExpCoord); + corrExpCoord.SetZ(1000 + corrExpCoord.Z()); + mcPoint->SetPosition(corrExpCoord / 10.); + } else + mcPoint->SetPosition(pos / 10.); // Convert to cm + mcPoint->SetMomentum(mom.Vect() / 1000.); // Convert to GeV/c +} diff --git a/AtDigitization/AtStandaloneSimulation.h b/AtDigitization/AtStandaloneSimulation.h new file mode 100644 index 000000000..7011f3611 --- /dev/null +++ b/AtDigitization/AtStandaloneSimulation.h @@ -0,0 +1,67 @@ +#ifndef AT_STANDALONE_SIMULATION_H +#define AT_STANDALONE_SIMULATION_H + +#include "AtMCPoint.h" +#include "AtSimpleSimulation.h" + +#include +#include +#include + +#include +#include +#include +#include + +class AtSpaceChargeModel; + +/** + * Standalone simulation wrapper that owns an AtSimpleSimulation transport engine + * and adds hit recording to a thread-local TClonesArray. + * + * Used by AtMCFitter, AtMCFission, and similar callers that manage their own + * event loop rather than running inside the FairRoot simulation pipeline. + */ +class AtStandaloneSimulation { +public: + using SpaceChargeModel = std::shared_ptr; + using XYZPoint = ROOT::Math::XYZPoint; + using PxPyPzEVector = ROOT::Math::PxPyPzEVector; + + explicit AtStandaloneSimulation(std::unique_ptr engine); + ~AtStandaloneSimulation() = default; + + /// Access the underlying transport engine for configuration (models, fields, etc.) + AtSimpleSimulation *GetEngine() { return fEngine.get(); } + + void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = std::move(model); } + SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } + void SetStandaloneVolumeName(const std::string &name) { fVolumeName = name; } + + void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); + void NewEvent(); + + /** + * 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, PxPyPzEVector) { return true; }); + + AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } + int GetNumPoints() { return fMCPoints.GetEntries(); } + TClonesArray &GetPointsArray() { return fMCPoints; } + +private: + std::unique_ptr fEngine; + SpaceChargeModel fSCModel{nullptr}; + std::string fVolumeName{"drift_volume"}; + + static thread_local int fTrackID; + static thread_local TClonesArray fMCPoints; + + void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); +}; + +#endif // AT_STANDALONE_SIMULATION_H diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index ead7f55ba..3f7e38b1e 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -37,6 +37,7 @@ AtTriggerTask.cxx AtVectorResponse.cxx AtSimpleSimulation.cxx +AtStandaloneSimulation.cxx AtSimpleSimulationTask.cxx AtSimpleSimulationGeneratorTask.cxx AtSimpleSimulationReplayTask.cxx diff --git a/AtReconstruction/AtFitter/AtMCFission.cxx b/AtReconstruction/AtFitter/AtMCFission.cxx index f978d09ff..77ad1fa14 100644 --- a/AtReconstruction/AtFitter/AtMCFission.cxx +++ b/AtReconstruction/AtFitter/AtMCFission.cxx @@ -14,7 +14,7 @@ #include "AtPatternY.h" // for AtPatternY, AtPatternY::XYZVector #include "AtPulse.h" #include "AtRadialChargeModel.h" -#include "AtSimpleSimulation.h" +#include "AtStandaloneSimulation.h" #include "AtStudentDistribution.h" #include "AtUniformDistribution.h" #include "AtVectorUtil.h" diff --git a/AtReconstruction/AtFitter/AtMCFitter.cxx b/AtReconstruction/AtFitter/AtMCFitter.cxx index bef820f99..6a596546e 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 "AtStandaloneSimulation.h" #include "AtSimulatedPoint.h" // IWYU pragma: keep #include "AtSpaceChargeModel.h" diff --git a/AtReconstruction/AtFitter/AtMCFitter.h b/AtReconstruction/AtFitter/AtMCFitter.h index 69c19652d..60324678a 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 AtStandaloneSimulation; class AtDigiPar; class AtPSA; @@ -31,7 +31,7 @@ class AtParameterDistribution; class AtMCFitter { protected: using ParamPtr = std::shared_ptr; - using SimPtr = std::shared_ptr; + using SimPtr = std::shared_ptr; using ClusterPtr = std::shared_ptr; using PulsePtr = std::shared_ptr; diff --git a/AtTools/AtPropagator.h b/AtTools/AtPropagator.h index 56c3ce947..14e915669 100644 --- a/AtTools/AtPropagator.h +++ b/AtTools/AtPropagator.h @@ -54,7 +54,7 @@ class AtPropagator { // Variables used for the force XYZVector fEField{0, 0, 0}; // Electric field vector XYZVector fBField{0, 0, 0}; // Magnetic field vector - std::unique_ptr fELossModel; // Energy loss model + const AtELossModel *fELossModel; // Energy loss model (non-owning; caller ensures lifetime) // Internal state variables for the propagator StepState fState; /// Current state of the particle @@ -75,8 +75,8 @@ class AtPropagator { * @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, std::unique_ptr elossModel) - : fELossModel(std::move(elossModel)) + AtPropagator(double charge, double mass, const AtELossModel *elossModel) + : fELossModel(elossModel) { fState.fMass = mass; fState.fQ = charge; @@ -106,7 +106,7 @@ class AtPropagator { fState.fMom = mom; } const StepState &GetState() const { return fState; } - const AtELossModel *GetELossModel() const { return fELossModel.get(); } + const AtELossModel *GetELossModel() const { return fELossModel; } XYZPoint GetPosition() const { return fState.fPos; } XYZVector GetMomentum() const { return fState.fMom; } diff --git a/AtTools/AtPropagatorTest.cxx b/AtTools/AtPropagatorTest.cxx index 9579f3477..bfb989ba7 100644 --- a/AtTools/AtPropagatorTest.cxx +++ b/AtTools/AtPropagatorTest.cxx @@ -52,7 +52,7 @@ TEST(AtPropagatorTest, ForceNoField) // Create a dummy energy loss model auto elossModel = std::make_unique(); - AtPropagator propagator(charge, mass, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); propagator.SetEField({0, 0, 0}); propagator.SetBField({0, 0, 0}); @@ -79,7 +79,7 @@ TEST(AtPropagatorTest, ForceEField) // Create a dummy energy loss model auto elossModel = std::make_unique(); elossModel->eLoss = 0; // No energy loss for this test - AtPropagator propagator(charge, mass, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); propagator.SetEField({0, 0, 70000}); propagator.SetBField({0, 0, 0}); @@ -100,7 +100,7 @@ TEST(AtPropagatorTest, ForceBField) // Create a dummy energy loss model auto elossModel = std::make_unique(); elossModel->eLoss = 0; // No energy loss for this test - AtPropagator propagator(charge, mass, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); propagator.SetEField({0, 0, 0}); propagator.SetBField({0, 0, 1}); @@ -117,7 +117,7 @@ TEST(AtPropagatorTest, PropagateToPoint_StoppingNoField) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); AtRK4Stepper stepper; AtMeasurementPoint measurementPoint({1e3, 0, 0}); @@ -160,7 +160,7 @@ TEST(AtPropagatorTest, PropagateToPoint_NoField) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); AtRK4Stepper stepper; AtMeasurementPoint measurementPoint({10, 0, 0}); @@ -195,7 +195,7 @@ TEST(AtPropagatorTest, PropagateToPlane_NoField) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); AtRK4Stepper stepper; double KE = 1; // Kinetic energy in MeV @@ -233,7 +233,7 @@ TEST(AtPropagatorTest, PropagateToPlane_StoppingNoField) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); AtRK4Stepper stepper; double KE = 1; // Kinetic energy in MeV @@ -267,7 +267,7 @@ TEST(AtPropagatorTest, PropagateToPointAdaptive_NoField) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); AtRK4AdaptiveStepper stepper; AtMeasurementPoint measurementPoint({10, 0, 0}); @@ -319,7 +319,7 @@ TEST(AtPropagatorTest, PropagateToPoint_Field) 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, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); propagator.SetEField({0, 0, 0}); // No electric field propagator.SetBField({0, 0, 2.85}); // Magnetic field AtRK4Stepper stepper; @@ -374,7 +374,7 @@ TEST(AtPropagatorTest, PropagateToPointAdaptive_Field) auto elossModel = std::make_unique(0); elossModel->LoadSrimTable(getEnergyPath()); elossModel->SetDensity(3.3084e-05); - AtPropagator propagator(charge, mass, std::move(elossModel)); + AtPropagator propagator(charge, mass, elossModel.get()); propagator.SetEField({0, 0, 0}); propagator.SetBField({0, 0, 2.85}); AtRK4AdaptiveStepper stepper; From f30396a4326b5ff22bac84ab989a0fdb86249375 Mon Sep 17 00:00:00 2001 From: Adam Anthony Date: Sun, 12 Apr 2026 20:21:25 -0400 Subject: [PATCH 18/18] Address PR 268 review comments and rework energy-loss + sim APIs Resolves the ~39 inline review comments on PR #268. AtTpc: drop fIsBeamTrack and fStopOnReactionVolumeExit; restore fTrackID == 0 as the beam predicate; rename IsReactionVolume to IsActiveGasVolume; delete unused AddHit overload and the redundant IsSensitiveVolume static; move correctPosOut back into getTrackParametersWhileExiting; demote CheckIfSensitive log to debug. AtTPC2Body: revert the beam-axis normalization so the file matches develop. AtSimParticleCollector: rewrite on top of FairGenericStack using TParticle directly so parent ID, time, polarization and weight are preserved instead of thrown away. Energy-loss rework: replace AtELossModelFactory hierarchy with AtELossManager that both accepts pre-built models and, in subclasses (AtELossManagerBetheBloch, AtELossManagerCATIMA), auto-generates them. Two AddModel overloads support material-agnostic registration (for MCFission/AtMCFitter) and material-specific registration. Transport queries the manager on each volume change so different materials get different models. Delete the unused legacy AtELossManager lookup class. Transport-engine / standalone-class rename: the callback-based engine is AtSimTransport; the standalone hit-recording class is AtSimpleSimulation (formerly AtStandaloneSimulation). FairRoot task classes rename to AtSimTransportTask / GeneratorTask / ReplayTask. AtSimpleSimulation carries thin forwarders so pre-refactor macros compile unchanged. PropagateParticle is split into PropagateCurved + PropagateStraightLine with a shared BuildStep helper; the default stop tolerance is restored to 1e-3 MeV and stuck-step abort constants become settable members. Docs updated to match. Tests green (90/90). Co-Authored-By: Claude Opus 4.6 (1M context) --- AtDetectors/AtTpc/AtTpc.cxx | 73 +-- AtDetectors/AtTpc/AtTpc.h | 41 +- AtDetectors/AtTpc/AtTpcTest.cxx | 40 +- AtDigitization/AtDigiLinkDef.h | 8 +- AtDigitization/AtSimParticleCollector.cxx | 52 +- AtDigitization/AtSimParticleCollector.h | 95 ++- AtDigitization/AtSimTest.cxx | 103 +-- AtDigitization/AtSimTransport.cxx | 370 +++++++++++ AtDigitization/AtSimTransport.h | 184 ++++++ ...sk.cxx => AtSimTransportGeneratorTask.cxx} | 12 +- ...orTask.h => AtSimTransportGeneratorTask.h} | 14 +- ...yTask.cxx => AtSimTransportReplayTask.cxx} | 22 +- ...eplayTask.h => AtSimTransportReplayTask.h} | 14 +- ...ulationTask.cxx => AtSimTransportTask.cxx} | 121 ++-- ...eSimulationTask.h => AtSimTransportTask.h} | 29 +- AtDigitization/AtSimpleSimulation.cxx | 405 +++--------- AtDigitization/AtSimpleSimulation.h | 196 ++---- AtDigitization/AtStandaloneSimulation.cxx | 95 --- AtDigitization/AtStandaloneSimulation.h | 67 -- AtDigitization/CMakeLists.txt | 8 +- AtGenerators/AtTPC2Body.cxx | 18 +- AtReconstruction/AtFitter/AtMCFission.cxx | 2 +- AtReconstruction/AtFitter/AtMCFitter.cxx | 2 +- AtReconstruction/AtFitter/AtMCFitter.h | 4 +- AtSimulationData/AtMCPoint.h | 4 +- AtTools/AtELossFactoryBetheBloch.cxx | 79 --- AtTools/AtELossFactoryBetheBloch.h | 26 - AtTools/AtELossFactoryCATIMA.cxx | 36 -- AtTools/AtELossFactoryCATIMA.h | 30 - AtTools/AtELossManager.cxx | 609 ++++-------------- AtTools/AtELossManager.h | 120 ++-- AtTools/AtELossManagerBetheBloch.cxx | 71 ++ AtTools/AtELossManagerBetheBloch.h | 24 + AtTools/AtELossManagerCATIMA.cxx | 35 + AtTools/AtELossManagerCATIMA.h | 30 + AtTools/AtELossManagerTest.cxx | 264 ++++++++ AtTools/AtELossModelFactory.cxx | 123 ---- AtTools/AtELossModelFactory.h | 64 -- AtTools/AtELossModelFactoryTest.cxx | 177 ----- AtTools/AtPropagator.h | 10 +- AtTools/AtToolsLinkDef.h | 7 +- AtTools/CMakeLists.txt | 8 +- .../simplesim-integration-review.md | 7 + docs/subsystems/energy-loss.md | 86 ++- docs/subsystems/simplesim-migration.md | 86 ++- docs/subsystems/simulation-pipeline.md | 18 +- .../Simulation/AtSimValidation/compareFixed.C | 2 +- .../AtSimValidation/compareKinematic.C | 2 +- .../AtSimValidation/simpleSim_fixed.C | 14 +- .../simpleSim_fixed_bethebloch.C | 12 +- .../AtSimValidation/simpleSim_fixed_factory.C | 12 +- .../AtSimValidation/simpleSim_kinematic.C | 14 +- .../simpleSim_kinematic_factory.C | 12 +- macro/e12014/adam/simulation/simpleSim.C | 6 +- 54 files changed, 1789 insertions(+), 2174 deletions(-) create mode 100644 AtDigitization/AtSimTransport.cxx create mode 100644 AtDigitization/AtSimTransport.h rename AtDigitization/{AtSimpleSimulationGeneratorTask.cxx => AtSimTransportGeneratorTask.cxx} (66%) rename AtDigitization/{AtSimpleSimulationGeneratorTask.h => AtSimTransportGeneratorTask.h} (59%) rename AtDigitization/{AtSimpleSimulationReplayTask.cxx => AtSimTransportReplayTask.cxx} (73%) rename AtDigitization/{AtSimpleSimulationReplayTask.h => AtSimTransportReplayTask.h} (63%) rename AtDigitization/{AtSimpleSimulationTask.cxx => AtSimTransportTask.cxx} (75%) rename AtDigitization/{AtSimpleSimulationTask.h => AtSimTransportTask.h} (67%) delete mode 100644 AtDigitization/AtStandaloneSimulation.cxx delete mode 100644 AtDigitization/AtStandaloneSimulation.h delete mode 100644 AtTools/AtELossFactoryBetheBloch.cxx delete mode 100644 AtTools/AtELossFactoryBetheBloch.h delete mode 100644 AtTools/AtELossFactoryCATIMA.cxx delete mode 100644 AtTools/AtELossFactoryCATIMA.h create mode 100644 AtTools/AtELossManagerBetheBloch.cxx create mode 100644 AtTools/AtELossManagerBetheBloch.h create mode 100644 AtTools/AtELossManagerCATIMA.cxx create mode 100644 AtTools/AtELossManagerCATIMA.h create mode 100644 AtTools/AtELossManagerTest.cxx delete mode 100644 AtTools/AtELossModelFactory.cxx delete mode 100644 AtTools/AtELossModelFactory.h delete mode 100644 AtTools/AtELossModelFactoryTest.cxx diff --git a/AtDetectors/AtTpc/AtTpc.cxx b/AtDetectors/AtTpc/AtTpc.cxx index 133898e8e..cad5005be 100644 --- a/AtDetectors/AtTpc/AtTpc.cxx +++ b/AtDetectors/AtTpc/AtTpc.cxx @@ -62,22 +62,17 @@ void AtTpc::Initialize() void AtTpc::trackEnteringVolume(const StepState &step) { - auto AZ = DecodePdG(step.pdg); - 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 = step.timeNs; - fLength = step.trackLength; - fPosIn = step.pos; - fMomIn = step.mom; - fTrackID = step.trackID; + getTrackParametersFromStep(step); - // Position of the first hit of the beam in the TPC volume ( For tracking purposes in the TPC) - if (fIsBeamTrack && IsReactionVolume(fVolName)) + if (fTrackID == 0 && IsActiveGasVolume(fVolName)) InPos = fPosIn; - Int_t VolumeID = 0; + auto AZ = DecodePdG(step.pdg); - if (fIsBeamTrack) + if (fTrackID == 0) LOG(debug) << cGREEN << " AtTPC: Beam Event "; else LOG(debug) << cBLUE << " AtTPC: Reaction/Decay Event "; @@ -118,8 +113,11 @@ void AtTpc::getTrackParametersWhileExiting(const StepState &step) fPosOut = step.posOut; fMomOut = step.momOut; - if (step.exiting && IsReactionVolume(fVolName) && fIsBeamTrack) - resetVertex(); + if (step.exiting) { + correctPosOut(); + if (IsActiveGasVolume(fVolName) && fTrackID == 0) + resetVertex(); + } } void AtTpc::resetVertex() @@ -130,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]; @@ -160,8 +161,8 @@ void AtTpc::correctPosOut() bool AtTpc::reactionOccursHere() { bool atEnergyLoss = fELossAcc * 1000 > AtVertexPropagator::Instance()->GetRndELoss(); - bool isPrimaryBeam = fIsBeamTrack; - bool isInRightVolume = IsReactionVolume(fVolName); + bool isPrimaryBeam = fTrackID == 0; + bool isInRightVolume = IsActiveGasVolume(fVolName); return atEnergyLoss && isPrimaryBeam && isInRightVolume; } @@ -176,7 +177,6 @@ Bool_t AtTpc::ProcessHits(FairVolume *vol) step.volumeName = gMC->CurrentVolName(); step.volumeID = vol->getMCid(); step.detCopyID = vol->getCopyNo(); - step.beamTrack = step.trackID == 0; step.entering = gMC->IsTrackEntering(); step.exiting = gMC->IsTrackExiting(); step.stopping = gMC->IsTrackStop(); @@ -192,11 +192,6 @@ Bool_t AtTpc::ProcessHits(FairVolume *vol) if (step.exiting || step.stopping || step.disappeared) { gMC->TrackPosition(step.posOut); gMC->TrackMomentum(step.momOut); - if (step.exiting) { - fPosOut = step.posOut; - correctPosOut(); - step.posOut = fPosOut; - } } bool stopTrack = ProcessStep(step); @@ -213,12 +208,11 @@ bool AtTpc::ProcessStep(const StepState &step) fVolName = step.volumeName; fVolumeID = step.volumeID; fDetCopyID = step.detCopyID; - fIsBeamTrack = step.beamTrack; if (step.entering) trackEnteringVolume(step); - - getTrackParametersFromStep(step); + else + getTrackParametersFromStep(step); if (step.exiting || step.stopping || step.disappeared) getTrackParametersWhileExiting(step); @@ -230,11 +224,6 @@ bool AtTpc::ProcessStep(const StepState &step) return true; } - // For SimpleSim transport, leaving the active gas means transport should stop. - // Guarded by flag to preserve Geant4 behavior where products may continue into boundary volumes. - if (fStopOnReactionVolumeExit && step.exiting && IsReactionVolume(fVolName)) - return true; - return false; } @@ -279,7 +268,7 @@ void AtTpc::addHit(const StepState &step) TVector3(fMomIn.Px(), fMomIn.Py(), fMomIn.Pz()), fTime, fLength, fELoss, EIni, AIni, AZ.first, AZ.second); } -bool AtTpc::IsReactionVolume(const TString &volumeName) const +bool AtTpc::IsActiveGasVolume(const TString &volumeName) const { return volumeName.Contains("drift_volume") || volumeName.Contains("cell"); } @@ -329,27 +318,13 @@ void AtTpc::ConstructGeometry() } } -bool AtTpc::IsSensitiveVolume(const std::string &name) -{ - return name.find("drift_volume") != std::string::npos || name.find("window") != std::string::npos || - name.find("cell") != std::string::npos; -} - Bool_t AtTpc::CheckIfSensitive(std::string name) { - if (IsSensitiveVolume(name)) { - LOG(info) << " AtTPC geometry: Sensitive volume found: " << name; - 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 f323c79f6..03698d861 100644 --- a/AtDetectors/AtTpc/AtTpc.h +++ b/AtDetectors/AtTpc/AtTpc.h @@ -28,22 +28,32 @@ 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 beamTrack = false; 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 + 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; @@ -77,8 +87,6 @@ class AtTpc : public FairDetector { TString fVolName; Double32_t fELossAcc; TLorentzVector InPos; - bool fIsBeamTrack = false; - bool fStopOnReactionVolumeExit{false}; /** container for data points */ @@ -106,9 +114,6 @@ 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); @@ -119,23 +124,15 @@ class AtTpc : public FairDetector { * 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 a reaction volume. + * 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 or beam exited - * a reaction volume with fStopOnReactionVolumeExit enabled). + * Returns true when the transport should stop at this step (reaction fired). */ bool ProcessStep(const StepState &step); - /// When true, ProcessStep returns true (stop transport) when any particle exits a reaction volume. - /// Used by SimpleSim; defaults to false to preserve Geant4 behavior. - void SetStopOnReactionVolumeExit(bool val) { fStopOnReactionVolumeExit = val; } - - /// Canonical check for whether a volume name is a sensitive detector volume. - static bool IsSensitiveVolume(const std::string &name); - private: std::pair DecodePdG(Int_t PdG_Code); @@ -147,7 +144,7 @@ class AtTpc : public FairDetector { void addHit(const StepState &step); bool reactionOccursHere(); void startReactionEvent(const StepState &step); - bool IsReactionVolume(const TString &volumeName) const; + 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 index 390ed36f1..7269861bd 100644 --- a/AtDetectors/AtTpc/AtTpcTest.cxx +++ b/AtDetectors/AtTpc/AtTpcTest.cxx @@ -8,7 +8,8 @@ #include namespace { -AtTpc::StepState MakeStep(int trackID, int pdg, const char *volumeName, double eLossGeV, double totalEnergyGeV, double zCm) +AtTpc::StepState +MakeStep(int trackID, int pdg, const char *volumeName, double eLossGeV, double totalEnergyGeV, double zCm) { AtTpc::StepState step; step.trackID = trackID; @@ -16,7 +17,6 @@ AtTpc::StepState MakeStep(int trackID, int pdg, const char *volumeName, double e step.volumeName = volumeName; step.volumeID = 1; step.detCopyID = 0; - step.beamTrack = false; step.energyLoss = eLossGeV; step.trackLength = zCm; step.totalEnergy = totalEnergyGeV; @@ -49,7 +49,6 @@ TEST_F(AtTpcTest, ReactionTriggerPopulatesVertexPropagator) AtVertexPropagator::Instance()->SetRndELoss(0.5); auto step = MakeStep(0, 2212, "drift_volume", 0.0006, 0.98, 12.0); - step.beamTrack = true; step.entering = true; const bool stopTransport = detector.ProcessStep(step); @@ -70,7 +69,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.beamTrack = true; step.exiting = true; step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); @@ -81,12 +79,13 @@ TEST_F(AtTpcTest, BeamExitResetsVertexState) EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 0.0); } -TEST_F(AtTpcTest, ReactionEventTrackZeroDoesNotTriggerBeamHandoff) +TEST_F(AtTpcTest, ReactionEventTrackZeroDoesNotTriggerBeamHandoffWhenNotInBeamEvent) { AtVertexPropagator::Instance()->SetIsBeamEvent(false); AtVertexPropagator::Instance()->SetRndELoss(0.5); - auto step = MakeStep(0, 1000060160, "drift_volume", 0.0006, 15.9, 12.0); + // 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); @@ -96,12 +95,12 @@ TEST_F(AtTpcTest, ReactionEventTrackZeroDoesNotTriggerBeamHandoff) EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetEnergy(), 0.0); } -TEST_F(AtTpcTest, ReactionEventTrackZeroExitDoesNotResetVertexState) +TEST_F(AtTpcTest, SecondaryExitDoesNotResetVertexState) { - AtVertexPropagator::Instance()->SetIsBeamEvent(false); 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, 1000060160, "drift_volume", 0.0, 15.9, 20.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); @@ -112,29 +111,6 @@ TEST_F(AtTpcTest, ReactionEventTrackZeroExitDoesNotResetVertexState) EXPECT_DOUBLE_EQ(AtVertexPropagator::Instance()->GetPz(), 1.0); } -TEST_F(AtTpcTest, ExitingReactionVolumeStopsTransportWhenFlagSet) -{ - detector.SetStopOnReactionVolumeExit(true); - auto step = MakeStep(1, 2212, "drift_volume", 0.0, 0.95, 20.0); - step.exiting = true; - step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); - - const bool stopTransport = detector.ProcessStep(step); - - EXPECT_TRUE(stopTransport); -} - -TEST_F(AtTpcTest, ExitingReactionVolumeDoesNotStopByDefault) -{ - auto step = MakeStep(1, 2212, "drift_volume", 0.0, 0.95, 20.0); - step.exiting = true; - step.posOut.SetXYZT(0.0, 0.0, 25.0, 0.0); - - const bool stopTransport = detector.ProcessStep(step); - - EXPECT_FALSE(stopTransport); -} - TEST_F(AtTpcTest, NonBeamTracksUseStoredMetadata) { AtVertexPropagator::Instance()->SetTrackEnergy(1, 7.5); diff --git a/AtDigitization/AtDigiLinkDef.h b/AtDigitization/AtDigiLinkDef.h index 37e27b85d..15a1de747 100644 --- a/AtDigitization/AtDigiLinkDef.h +++ b/AtDigitization/AtDigiLinkDef.h @@ -21,9 +21,9 @@ #pragma link C++ class AtTrigger + ; #pragma link C++ class AtTriggerTask + ; #pragma link C++ class AtVectorResponse -; -#pragma link C++ class AtSimpleSimulationTask +; -#pragma link C++ class AtSimpleSimulationGeneratorTask +; -#pragma link C++ class AtSimpleSimulationReplayTask +; +#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 -!; -#pragma link C++ class AtStandaloneSimulation -!; #endif diff --git a/AtDigitization/AtSimParticleCollector.cxx b/AtDigitization/AtSimParticleCollector.cxx index c307fd5d2..fd06b364e 100644 --- a/AtDigitization/AtSimParticleCollector.cxx +++ b/AtDigitization/AtSimParticleCollector.cxx @@ -2,33 +2,61 @@ #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; } - -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) { - fParticles.push_back({ntr, pdgCode, px, py, pz, e, vx, vy, vz}); - } -} diff --git a/AtDigitization/AtSimParticleCollector.h b/AtDigitization/AtSimParticleCollector.h index 4de5f2a50..44e82f8e4 100644 --- a/AtDigitization/AtSimParticleCollector.h +++ b/AtDigitization/AtSimParticleCollector.h @@ -11,77 +11,62 @@ class TRefArray; class TParticle; /** - * @brief Particle captured from FairPrimaryGenerator::GenerateEvent(). - * - * Units follow FairRoot conventions as they come out of FairPrimaryGenerator::AddTrack(): - * - position in cm - * - momentum (px, py, pz) in GeV/c, total energy e in GeV - */ -struct AtCollectedParticle { - int trackID; - int pdgCode; - double px, py, pz; ///< Momentum in GeV/c - double e; ///< Total energy in GeV - double vx, vy, vz; ///< Vertex position in cm -}; - -/** - * @brief Minimal FairGenericStack stub that captures PushTrack() calls. + * @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 to be tracked (toBeDone == 1) are stored. + * Only primary particles marked for tracking (toBeDone == 1) are stored. * - * @note GetCurrentTrack(), PopNextTrack(), and PopPrimaryForTracking() are not implemented - * and will LOG(fatal) if called. Generators that only call PushTrack() work correctly. If a - * generator needs to inspect the stack, these stubs must be extended to return synthesized - * TParticle objects. + * @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 { - std::vector fParticles; - int fCurrentTrack{-1}; - public: AtSimParticleCollector() = default; + ~AtSimParticleCollector() override; - // ---- PushTrack: the only method we actually need ---- - virtual 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); - - const std::vector &GetParticles() const { return fParticles; } - void Clear() { fParticles.clear(); } + /// 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; - // ---- TVirtualMCStack 18-param PushTrack (delegates to the 19-param FairGenericStack version) ---- - // FairGenericStack implements this in its .cxx (invisible to Cling), so we must provide it - // in the header to satisfy the pure-virtual requirement during dictionary generation. - virtual 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) + /// 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); + PushTrack(toBeDone, parentID, pdgCode, px, py, pz, e, vx, vy, vz, time, polx, poly, polz, proc, ntr, weight, is, + -1); } - // ---- TVirtualMCStack pure-virtual stubs ---- - // These are not implemented and will LOG(fatal) if called. See class-level @note. - virtual TParticle *PopNextTrack(Int_t &itrack); - virtual TParticle *PopPrimaryForTracking(Int_t i); - virtual TParticle *GetCurrentTrack() const; - virtual void SetCurrentTrack(Int_t itrack) { fCurrentTrack = itrack; } - virtual Int_t GetNtrack() const { return static_cast(fParticles.size()); } - virtual Int_t GetNprimary() const { return static_cast(fParticles.size()); } - virtual Int_t GetCurrentTrackNumber() const { return fCurrentTrack; } - virtual Int_t GetCurrentParentTrackNumber() const { return -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; - // ---- FairGenericStack virtual stubs ---- - virtual void AddParticle(TParticle *) {} - virtual void FillTrackArray() {} - virtual void UpdateTrackIndex(TRefArray *) {} - virtual void Reset() { Clear(); } +private: + std::vector fParticles; ///< Owned TParticle pointers. + Int_t fCurrentTrack{-1}; }; #endif // ATSIMPARTICLECOLLECTOR_H diff --git a/AtDigitization/AtSimTest.cxx b/AtDigitization/AtSimTest.cxx index 4e4fd5d86..1d90f64cd 100644 --- a/AtDigitization/AtSimTest.cxx +++ b/AtDigitization/AtSimTest.cxx @@ -1,5 +1,5 @@ /** - * Unit tests for AtSimpleSimulation. + * Unit tests for AtSimTransport. * * Two physics tests: * 1. ZeroFieldStraightLine — zero E/B fields produce a collinear track along the @@ -13,14 +13,13 @@ * No external files are used; geometry and energy-loss model are built in memory. */ -#include "AtMCPoint.h" -#include "AtSimpleSimulation.h" -#include "AtSimpleSimulationGeneratorTask.h" -#include "AtStandaloneSimulation.h" - +#include "AtELossManager.h" #include "AtELossModel.h" +#include "AtMCPoint.h" #include "AtMCTrack.h" -#include "AtTpc/AtTpc.h" +#include "AtSimTransport.h" +#include "AtSimTransportGeneratorTask.h" +#include "AtSimpleSimulation.h" #include "AtVertexPropagator.h" #include @@ -31,12 +30,13 @@ #include #include -#include - #include +#include #include #include +#include "AtTpc/AtTpc.h" + // --------------------------------------------------------------------------- // Minimal energy-loss model with constant dEdx = fRate [MeV/mm]. // --------------------------------------------------------------------------- @@ -46,10 +46,7 @@ class ConstELoss : public AtTools::AtELossModel { 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 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; } @@ -59,7 +56,7 @@ class ConstELoss : public AtTools::AtELossModel { // --------------------------------------------------------------------------- // Fixture: builds an in-memory TGeoManager with a 100×100×100 cm "cave" -// containing a 50×50×50 cm "drift_volume" box. AtSimpleSimulation calls +// 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. // --------------------------------------------------------------------------- @@ -84,14 +81,14 @@ class AtSimTest : public ::testing::Test { // --------------------------------------------------------------------------- // Test helper: exposes protected members for testing without #define hacks. // --------------------------------------------------------------------------- -class TestableSimTask : public AtSimpleSimulationGeneratorTask { +class TestableSimTask : public AtSimTransportGeneratorTask { public: - using AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask; - using AtSimpleSimulationTask::fCollector; - using AtSimpleSimulationTask::fDetector; - using AtSimpleSimulationTask::fMCTrackArray; - using AtSimpleSimulationTask::FillMCTracks; - using AtSimpleSimulationTask::SubmitDetectorStep; + using AtSimTransportGeneratorTask::AtSimTransportGeneratorTask; + using AtSimTransportTask::fCollector; + using AtSimTransportTask::fDetector; + using AtSimTransportTask::FillMCTracks; + using AtSimTransportTask::fMCTrackArray; + using AtSimTransportTask::SubmitDetectorStep; EventState LoadEvent() override { return {}; } }; @@ -103,18 +100,19 @@ class TestableSimTask : public AtSimpleSimulationGeneratorTask { // --------------------------------------------------------------------------- TEST_F(AtSimTest, ZeroFieldStraightLine) { - auto engine = std::make_unique(); - engine->AddModel(1, 1, std::make_shared(1.0 /*MeV/mm*/), 1.007276); // proton mass in amu - AtStandaloneSimulation sim(std::move(engine)); + 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 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 + 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); @@ -164,16 +162,14 @@ TEST_F(AtSimTest, ZeroFieldStraightLine) // --------------------------------------------------------------------------- TEST_F(AtSimTest, MagneticFieldLarmorRadius) { - auto engine = std::make_unique(); - // Tiny energy-loss rate keeps KE almost constant (avoids infinite loop in - // straight-line path, irrelevant here since B≠0 uses AtPropagator). - engine->AddModel(1, 1, std::make_shared(0.0 /*MeV/mm — no drag*/)); - // Use explicit XYZVector construction to ensure the B field is recognised as non-zero. + 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 - AtStandaloneSimulation sim(std::move(engine)); + AtSimpleSimulation sim(std::move(engine)); - const double mass_p = 938.272; // MeV/c² - const double px0 = 100.0; // MeV/c (purely transverse) + 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); @@ -229,15 +225,16 @@ TEST_F(AtSimTest, MagneticFieldLarmorRadius) 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"; + EXPECT_LT(meanErr, larmor_mm * 0.05) << "Mean Larmor radius error " << meanErr << " mm exceeds 5% of " << larmor_mm + << " mm"; } TEST_F(AtSimTest, LegacySimulateParticleStillRejectsStartsOutsideDriftVolume) { - auto engine = std::make_unique(); - engine->AddModel(1, 1, std::make_shared(0.1)); - AtStandaloneSimulation sim(std::move(engine)); + 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; @@ -252,9 +249,10 @@ TEST_F(AtSimTest, LegacySimulateParticleStillRejectsStartsOutsideDriftVolume) TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) { - AtSimpleSimulation sim; - sim.AddModel(1, 1, std::make_shared(0.0)); - sim.SetDistanceStep(10.0); + 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; @@ -266,7 +264,7 @@ TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) bool sawCaveToDrift = false; int callbackCount = 0; - sim.TransportParticle(1, 1, pos, mom, [&](const AtSimpleSimulation::TransportStep &step) { + sim.TransportParticle(1, 1, pos, mom, [&](const AtSimTransport::TransportStep &step) { ++callbackCount; if (step.preVolumeName == "cave" && step.postVolumeName == "drift_volume") sawCaveToDrift = true; @@ -279,15 +277,16 @@ TEST_F(AtSimTest, TransportParticleInvokesCallbackAcrossVolumeBoundary) TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) { - auto sim = std::make_unique(); + 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, + 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.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(); @@ -307,7 +306,8 @@ TEST_F(AtSimTest, ReactionMCTracksKeepGeneratedTrackIDs) TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) { - auto sim = std::make_unique(); + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); TestableSimTask task(std::move(sim)); task.fMCTrackArray = new TClonesArray("AtMCTrack"); @@ -326,7 +326,8 @@ TEST_F(AtSimTest, BeamMCTracksKeepBeamAtTrackZero) TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) { - auto sim = std::make_unique(); + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); TestableSimTask task(std::move(sim)); AtTpc detector; task.fDetector = &detector; @@ -340,7 +341,7 @@ TEST_F(AtSimTest, InitialSensitivePointUsesTrackStartState) 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) - AtSimpleSimulation::TransportStep initialStep; + AtSimTransport::TransportStep initialStep; initialStep.pdg = 1000060160; initialStep.trackMass = mom.M(); initialStep.prePosition = pos; 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/AtSimpleSimulationGeneratorTask.cxx b/AtDigitization/AtSimTransportGeneratorTask.cxx similarity index 66% rename from AtDigitization/AtSimpleSimulationGeneratorTask.cxx rename to AtDigitization/AtSimTransportGeneratorTask.cxx index 1cd2a9fc2..a3a7c40cd 100644 --- a/AtDigitization/AtSimpleSimulationGeneratorTask.cxx +++ b/AtDigitization/AtSimTransportGeneratorTask.cxx @@ -1,16 +1,16 @@ -#include "AtSimpleSimulationGeneratorTask.h" +#include "AtSimTransportGeneratorTask.h" #include "AtVertexPropagator.h" #include #include -AtSimpleSimulationGeneratorTask::AtSimpleSimulationGeneratorTask(std::unique_ptr sim) - : AtSimpleSimulationTask(std::move(sim)) +AtSimTransportGeneratorTask::AtSimTransportGeneratorTask(std::unique_ptr sim) + : AtSimTransportTask(std::move(sim)) { } -InitStatus AtSimpleSimulationGeneratorTask::InitEventSource() +InitStatus AtSimTransportGeneratorTask::InitEventSource() { if (fPrimGen == nullptr) return kSUCCESS; @@ -21,7 +21,7 @@ InitStatus AtSimpleSimulationGeneratorTask::InitEventSource() return kSUCCESS; } -AtSimpleSimulationTask::EventState AtSimpleSimulationGeneratorTask::LoadEvent() +AtSimTransportTask::EventState AtSimTransportGeneratorTask::LoadEvent() { fCollector.Clear(); if (fPrimGen == nullptr) @@ -38,4 +38,4 @@ AtSimpleSimulationTask::EventState AtSimpleSimulationGeneratorTask::LoadEvent() return state; } -ClassImp(AtSimpleSimulationGeneratorTask); +ClassImp(AtSimTransportGeneratorTask); diff --git a/AtDigitization/AtSimpleSimulationGeneratorTask.h b/AtDigitization/AtSimTransportGeneratorTask.h similarity index 59% rename from AtDigitization/AtSimpleSimulationGeneratorTask.h rename to AtDigitization/AtSimTransportGeneratorTask.h index 186db936e..d5b5e7250 100644 --- a/AtDigitization/AtSimpleSimulationGeneratorTask.h +++ b/AtDigitization/AtSimTransportGeneratorTask.h @@ -1,7 +1,7 @@ -#ifndef AtSimpleSimulationGeneratorTask_h -#define AtSimpleSimulationGeneratorTask_h +#ifndef AtSimTransportGeneratorTask_h +#define AtSimTransportGeneratorTask_h -#include "AtSimpleSimulationTask.h" +#include "AtSimTransportTask.h" #include #include @@ -13,10 +13,10 @@ class TBuffer; class TClass; class TMemberInspector; -class AtSimpleSimulationGeneratorTask : public AtSimpleSimulationTask { +class AtSimTransportGeneratorTask : public AtSimTransportTask { public: - explicit AtSimpleSimulationGeneratorTask(std::unique_ptr sim); - ~AtSimpleSimulationGeneratorTask() override = default; + explicit AtSimTransportGeneratorTask(std::unique_ptr sim); + ~AtSimTransportGeneratorTask() override = default; void SetPrimaryGenerator(FairPrimaryGenerator *primGen) { fPrimGen = primGen; } void SetEventGenerator(FairPrimaryGenerator *primGen) { SetPrimaryGenerator(primGen); } @@ -28,7 +28,7 @@ class AtSimpleSimulationGeneratorTask : public AtSimpleSimulationTask { InitStatus InitEventSource() override; EventState LoadEvent() override; - ClassDefOverride(AtSimpleSimulationGeneratorTask, 1); + ClassDefOverride(AtSimTransportGeneratorTask, 1); }; #endif diff --git a/AtDigitization/AtSimpleSimulationReplayTask.cxx b/AtDigitization/AtSimTransportReplayTask.cxx similarity index 73% rename from AtDigitization/AtSimpleSimulationReplayTask.cxx rename to AtDigitization/AtSimTransportReplayTask.cxx index 79a034ec3..da36d4d99 100644 --- a/AtDigitization/AtSimpleSimulationReplayTask.cxx +++ b/AtDigitization/AtSimTransportReplayTask.cxx @@ -1,4 +1,4 @@ -#include "AtSimpleSimulationReplayTask.h" +#include "AtSimTransportReplayTask.h" #include "AtMCTrack.h" @@ -8,35 +8,35 @@ #include #include -AtSimpleSimulationReplayTask::AtSimpleSimulationReplayTask(std::unique_ptr sim) - : AtSimpleSimulationTask(std::move(sim)) +AtSimTransportReplayTask::AtSimTransportReplayTask(std::unique_ptr sim) + : AtSimTransportTask(std::move(sim)) { } -InitStatus AtSimpleSimulationReplayTask::InitEventSource() +InitStatus AtSimTransportReplayTask::InitEventSource() { if (fPrimaryTrackSourceFile.empty()) return kSUCCESS; fPrimaryTrackFile = TFile::Open(fPrimaryTrackSourceFile.c_str(), "READ"); if (fPrimaryTrackFile == nullptr || fPrimaryTrackFile->IsZombie()) { - LOG(fatal) << "AtSimpleSimulationReplayTask: cannot open primary track source " << fPrimaryTrackSourceFile; + LOG(fatal) << "AtSimTransportReplayTask: cannot open primary track source " << fPrimaryTrackSourceFile; return kFATAL; } fPrimaryTrackTree = dynamic_cast(fPrimaryTrackFile->Get("cbmsim")); if (fPrimaryTrackTree == nullptr) { - LOG(fatal) << "AtSimpleSimulationReplayTask: missing cbmsim tree in primary track source " + LOG(fatal) << "AtSimTransportReplayTask: missing cbmsim tree in primary track source " << fPrimaryTrackSourceFile; return kFATAL; } fPrimaryTrackTree->SetBranchAddress("MCTrack", &fPrimaryTrackInput); - LOG(info) << "AtSimpleSimulationReplayTask: replaying primary MC tracks from " << fPrimaryTrackSourceFile; + LOG(info) << "AtSimTransportReplayTask: replaying primary MC tracks from " << fPrimaryTrackSourceFile; return kSUCCESS; } -AtSimpleSimulationTask::EventState AtSimpleSimulationReplayTask::LoadEvent() +AtSimTransportTask::EventState AtSimTransportReplayTask::LoadEvent() { if (fPrimaryTrackTree == nullptr) return {}; @@ -54,7 +54,7 @@ AtSimpleSimulationTask::EventState AtSimpleSimulationReplayTask::LoadEvent() return state; } -void AtSimpleSimulationReplayTask::FinishEventSource() +void AtSimTransportReplayTask::FinishEventSource() { if (fPrimaryTrackFile == nullptr) return; @@ -66,7 +66,7 @@ void AtSimpleSimulationReplayTask::FinishEventSource() fPrimaryTrackInput = nullptr; } -bool AtSimpleSimulationReplayTask::LoadPrimaryTracksFromSource() +bool AtSimTransportReplayTask::LoadPrimaryTracksFromSource() { if (fPrimaryTrackTree == nullptr || fSourceEventIndex >= fPrimaryTrackTree->GetEntries()) return false; @@ -90,4 +90,4 @@ bool AtSimpleSimulationReplayTask::LoadPrimaryTracksFromSource() return true; } -ClassImp(AtSimpleSimulationReplayTask); +ClassImp(AtSimTransportReplayTask); diff --git a/AtDigitization/AtSimpleSimulationReplayTask.h b/AtDigitization/AtSimTransportReplayTask.h similarity index 63% rename from AtDigitization/AtSimpleSimulationReplayTask.h rename to AtDigitization/AtSimTransportReplayTask.h index f0c8ba98a..b0ff51246 100644 --- a/AtDigitization/AtSimpleSimulationReplayTask.h +++ b/AtDigitization/AtSimTransportReplayTask.h @@ -1,7 +1,7 @@ -#ifndef AtSimpleSimulationReplayTask_h -#define AtSimpleSimulationReplayTask_h +#ifndef AtSimTransportReplayTask_h +#define AtSimTransportReplayTask_h -#include "AtSimpleSimulationTask.h" +#include "AtSimTransportTask.h" #include @@ -13,10 +13,10 @@ class TFile; class TMemberInspector; class TTree; -class AtSimpleSimulationReplayTask : public AtSimpleSimulationTask { +class AtSimTransportReplayTask : public AtSimTransportTask { public: - explicit AtSimpleSimulationReplayTask(std::unique_ptr sim); - ~AtSimpleSimulationReplayTask() override = default; + explicit AtSimTransportReplayTask(std::unique_ptr sim); + ~AtSimTransportReplayTask() override = default; void SetPrimaryTrackSource(const std::string &fileName) { fPrimaryTrackSourceFile = fileName; } @@ -34,7 +34,7 @@ class AtSimpleSimulationReplayTask : public AtSimpleSimulationTask { private: bool LoadPrimaryTracksFromSource(); - ClassDefOverride(AtSimpleSimulationReplayTask, 1); + ClassDefOverride(AtSimTransportReplayTask, 1); }; #endif diff --git a/AtDigitization/AtSimpleSimulationTask.cxx b/AtDigitization/AtSimTransportTask.cxx similarity index 75% rename from AtDigitization/AtSimpleSimulationTask.cxx rename to AtDigitization/AtSimTransportTask.cxx index b4bc47816..f33382240 100644 --- a/AtDigitization/AtSimpleSimulationTask.cxx +++ b/AtDigitization/AtSimTransportTask.cxx @@ -1,9 +1,8 @@ -#include "AtSimpleSimulationTask.h" +#include "AtSimTransportTask.h" #include "AtDetectorList.h" #include "AtMCTrack.h" -#include "AtSimpleSimulation.h" -#include "AtTpc/AtTpc.h" +#include "AtSimTransport.h" #include #include @@ -20,8 +19,9 @@ #include #include #include -#include #include +#include +#include #include #include @@ -29,6 +29,8 @@ #include #include +#include "AtTpc/AtTpc.h" + using namespace ROOT::Math; namespace { @@ -57,9 +59,9 @@ std::pair GetZAFromPDG(int pdg) } } // namespace -AtSimpleSimulationTask::AtSimpleSimulationTask(std::unique_ptr sim) : fSimulation(std::move(sim)) {} +AtSimTransportTask::AtSimTransportTask(std::unique_ptr sim) : fSimulation(std::move(sim)) {} -InitStatus AtSimpleSimulationTask::Init() +InitStatus AtSimTransportTask::Init() { // Auto-discover detector from FairRunSim if not set manually if (fDetector == nullptr) { @@ -71,7 +73,7 @@ InitStatus AtSimpleSimulationTask::Init() auto *det = dynamic_cast(modules->At(i)); if (det != nullptr) { fDetector = det; - LOG(info) << "AtSimpleSimulationTask: auto-discovered AtTpc detector '" << det->GetName() + LOG(info) << "AtSimTransportTask: auto-discovered AtTpc detector '" << det->GetName() << "' from FairRunSim"; break; } @@ -80,12 +82,11 @@ InitStatus AtSimpleSimulationTask::Init() } } if (fDetector == nullptr) { - LOG(fatal) << "AtSimpleSimulationTask requires a sensitive detector. " + LOG(fatal) << "AtSimTransportTask requires a sensitive detector. " << "Call SetDetector(tpc) before Init(), or register an AtTpc with FairRunSim."; return kFATAL; } - LOG(info) << "AtSimpleSimulationTask: using detector-coupled transport adapter"; - fDetector->SetStopOnReactionVolumeExit(true); + LOG(info) << "AtSimTransportTask: using detector-coupled transport adapter"; if (fAutoConfigureField) ConfigureFieldFromFairRun(); @@ -98,7 +99,7 @@ InitStatus AtSimpleSimulationTask::Init() return kSUCCESS; } -void AtSimpleSimulationTask::Exec(Option_t *) +void AtSimTransportTask::Exec(Option_t *) { auto eventState = LoadEvent(); if (!eventState.hasEvent) @@ -111,13 +112,19 @@ void AtSimpleSimulationTask::Exec(Option_t *) TransportCurrentEvent(eventState.beamEvent); } -void AtSimpleSimulationTask::Finish() { FinishEventSource(); } +void AtSimTransportTask::Finish() +{ + FinishEventSource(); +} -InitStatus AtSimpleSimulationTask::InitEventSource() { return kSUCCESS; } +InitStatus AtSimTransportTask::InitEventSource() +{ + return kSUCCESS; +} -void AtSimpleSimulationTask::FinishEventSource() {} +void AtSimTransportTask::FinishEventSource() {} -void AtSimpleSimulationTask::ConfigureFieldFromFairRun() +void AtSimTransportTask::ConfigureFieldFromFairRun() { using XYZVector = ROOT::Math::XYZVector; constexpr double kKGtoTesla = 0.1; @@ -129,13 +136,13 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() auto *run = FairRun::Instance(); if (run == nullptr) { - LOG(info) << "AtSimpleSimulationTask: no FairRun instance; skipping field auto-config"; + LOG(info) << "AtSimTransportTask: no FairRun instance; skipping field auto-config"; return; } auto *field = run->GetField(); if (field == nullptr) { - LOG(info) << "AtSimpleSimulationTask: no field set on FairRun; SimpleSim fields remain at zero"; + LOG(info) << "AtSimTransportTask: no field set on FairRun; SimpleSim fields remain at zero"; return; } @@ -165,14 +172,14 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() double bz_T = bz_kG * kKGtoTesla; fSimulation->SetMagneticField(XYZVector(bx_T, by_T, bz_T)); - LOG(info) << "AtSimpleSimulationTask: auto-configured B field from FairRun: (" << bx_T << ", " << by_T << ", " << bz_T - << ") T (sampled at drift volume center)"; + 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) << "AtSimpleSimulationTask: non-constant field (type " << field->GetType() + 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 { @@ -181,8 +188,7 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() 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, + 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}; }); @@ -198,19 +204,16 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() 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}}; + 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) << "AtSimpleSimulationTask: drift volume extends beyond the constant field region. " + 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; @@ -220,11 +223,11 @@ void AtSimpleSimulationTask::ConfigureFieldFromFairRun() } } -void AtSimpleSimulationTask::RegisterMCTrackBranch() +void AtSimTransportTask::RegisterMCTrackBranch() { auto *ioMan = FairRootManager::Instance(); if (ioMan == nullptr) { - LOG(fatal) << "The IO manager was not instantiated before AtSimpleSimulationTask::Init()."; + LOG(fatal) << "The IO manager was not instantiated before AtSimTransportTask::Init()."; return; } @@ -240,33 +243,38 @@ void AtSimpleSimulationTask::RegisterMCTrackBranch() ioMan->Register("MCTrack", "Stack", fMCTrackArray, kTRUE); } -void AtSimpleSimulationTask::FillMCTracks() +void AtSimTransportTask::FillMCTracks() { if (fMCTrackArray == nullptr) return; fMCTrackArray->Clear("C"); - for (const auto &particle : fCollector.GetParticles()) { - new ((*fMCTrackArray)[particle.trackID]) AtMCTrack(particle.pdgCode, -1, particle.px, particle.py, particle.pz, - particle.vx, particle.vy, particle.vz, 0.0, 0); + int idx = 0; + for (auto *particle : fCollector.GetParticles()) { + new ((*fMCTrackArray)[idx]) AtMCTrack(particle); + ++idx; } } -void AtSimpleSimulationTask::TransportCurrentEvent(bool beamEvent) +void AtSimTransportTask::TransportCurrentEvent(bool beamEvent) { - for (const auto &particle : fCollector.GetParticles()) - TransportParticle(particle, beamEvent); + int trackID = 0; + for (auto *particle : fCollector.GetParticles()) { + TransportParticle(*particle, trackID, beamEvent); + ++trackID; + } } -void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &particle, bool beamEvent) +void AtSimTransportTask::TransportParticle(const TParticle &particle, int trackID, bool beamEvent) { - auto [Z, A] = GetZAFromPDG(particle.pdgCode); + 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.e * kGeVToMeV); + 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))) @@ -277,14 +285,14 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic // 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 && particle.trackID == 0; + 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)) { - AtSimpleSimulation::TransportStep initialStep; - initialStep.pdg = particle.pdgCode; + AtSimTransport::TransportStep initialStep; + initialStep.pdg = pdgCode; initialStep.preVolumeName = startVolName; initialStep.postVolumeName = startVolName; initialStep.trackMass = mom.M(); // MeV/c^2 @@ -292,12 +300,12 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic initialStep.postPosition = pos; initialStep.preMomentum = mom; initialStep.postMomentum = mom; - if (!SubmitDetectorStep(initialStep, particle.trackID, beamTrack, true, false)) + if (!SubmitDetectorStep(initialStep, trackID, beamTrack, true, false)) return; } fSimulation->TransportParticle( - Z, A, pos, mom, [this, trackID = particle.trackID, beamEvent](const AtSimpleSimulation::TransportStep &step) { + Z, A, pos, mom, [this, trackID, beamEvent](const AtSimTransport::TransportStep &step) { const bool preSensitive = IsSensitiveVolume(step.preVolumeName); const bool postSensitive = IsSensitiveVolume(step.postVolumeName); @@ -326,11 +334,11 @@ void AtSimpleSimulationTask::TransportParticle(const AtCollectedParticle &partic return keepTransporting; }); } catch (const std::invalid_argument &ex) { - LOG(fatal) << "AtSimpleSimulationTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); + LOG(fatal) << "AtSimTransportTask: skipping particle Z=" << Z << " A=" << A << ": " << ex.what(); } } -bool AtSimpleSimulationTask::SubmitDetectorStep(const AtSimpleSimulation::TransportStep &step, int trackID, +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). @@ -345,7 +353,6 @@ bool AtSimpleSimulationTask::SubmitDetectorStep(const AtSimpleSimulation::Transp detectorStep.volumeName = refVol.c_str(); detectorStep.volumeID = kAtTpc; detectorStep.detCopyID = 0; - detectorStep.beamTrack = beamTrack; detectorStep.entering = entering; detectorStep.exiting = exiting; detectorStep.stopping = !exiting && (step.postMomentum.E() - step.trackMass <= 1e-3); @@ -367,7 +374,7 @@ bool AtSimpleSimulationTask::SubmitDetectorStep(const AtSimpleSimulation::Transp return !stopTransport; } -XYZPoint AtSimpleSimulationTask::FindSensitiveEntry(const XYZPoint &pos, const PxPyPzEVector &mom) const +XYZPoint AtSimTransportTask::FindSensitiveEntry(const XYZPoint &pos, const PxPyPzEVector &mom) const { const auto dir = mom.Vect().Unit(); if (dir.R() == 0.0) @@ -398,9 +405,9 @@ XYZPoint AtSimpleSimulationTask::FindSensitiveEntry(const XYZPoint &pos, const P throw std::invalid_argument("Particle does not intersect a sensitive detector volume"); } -bool AtSimpleSimulationTask::IsSensitiveVolume(const std::string &volumeName) +bool AtSimTransportTask::IsSensitiveVolume(const std::string &volumeName) const { - return AtTpc::IsSensitiveVolume(volumeName); + return fDetector != nullptr && fDetector->CheckIfSensitive(volumeName); } -ClassImp(AtSimpleSimulationTask); +ClassImp(AtSimTransportTask); diff --git a/AtDigitization/AtSimpleSimulationTask.h b/AtDigitization/AtSimTransportTask.h similarity index 67% rename from AtDigitization/AtSimpleSimulationTask.h rename to AtDigitization/AtSimTransportTask.h index 034709930..3eae4d2f5 100644 --- a/AtDigitization/AtSimpleSimulationTask.h +++ b/AtDigitization/AtSimTransportTask.h @@ -1,11 +1,12 @@ -#ifndef AtSimpleSimulationTask_h -#define AtSimpleSimulationTask_h +#ifndef AtSimTransportTask_h +#define AtSimTransportTask_h #include "AtSimParticleCollector.h" -#include "AtSimpleSimulation.h" +#include "AtSimTransport.h" #include #include + #include #include @@ -16,8 +17,9 @@ class TBuffer; class TClass; class TClonesArray; class TMemberInspector; +class TParticle; -class AtSimpleSimulationTask : public FairTask { +class AtSimTransportTask : public FairTask { public: struct EventState { bool hasEvent{false}; @@ -25,8 +27,8 @@ class AtSimpleSimulationTask : public FairTask { bool transportPrimaries{true}; }; - explicit AtSimpleSimulationTask(std::unique_ptr sim); - ~AtSimpleSimulationTask() override = default; + explicit AtSimTransportTask(std::unique_ptr sim); + ~AtSimTransportTask() override = default; void SetSensitiveDetector(AtTpc *detector) { fDetector = detector; } void SetDetector(AtTpc *detector) { SetSensitiveDetector(detector); } @@ -38,10 +40,10 @@ class AtSimpleSimulationTask : public FairTask { /// Enable/disable automatic field extraction from FairRun. On by default for drop-in behavior. void SetAutoConfigureField(bool enable) { fAutoConfigureField = enable; } - AtSimpleSimulation *GetSimulation() { return fSimulation.get(); } + AtSimTransport *GetSimulation() { return fSimulation.get(); } protected: - std::unique_ptr fSimulation{nullptr}; //! + std::unique_ptr fSimulation{nullptr}; //! AtTpc *fDetector{nullptr}; //! bool fAutoConfigureField{true}; //! AtSimParticleCollector fCollector; //! @@ -55,17 +57,16 @@ class AtSimpleSimulationTask : public FairTask { void RegisterMCTrackBranch(); void FillMCTracks(); void TransportCurrentEvent(bool beamEvent); - void TransportParticle(const AtCollectedParticle &particle, 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 AtSimpleSimulation::TransportStep &step, int trackID, bool beamTrack, bool entering, + 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; - static bool IsSensitiveVolume(const std::string &volumeName); + ROOT::Math::XYZPoint FindSensitiveEntry(const ROOT::Math::XYZPoint &pos, const ROOT::Math::PxPyPzEVector &mom) const; + bool IsSensitiveVolume(const std::string &volumeName) const; - ClassDefOverride(AtSimpleSimulationTask, 1); + ClassDefOverride(AtSimTransportTask, 1); }; #endif diff --git a/AtDigitization/AtSimpleSimulation.cxx b/AtDigitization/AtSimpleSimulation.cxx index 84c1260e3..7ce0af1f1 100644 --- a/AtDigitization/AtSimpleSimulation.cxx +++ b/AtDigitization/AtSimpleSimulation.cxx @@ -1,367 +1,112 @@ - #include "AtSimpleSimulation.h" -#include "AtELossModel.h" -#include "AtELossModelFactory.h" -#include "AtKinematics.h" -#include "AtPropagator.h" +#include "AtMCPoint.h" +#include "AtSimTransport.h" +#include "AtSpaceChargeModel.h" #include +#include + +#include -#include -#include -#include -#include -#include -#include -#include -#include +#include -#include // for sqrt -#include // for invalid_argument -#include // for pair +thread_local TClonesArray AtSimpleSimulation::fMCPoints("AtMCPoint"); +thread_local int AtSimpleSimulation::fTrackID = 0; -using ModelPtr = std::shared_ptr; using XYZPoint = ROOT::Math::XYZPoint; -using XYZVector = ROOT::Math::XYZVector; using PxPyPzEVector = ROOT::Math::PxPyPzEVector; -namespace { -constexpr int kMaxMinStepCurvedSteps = 4096; -constexpr double kMinStepGuardScale = 1.01; - -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; -} -} // namespace - -AtSimpleSimulation::AtSimpleSimulation(std::string geoFile) -{ - TGeoManager *geo = TGeoManager::Import(geoFile.c_str()); - - if (gGeoManager == nullptr) - LOG(fatal) << "Failed to load geometry file " << geoFile << " " << geo; - - fGeoManager = gGeoManager; - fNavigator = nullptr; -} +AtSimpleSimulation::AtSimpleSimulation() : fEngine(std::make_unique()) {} -AtSimpleSimulation::AtSimpleSimulation() +AtSimpleSimulation::AtSimpleSimulation(std::unique_ptr engine) : fEngine(std::move(engine)) { - // Defer geometry check until first use. FairRunSim::Init() sets up - // gGeoManager, which may not have happened yet at construction time. - // GetVolume() re-syncs with gGeoManager on each call. - fGeoManager = nullptr; - fNavigator = nullptr; + if (!fEngine) + fEngine = std::make_unique(); } -AtSimpleSimulation::AtSimpleSimulation(std::shared_ptr factory) : AtSimpleSimulation() +AtSimpleSimulation::AtSimpleSimulation(const std::string &geoFile) : fEngine(std::make_unique(geoFile)) { - fModelFactory = std::move(factory); } -AtSimpleSimulation::AtSimpleSimulation(std::string geoFile, std::shared_ptr factory) - : AtSimpleSimulation(std::move(geoFile)) +AtSimpleSimulation::AtSimpleSimulation(const std::string &geoFile, std::shared_ptr manager) + : fEngine(std::make_unique(geoFile, std::move(manager))) { - fModelFactory = std::move(factory); } -bool AtSimpleSimulation::ParticleID::operator<(const ParticleID &other) const +AtSimpleSimulation::AtSimpleSimulation(std::shared_ptr manager) + : fEngine(std::make_unique(std::move(manager))) { - if (A < other.A) { - return true; - } else if (A > other.A) { - return false; - } - return Z < other.Z; } -TGeoVolume *AtSimpleSimulation::GetVolume(const XYZPoint &pos) +void AtSimpleSimulation::NewEvent() { - auto pointCm = pos / 10.; // Convert from mm to cm - - std::lock_guard lock(fGeoMutex); - - if (gGeoManager == nullptr) { - return nullptr; - } - - // Re-sync with gGeoManager if it changed (e.g. FairRunSim::Init loaded geometry). - if (fGeoManager != gGeoManager || fNavigator == nullptr) { - // The old navigator (if any) belongs to the old TGeoManager, which owns and - // will delete it when the manager is destroyed. We abandon it intentionally. - fNavigator = nullptr; - fGeoManager = gGeoManager; - fNavigator = fGeoManager->AddNavigator(); - } - - TGeoNode *node = fNavigator->FindNode(pointCm.X(), pointCm.Y(), pointCm.Z()); - if (node == nullptr) { - return nullptr; - } - return node->GetVolume(); + fMCPoints.Clear(); + fTrackID = 0; } -bool AtSimpleSimulation::IsInVolume(const std::string &volName, const XYZPoint &point) +void AtSimpleSimulation::RegisterBranch(std::string branchName, bool pers) { - TGeoVolume *volume = GetVolume(point); - if (volume == nullptr || volName != std::string(volume->GetName())) { - return false; - } - return true; -} - -std::string AtSimpleSimulation::GetVolumeName(const XYZPoint &point) -{ - TGeoVolume *volume = GetVolume(point); - if (volume == nullptr) { - return ""; - } - return volume->GetName(); -} - -void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model) -{ - AddModel(Z, A, model, static_cast(A)); -} - -void AtSimpleSimulation::AddModel(int Z, int A, ModelPtr model, double massAmu) -{ - static constexpr double kEperAMU = 931.494; // MeV/c² per amu - static constexpr double kEcharge = 1.602176634e-19; // Coulombs - - ParticleID id = {.A = A, .Z = Z}; - fModels[id] = {model, Z * kEcharge, massAmu * kEperAMU}; -} - -std::pair -AtSimpleSimulation::TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - StepCallback callback) -{ - auto modelIt = fModels.find({A, Z}); - if (modelIt == fModels.end() && fModelFactory) { - TryAutoCreateModel(Z, A, iniPos); - modelIt = fModels.find({A, Z}); + auto ioMan = FairRootManager::Instance(); + if (ioMan == nullptr) { + LOG(fatal) << "The IO manager was not instantiated before attempting to simulate an event."; + return; } - if (modelIt == fModels.end()) - throw std::invalid_argument("Missing energy loss model for Z:" + std::to_string(Z) + " A:" + std::to_string(A)); - if (GetVolume(iniPos) == nullptr) - throw std::invalid_argument("Position of particle is outside the loaded geometry"); - return PropagateParticle(modelIt->second, GetPDGFromZA(Z, A), iniPos, iniMom, callback); + ioMan->Register(branchName.c_str(), "AtTPC", &fMCPoints, pers); } -// ParticleInfo is taken by value (not const ref) so the transport loop owns its copy of the -// model shared_ptr and mass, independent of any external map modifications during transport. std::pair -AtSimpleSimulation::PropagateParticle(ParticleInfo info, int pdg, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - const StepCallback &callback) +AtSimpleSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + std::function func) { - // ----------------------------------------------------------------------- - // Curved-track path: use AtPropagator when B field is non-zero or a - // field function is provided. E-field alone does not trigger RK4 because - // it is negligible for MeV-scale ion transport. - // ----------------------------------------------------------------------- - if (fBField.Mag2() != 0 || fFieldFunc != nullptr) { - AtTools::AtPropagator prop(info.charge, info.mass, info.model.get()); - - // Initialize propagator fields - 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 = fMaxPropStep; - stepper.fMaxStep = fMaxPropStep; - const double minAcceptedStepMm = stepper.fMinStep * 1e3 * kMinStepGuardScale; - double length = 0; - int numSteps = 0; - int minStepSteps = 0; - - TGeoVolume *curVol = nullptr; - while ((curVol = GetVolume(prop.GetPosition())) != nullptr) { - if (++numSteps > fMaxTransportSteps) { - LOG(warning) << "Aborting curved SimpleSim track after " << numSteps - << " steps without leaving the geometry"; - break; - } - - double KE = AtTools::Kinematics::KE(prop.GetMomentum(), info.mass); - if (KE <= fStopTol) - break; - - // For non-uniform fields, re-query the field at the current position before each RK4 step. - // Within a single step, the field is treated as uniform (piecewise-constant approximation). - 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 * kMinStepGuardScale) { - if (++minStepSteps > kMaxMinStepCurvedSteps) { - 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) { - TransportStep step; - step.pdg = pdg; - step.preVolumeName = preVolumeName; - step.postVolumeName = GetVolumeName(posAfter); - step.energyLoss = eLoss; - step.length = length; - step.trackMass = info.mass; - step.prePosition = posBefore; - step.postPosition = posAfter; - step.preMomentum = momBefore; - step.postMomentum = momAfter; - if (!callback(step)) - break; - } - } - - return {prop.GetPosition(), AtTools::Kinematics::Get4Vector(prop.GetMomentum(), info.mass)}; - } - - // ----------------------------------------------------------------------- - // Straight-line fast path (zero field) - // KE and momentum are computed using info.mass throughout (not the 4-vector's invariant - // mass) to stay consistent with the curved path and the energy loss model's mass. - // ----------------------------------------------------------------------- - auto &model = info.model; - auto pos = iniPos; - auto mom = iniMom; - double length = 0; - int numSteps = 0; - - TGeoVolume *curVol = nullptr; - while ((curVol = GetVolume(pos)) != nullptr) { - if (++numSteps > fMaxTransportSteps) { - LOG(warning) << "Aborting straight-line SimpleSim track after " << numSteps - << " steps without leaving the geometry"; - break; - } - - 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 = model->GetEnergyLoss(KE, fDistStep); - double newKE = KE - eLoss; - if (newKE <= 0) - break; - double E = newKE + info.mass; - double p = sqrt(E * E - info.mass * info.mass); - mom.SetPxPyPzE(dir.X() * p, dir.Y() * p, dir.Z() * p, E); - pos += dir * fDistStep; - length += fDistStep; - - if (callback) { - TransportStep step; - step.pdg = pdg; - step.preVolumeName = preVolumeName; - step.postVolumeName = GetVolumeName(pos); - step.energyLoss = eLoss; - step.length = length; - step.trackMass = info.mass; - step.prePosition = posBefore; - step.postPosition = pos; - step.preMomentum = momBefore; - step.postMomentum = mom; - if (!callback(step)) - break; - } - } - - 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::TryAutoCreateModel(int Z, int A, const XYZPoint &pos) +void AtSimpleSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length) { - if (!fModelFactory) - return; - - TGeoVolume *volume = GetVolume(pos); - if (volume == nullptr) { - LOG(warning) << "TryAutoCreateModel: position " << pos << " is outside geometry; cannot determine material"; - return; - } - - TGeoMedium *medium = volume->GetMedium(); - if (medium == nullptr) { - LOG(warning) << "TryAutoCreateModel: volume " << volume->GetName() << " has no medium"; - return; - } - - TGeoMaterial *material = medium->GetMaterial(); - if (material == nullptr) { - LOG(warning) << "TryAutoCreateModel: medium " << medium->GetName() << " has no material"; - return; - } - - // Look up mass in amu from PDG database for precision; fall back to A - double massAmu = static_cast(A); - int pdgCode = GetPDGFromZA(Z, A); - TParticlePDG *particle = TDatabasePDG::Instance()->GetParticle(pdgCode); - if (particle != nullptr) - massAmu = particle->Mass() / 0.931494; // GeV/c² -> amu - - auto model = fModelFactory->CreateModel(Z, A, massAmu, material); - if (model) { - AddModel(Z, A, model, massAmu); - LOG(info) << "Auto-created energy loss model for Z=" << Z << " A=" << A << " in " << material->GetName(); - } else { - LOG(warning) << "Factory failed to create energy loss model for Z=" << Z << " A=" << A << " in " - << material->GetName(); - } + LOG(debug) << "Adding a hit at element " << fMCPoints.GetEntriesFast() << " in TClonesArray."; + + auto *mcPoint = dynamic_cast(fMCPoints.ConstructedAt(fMCPoints.GetEntriesFast(), "C")); + + mcPoint->SetTrackID(fTrackID); + mcPoint->SetLength(length / 10.); // Convert to cm + mcPoint->SetEnergyLoss(ELoss / 1000.); // Convert to GeV + mcPoint->SetVolName(fVolumeName.c_str()); + + if (fSCModel) { + // In the simulation z = 0 is the window and z=1000 is the pad plane. + // In the data analysis that is flipped, so we must adjust the z value, apply SC and move back + auto posExpCoord = pos; + posExpCoord.SetZ(1000 - pos.Z()); + auto corrExpCoord = fSCModel->ApplySpaceCharge(posExpCoord); + corrExpCoord.SetZ(1000 + corrExpCoord.Z()); + mcPoint->SetPosition(corrExpCoord / 10.); + } else + mcPoint->SetPosition(pos / 10.); // Convert to cm + mcPoint->SetMomentum(mom.Vect() / 1000.); // Convert to GeV/c } diff --git a/AtDigitization/AtSimpleSimulation.h b/AtDigitization/AtSimpleSimulation.h index e44b77567..9907fd3fc 100644 --- a/AtDigitization/AtSimpleSimulation.h +++ b/AtDigitization/AtSimpleSimulation.h @@ -1,169 +1,109 @@ #ifndef AT_SIMPLE_SIMULATION_H #define AT_SIMPLE_SIMULATION_H -#include -#include // for XYZPoint -#include -#include // for XYZVector -#include -#include // for PxPyPzEVector - -#include // for function -#include +#include "AtMCPoint.h" +#include "AtSimTransport.h" + +#include +#include +#include +#include + +#include #include -#include -#include // for string -#include // for pair +#include +#include + namespace AtTools { +class AtELossManager; class AtELossModel; -class AtELossModelFactory; } // namespace AtTools -class TGeoVolume; -class TGeoManager; -class TGeoNavigator; +class AtSpaceChargeModel; /** - * Transport engine for simulating particles 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. * - * When B fields are 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. + * 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. * - * This class handles only transport. For standalone hit recording, see AtStandaloneSimulation. - * For FairRoot pipeline integration, see AtSimpleSimulationTask. + * 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; - }; - - struct ParticleInfo { - std::shared_ptr model; - double charge; ///< Particle charge in Coulombs - double mass; ///< Particle mass in MeV/c² - }; - 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; - struct TransportStep { - int trackID = -1; - int pdg = 0; - std::string preVolumeName; - std::string postVolumeName; - double energyLoss = 0.0; // MeV - double length = 0.0; // mm - double trackMass = 0.0; // MeV/c^2 - XYZPoint prePosition; - XYZPoint postPosition; - PxPyPzEVector preMomentum; - PxPyPzEVector postMomentum; - }; - - using StepCallback = std::function; - using FieldFunc = std::function(const XYZPoint &)>; - // ---- Construction ---- - AtSimpleSimulation(std::string geoFile); AtSimpleSimulation(); - AtSimpleSimulation(std::shared_ptr factory); - AtSimpleSimulation(std::string geoFile, std::shared_ptr factory); - AtSimpleSimulation(const AtSimpleSimulation &other) = delete; // Implicitly 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; - // ---- Model management ---- + // ---- Transport-engine access and thin forwarders ---- - /** - * Register an energy loss model for a particle species. Charge is derived as Z*e and - * mass as A * 931.494 MeV/c². Use the overload with massAmu for higher accuracy. - */ - void AddModel(int Z, int A, ModelPtr model); + AtSimTransport *GetEngine() { return fEngine.get(); } - /** - * Register an energy loss model with an explicit nuclear mass (in amu). - */ - void AddModel(int Z, int A, ModelPtr model, double massAmu); + void AddModel(int Z, int A, ModelPtr model) { fEngine->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)); } - /** - * Set a model factory for automatic energy loss model creation. - * When set, if a particle species (Z, A) is encountered without a registered model, - * the factory will be used to create one from the geometry material at the particle's position. - */ - void SetModelFactory(std::shared_ptr factory) { fModelFactory = std::move(factory); } + void SetManager(std::shared_ptr manager) { fEngine->SetManager(std::move(manager)); } + AtTools::AtELossManager *GetManager() { return fEngine->GetManager(); } - // ---- Field and step configuration ---- + 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); } - void SetDistanceStep(double step) { fDistStep = step; } ///< Step size in mm (straight-line path) - void SetElectricField(XYZVector eField) { fEField = eField; } ///< Electric field in V/m - void SetMagneticField(XYZVector bField) { fBField = bField; } ///< Magnetic field in T - /// Maximum step size (m) for the RK4 adaptive stepper in curved-track mode (default: 1e-3 m = 1 mm). - void SetMaxPropagationStep(double stepM) { fMaxPropStep = stepM; } - /// Kinetic energy (MeV) below which the particle is considered stopped. Applies to both paths. - void SetStopTolerance(double stopTolMeV) { fStopTol = stopTolMeV; } - /// Maximum transport steps before aborting. Applies to both curved and straight-line paths. - void SetMaxTransportSteps(int maxSteps) { fMaxTransportSteps = maxSteps; } + // ---- Hit-recording configuration ---- - /** - * Set a position-dependent field function. When set, the field is queried at each - * transport step instead of using the uniform field vectors. The function takes a - * position in mm and returns (E-field in V/m, B-field in T). - */ - void SetFieldFunction(FieldFunc func) { fFieldFunc = std::move(func); } + void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = std::move(model); } + SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } + void SetStandaloneVolumeName(const std::string &name) { fVolumeName = name; } + + // ---- Event lifecycle ---- - // ---- Transport API ---- + void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); + void NewEvent(); /** - * Transport a particle through the loaded geometry without writing detector hits. - * Steps are delivered via callback; hit recording is the caller's responsibility. + * Simulate a particle within the configured standalone volume. + * Hits are recorded to the thread-local TClonesArray. */ - std::pair TransportParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - StepCallback callback); - - // ---- Geometry queries ---- + std::pair SimulateParticle( + int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, + std::function func = [](XYZPoint, PxPyPzEVector) { return true; }); - bool IsInsideGeometry(const XYZPoint &point) { return GetVolume(point) != nullptr; } - std::string GetVolumeNameAt(const XYZPoint &point) { return GetVolumeName(point); } + AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } + int GetNumPoints() { return fMCPoints.GetEntries(); } + TClonesArray &GetPointsArray() { return fMCPoints; } -protected: - std::map fModels; - std::shared_ptr fModelFactory{nullptr}; - double fDistStep{1.}; // Distance step in mm for straight-line propagation - std::mutex fGeoMutex; - TGeoManager *fGeoManager{nullptr}; - TGeoNavigator *fNavigator{nullptr}; - - XYZVector fEField{0, 0, 0}; ///< Electric field in V/m (used by AtPropagator) - XYZVector fBField{0, 0, 0}; ///< Magnetic field in T (used by AtPropagator) - double fMaxPropStep{1e-3}; ///< Max step size in m for the adaptive stepper (default 1 mm) - double fStopTol{0.1}; ///< KE stop tolerance in MeV; shared by both transport paths - int fMaxTransportSteps{200000}; ///< Max steps before aborting; shared by both transport paths - FieldFunc fFieldFunc{nullptr}; ///< Optional position-dependent field function - - bool IsInVolume(const std::string &volName, const XYZPoint &point); - std::string GetVolumeName(const XYZPoint &point); - - /** - * Core transport loop. Propagates a particle through the geometry, invoking callback at each step. - * Continues while the particle is inside the geometry (GetVolume != nullptr) and KE > threshold. - * The callback controls early stopping by returning false. - * Selects curved-track (RK4) or straight-line path based on field settings. - */ - std::pair PropagateParticle(ParticleInfo info, int pdg, const XYZPoint &iniPos, - const PxPyPzEVector &iniMom, const StepCallback &callback); +private: + std::unique_ptr fEngine; + SpaceChargeModel fSCModel{nullptr}; + std::string fVolumeName{"drift_volume"}; - TGeoVolume *GetVolume(const XYZPoint &pos); + static thread_local int fTrackID; + static thread_local TClonesArray fMCPoints; - /// Attempt to auto-create an energy loss model using fModelFactory and the geometry material at pos. - void TryAutoCreateModel(int Z, int A, const XYZPoint &pos); + void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); }; #endif // AT_SIMPLE_SIMULATION_H diff --git a/AtDigitization/AtStandaloneSimulation.cxx b/AtDigitization/AtStandaloneSimulation.cxx deleted file mode 100644 index fbba5ffad..000000000 --- a/AtDigitization/AtStandaloneSimulation.cxx +++ /dev/null @@ -1,95 +0,0 @@ -#include "AtStandaloneSimulation.h" - -#include "AtMCPoint.h" -#include "AtSimpleSimulation.h" -#include "AtSpaceChargeModel.h" - -#include -#include - -#include - -#include - -thread_local TClonesArray AtStandaloneSimulation::fMCPoints("AtMCPoint"); -thread_local int AtStandaloneSimulation::fTrackID = 0; - -using XYZPoint = ROOT::Math::XYZPoint; -using PxPyPzEVector = ROOT::Math::PxPyPzEVector; - -AtStandaloneSimulation::AtStandaloneSimulation(std::unique_ptr engine) - : fEngine(std::move(engine)) -{ -} - -void AtStandaloneSimulation::NewEvent() -{ - fMCPoints.Clear(); - fTrackID = 0; -} - -void AtStandaloneSimulation::RegisterBranch(std::string branchName, bool pers) -{ - auto ioMan = FairRootManager::Instance(); - if (ioMan == nullptr) { - LOG(fatal) << "The IO manager was not instantiated before attempting to simulate an event."; - return; - } - - ioMan->Register(branchName.c_str(), "AtTPC", &fMCPoints, pers); -} - -std::pair -AtStandaloneSimulation::SimulateParticle(int Z, int A, const XYZPoint &iniPos, const PxPyPzEVector &iniMom, - std::function func) -{ - 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 AtSimpleSimulation::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 AtStandaloneSimulation::AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length) -{ - LOG(debug) << "Adding a hit at element " << fMCPoints.GetEntriesFast() << " in TClonesArray."; - - auto *mcPoint = dynamic_cast(fMCPoints.ConstructedAt(fMCPoints.GetEntriesFast(), "C")); - - mcPoint->SetTrackID(fTrackID); - mcPoint->SetLength(length / 10.); // Convert to cm - mcPoint->SetEnergyLoss(ELoss / 1000.); // Convert to GeV - mcPoint->SetVolName(fVolumeName.c_str()); - - if (fSCModel) { - // In the simulation z = 0 is the window and z=1000 is the pad plane. - // In the data analysis that is flipped, so we must adjust the z value, apply SC and move back - auto posExpCoord = pos; - posExpCoord.SetZ(1000 - pos.Z()); - auto corrExpCoord = fSCModel->ApplySpaceCharge(posExpCoord); - corrExpCoord.SetZ(1000 + corrExpCoord.Z()); - mcPoint->SetPosition(corrExpCoord / 10.); - } else - mcPoint->SetPosition(pos / 10.); // Convert to cm - mcPoint->SetMomentum(mom.Vect() / 1000.); // Convert to GeV/c -} diff --git a/AtDigitization/AtStandaloneSimulation.h b/AtDigitization/AtStandaloneSimulation.h deleted file mode 100644 index 7011f3611..000000000 --- a/AtDigitization/AtStandaloneSimulation.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifndef AT_STANDALONE_SIMULATION_H -#define AT_STANDALONE_SIMULATION_H - -#include "AtMCPoint.h" -#include "AtSimpleSimulation.h" - -#include -#include -#include - -#include -#include -#include -#include - -class AtSpaceChargeModel; - -/** - * Standalone simulation wrapper that owns an AtSimpleSimulation transport engine - * and adds hit recording to a thread-local TClonesArray. - * - * Used by AtMCFitter, AtMCFission, and similar callers that manage their own - * event loop rather than running inside the FairRoot simulation pipeline. - */ -class AtStandaloneSimulation { -public: - using SpaceChargeModel = std::shared_ptr; - using XYZPoint = ROOT::Math::XYZPoint; - using PxPyPzEVector = ROOT::Math::PxPyPzEVector; - - explicit AtStandaloneSimulation(std::unique_ptr engine); - ~AtStandaloneSimulation() = default; - - /// Access the underlying transport engine for configuration (models, fields, etc.) - AtSimpleSimulation *GetEngine() { return fEngine.get(); } - - void SetSpaceChargeModel(SpaceChargeModel model) { fSCModel = std::move(model); } - SpaceChargeModel GetSpaceChargeModel() { return fSCModel; } - void SetStandaloneVolumeName(const std::string &name) { fVolumeName = name; } - - void RegisterBranch(std::string branchName = "AtTpcPoint", bool pers = true); - void NewEvent(); - - /** - * 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, PxPyPzEVector) { return true; }); - - AtMCPoint &GetMcPoint(int i) { return dynamic_cast(*fMCPoints.At(i)); } - int GetNumPoints() { return fMCPoints.GetEntries(); } - TClonesArray &GetPointsArray() { return fMCPoints; } - -private: - std::unique_ptr fEngine; - SpaceChargeModel fSCModel{nullptr}; - std::string fVolumeName{"drift_volume"}; - - static thread_local int fTrackID; - static thread_local TClonesArray fMCPoints; - - void AddHit(double ELoss, const XYZPoint &pos, const PxPyPzEVector &mom, double length); -}; - -#endif // AT_STANDALONE_SIMULATION_H diff --git a/AtDigitization/CMakeLists.txt b/AtDigitization/CMakeLists.txt index 3f7e38b1e..ecaaefdd0 100644 --- a/AtDigitization/CMakeLists.txt +++ b/AtDigitization/CMakeLists.txt @@ -36,11 +36,11 @@ AtTrigger.cxx AtTriggerTask.cxx AtVectorResponse.cxx +AtSimTransport.cxx AtSimpleSimulation.cxx -AtStandaloneSimulation.cxx -AtSimpleSimulationTask.cxx -AtSimpleSimulationGeneratorTask.cxx -AtSimpleSimulationReplayTask.cxx +AtSimTransportTask.cxx +AtSimTransportGeneratorTask.cxx +AtSimTransportReplayTask.cxx AtSimParticleCollector.cxx ) diff --git a/AtGenerators/AtTPC2Body.cxx b/AtGenerators/AtTPC2Body.cxx index 48e6b0cb4..841b0be89 100644 --- a/AtGenerators/AtTPC2Body.cxx +++ b/AtGenerators/AtTPC2Body.cxx @@ -335,22 +335,14 @@ Bool_t AtTPC2Body::GenerateReaction(FairPrimaryGenerator *primGen) "< 0.0 && BeamPos.Perp() / BeamPos.Mag() < 1e-3) { - beamTheta = 0.0; - beamPhi = 0.0; - } - LOG(debug) << " Beam Theta (Mom) : " << beamTheta * 180.0 / TMath::Pi(); - LOG(debug) << " Beam Phi (Mom) : " << beamPhi * 180.0 / TMath::Pi(); + // TVector3 BeamPos(1.0,1.0,0.0); + LOG(debug) << " Beam Theta (Mom) : " << BeamPos.Theta() * 180.0 / TMath::Pi(); + LOG(debug) << " Beam Phi (Mom) : " << BeamPos.Phi() * 180.0 / TMath::Pi(); Double_t thetaLab1, phiLab1, thetaLab2, phiLab2; auto EulerTransformer = std::make_unique(); - EulerTransformer->SetBeamDirectionAtVertexTheta(beamTheta); - EulerTransformer->SetBeamDirectionAtVertexPhi(beamPhi); + EulerTransformer->SetBeamDirectionAtVertexTheta(BeamPos.Theta()); + EulerTransformer->SetBeamDirectionAtVertexPhi(BeamPos.Phi()); EulerTransformer->SetThetaInBeamSystem(Ang.at(0)); EulerTransformer->SetPhiInBeamSystem(phiBeam1); diff --git a/AtReconstruction/AtFitter/AtMCFission.cxx b/AtReconstruction/AtFitter/AtMCFission.cxx index 77ad1fa14..f978d09ff 100644 --- a/AtReconstruction/AtFitter/AtMCFission.cxx +++ b/AtReconstruction/AtFitter/AtMCFission.cxx @@ -14,7 +14,7 @@ #include "AtPatternY.h" // for AtPatternY, AtPatternY::XYZVector #include "AtPulse.h" #include "AtRadialChargeModel.h" -#include "AtStandaloneSimulation.h" +#include "AtSimpleSimulation.h" #include "AtStudentDistribution.h" #include "AtUniformDistribution.h" #include "AtVectorUtil.h" diff --git a/AtReconstruction/AtFitter/AtMCFitter.cxx b/AtReconstruction/AtFitter/AtMCFitter.cxx index 6a596546e..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 "AtStandaloneSimulation.h" +#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 60324678a..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 AtStandaloneSimulation; +class AtSimpleSimulation; class AtDigiPar; class AtPSA; @@ -31,7 +31,7 @@ class AtParameterDistribution; class AtMCFitter { protected: using ParamPtr = std::shared_ptr; - using SimPtr = std::shared_ptr; + using SimPtr = std::shared_ptr; using ClusterPtr = std::shared_ptr; using PulsePtr = std::shared_ptr; 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/AtTools/AtELossFactoryBetheBloch.cxx b/AtTools/AtELossFactoryBetheBloch.cxx deleted file mode 100644 index f95168f5f..000000000 --- a/AtTools/AtELossFactoryBetheBloch.cxx +++ /dev/null @@ -1,79 +0,0 @@ -#include "AtELossFactoryBetheBloch.h" - -#include "AtELossBetheBloch.h" - -#include - -#include - -#include - -namespace AtTools { - -namespace { -constexpr double kAmuToMeV = 931.494; // MeV/c² per amu -} // namespace - -std::shared_ptr -AtELossFactoryBetheBloch::CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) -{ - if (material == nullptr) { - LOG(error) << "AtELossFactoryBetheBloch::CreateModel: null material"; - return nullptr; - } - - double density = material->GetDensity(); // g/cm³ - double projMass = projMassAmu * kAmuToMeV; - - const auto *mixture = dynamic_cast(material); - if (mixture == nullptr) { - // Pure material - int matZ = static_cast(std::round(material->GetZ())); - int matA = static_cast(std::round(material->GetA())); - double I_eV = 13.5 * matZ; // Bloch approximation - - auto model = std::make_shared(projZ, projMass, matZ, matA, density, I_eV); - LOG(info) << "AtELossFactoryBetheBloch: created model for Z=" << projZ << " A=" << projA << " in " - << material->GetName() << " (pure Z=" << matZ << ", density=" << density << " g/cm³)"; - return model; - } - - // Mixture: compute effective Z and A via electron-density weighting - // = sum(w_i * Z_i / A_i) - // = sum(w_i * Z_i / A_i) / sum(w_i / A_i) - // = sum(w_i) / sum(w_i / A_i) = 1 / sum(w_i / A_i) [since sum(w_i)=1] - 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) << "AtELossFactoryBetheBloch::CreateModel: invalid mixture composition"; - return nullptr; - } - - int effZ = static_cast(std::round(sumWZoverA / sumWoverA)); - int effA = static_cast(std::round(1.0 / sumWoverA)); - double I_eV = EffectiveMeanIonization(material); - - // Ensure effective values are at least 1 - effZ = std::max(effZ, 1); - effA = std::max(effA, 1); - - auto model = std::make_shared(projZ, projMass, effZ, effA, density, I_eV); - LOG(info) << "AtELossFactoryBetheBloch: created model for Z=" << projZ << " A=" << projA << " 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/AtELossFactoryBetheBloch.h b/AtTools/AtELossFactoryBetheBloch.h deleted file mode 100644 index 75ab4bee0..000000000 --- a/AtTools/AtELossFactoryBetheBloch.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifndef ATELOSSFACTORYBETHEBLOCH_H -#define ATELOSSFACTORYBETHEBLOCH_H - -#include "AtELossModelFactory.h" - -namespace AtTools { - -/** - * Factory that creates AtELossBetheBloch models from ROOT geometry materials. - * - * For pure materials, uses Z/A/density directly. - * For mixtures, computes effective Z/A via electron-density weighting and - * effective mean ionization energy via Bragg's additivity rule. - * - * This factory is essentially stateless -- all information comes from the - * projectile and material arguments to CreateModel(). - */ -class AtELossFactoryBetheBloch : public AtELossModelFactory { -public: - std::shared_ptr - CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) override; -}; - -} // namespace AtTools - -#endif // ATELOSSFACTORYBETHEBLOCH_H diff --git a/AtTools/AtELossFactoryCATIMA.cxx b/AtTools/AtELossFactoryCATIMA.cxx deleted file mode 100644 index e4a78c487..000000000 --- a/AtTools/AtELossFactoryCATIMA.cxx +++ /dev/null @@ -1,36 +0,0 @@ -#include "AtELossFactoryCATIMA.h" - -#include "AtELossCATIMA.h" - -#include - -#include - -namespace AtTools { - -std::shared_ptr -AtELossFactoryCATIMA::CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) -{ - if (material == nullptr) { - LOG(error) << "AtELossFactoryCATIMA::CreateModel: null material"; - return nullptr; - } - - double density = material->GetDensity(); // g/cm³ - auto composition = ExtractComposition(material); - - if (composition.empty()) { - LOG(error) << "AtELossFactoryCATIMA::CreateModel: could not extract composition from " << material->GetName(); - return nullptr; - } - - auto model = std::make_shared(density, composition); - model->SetProjectile(projA, projZ, projMassAmu); - model->SetConfig(fConfig); - - LOG(info) << "AtELossFactoryCATIMA: created model for Z=" << projZ << " A=" << projA << " in " - << material->GetName() << " (density=" << density << " g/cm³, " << composition.size() << " elements)"; - return model; -} - -} // namespace AtTools diff --git a/AtTools/AtELossFactoryCATIMA.h b/AtTools/AtELossFactoryCATIMA.h deleted file mode 100644 index 65fcef83e..000000000 --- a/AtTools/AtELossFactoryCATIMA.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifndef ATELOSSFACTORYCATIMA_H -#define ATELOSSFACTORYCATIMA_H - -#include "AtELossModelFactory.h" - -#include - -namespace AtTools { - -/** - * Factory that creates AtELossCATIMA models from ROOT geometry materials. - * - * Stores a catima::Config that is applied to every model it creates. This allows - * the user to configure CATIMA options (z_effective model, calculation method, etc.) - * once, and have them consistently applied to all auto-created models. - */ -class AtELossFactoryCATIMA : public AtELossModelFactory { - catima::Config fConfig{catima::default_config}; - -public: - void SetConfig(catima::Config cfg) { fConfig = cfg; } - catima::Config GetConfig() const { return fConfig; } - - std::shared_ptr - CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) override; -}; - -} // namespace AtTools - -#endif // ATELOSSFACTORYCATIMA_H 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/AtELossModelFactory.cxx b/AtTools/AtELossModelFactory.cxx deleted file mode 100644 index ed591f979..000000000 --- a/AtTools/AtELossModelFactory.cxx +++ /dev/null @@ -1,123 +0,0 @@ -#include "AtELossModelFactory.h" - -#include - -#include - -#include -#include -#include - -namespace AtTools { - -std::vector> AtELossModelFactory::ExtractComposition(const TGeoMaterial *material) -{ - if (material == nullptr) { - LOG(error) << "AtELossModelFactory::ExtractComposition: null material"; - return {}; - } - - const auto *mixture = dynamic_cast(material); - if (mixture == nullptr) { - // Pure material: single element - int Z = static_cast(std::round(material->GetZ())); - int A = static_cast(std::round(material->GetA())); - return {{A, Z, 1}}; - } - - int nElem = mixture->GetNelements(); - if (nElem <= 0) - return {}; - - 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]; - } - - auto stoich = WeightFractionsToStoichiometry(weights, masses); - - 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 result; -} - -std::vector -AtELossModelFactory::WeightFractionsToStoichiometry(const std::vector &weights, - const std::vector &atomicMasses) -{ - if (weights.size() != atomicMasses.size() || weights.empty()) - return {}; - - // Compute molar ratios: n_i = w_i / A_i - std::vector molar(weights.size()); - for (size_t i = 0; i < weights.size(); ++i) { - if (atomicMasses[i] <= 0) { - LOG(error) << "AtELossModelFactory::WeightFractionsToStoichiometry: non-positive atomic mass"; - return {}; - } - molar[i] = weights[i] / atomicMasses[i]; - } - - // Normalize by smallest non-zero molar ratio - 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; - }); - - if (minMolar <= 0) - return std::vector(weights.size(), 1); - - std::vector 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 AtELossModelFactory::EffectiveMeanIonization(const TGeoMaterial *material) -{ - if (material == nullptr) - return 0; - - const auto *mixture = dynamic_cast(material); - if (mixture == nullptr) { - // Pure material: Bloch approximation - int Z = static_cast(std::round(material->GetZ())); - return 13.5 * Z; // eV - } - - // Bragg additivity: ln(I_eff) = sum(f_i * Z_i/A_i * ln(I_i)) / sum(f_i * Z_i/A_i) - int nElem = mixture->GetNelements(); - double numerator = 0; - double denominator = 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 || Z <= 0) - continue; - - double I_i = 13.5 * Z; // Bloch approximation per element, eV - double frac = w * Z / A; - numerator += frac * std::log(I_i); - denominator += frac; - } - - if (denominator <= 0) - return 13.5; // fallback - - return std::exp(numerator / denominator); -} - -} // namespace AtTools diff --git a/AtTools/AtELossModelFactory.h b/AtTools/AtELossModelFactory.h deleted file mode 100644 index 14c94132c..000000000 --- a/AtTools/AtELossModelFactory.h +++ /dev/null @@ -1,64 +0,0 @@ -#ifndef ATELOSSMODELFACTORY_H -#define ATELOSSMODELFACTORY_H - -#include "AtELossModel.h" - -#include -#include -#include - -class TGeoMaterial; - -namespace AtTools { - -/** - * Abstract factory for creating energy loss models from ROOT geometry materials. - * - * Subclasses hold model-type-specific configuration (e.g. catima::Config for CATIMA) - * and produce configured AtELossModel instances on demand for any (projectile, material) - * combination. This allows AtSimpleSimulation to auto-create models at transport time - * for particle species that were not explicitly registered. - */ -class AtELossModelFactory { -public: - virtual ~AtELossModelFactory() = default; - - /** - * Create an energy loss model for the given projectile in the given target material. - * @param projZ Projectile atomic number - * @param projA Projectile mass number - * @param projMassAmu Projectile mass in atomic mass units - * @param material Target material from ROOT geometry (TGeoMaterial or TGeoMixture) - * @return Configured model ready for use, or nullptr on failure - */ - virtual std::shared_ptr - CreateModel(int projZ, int projA, double projMassAmu, const TGeoMaterial *material) = 0; - - // ---- Shared utility functions for extracting material info from TGeo ---- - - /** - * Extract elemental composition from a TGeoMaterial as (A, Z, stoichiometry) tuples. - * For TGeoMixture: weight fractions are converted to integer stoichiometry. - * For pure TGeoMaterial: returns a single element with stoichiometry 1. - */ - static std::vector> ExtractComposition(const TGeoMaterial *material); - - /** - * Convert weight fractions and atomic masses to approximate integer stoichiometry. - * Algorithm: n_i = w_i / A_i (molar ratio), normalize by smallest, round to nearest int. - */ - static std::vector - WeightFractionsToStoichiometry(const std::vector &weights, const std::vector &atomicMasses); - - /** - * Compute effective mean ionization energy (eV) for a material using Bragg's additivity rule: - * ln(I_eff) = sum(f_i * Z_i/A_i * ln(I_i)) / sum(f_i * Z_i/A_i) - * where f_i are weight fractions and I_i = 13.5*Z_i eV (Bloch approximation). - * For a pure material, returns 13.5*Z eV directly. - */ - static double EffectiveMeanIonization(const TGeoMaterial *material); -}; - -} // namespace AtTools - -#endif // ATELOSSMODELFACTORY_H diff --git a/AtTools/AtELossModelFactoryTest.cxx b/AtTools/AtELossModelFactoryTest.cxx deleted file mode 100644 index 5b6a9575e..000000000 --- a/AtTools/AtELossModelFactoryTest.cxx +++ /dev/null @@ -1,177 +0,0 @@ -#include "AtELossModelFactory.h" - -#include "AtELossFactoryBetheBloch.h" -#include "AtELossModel.h" - -#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(AtELossModelFactoryUtils, WaterStoichiometry) -{ - // H2O: H weight fraction ~0.1119, O ~0.8881 - std::vector weights = {0.111898, 0.888102}; - std::vector masses = {1.008, 15.999}; - auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); - - ASSERT_EQ(stoich.size(), 2u); - EXPECT_EQ(stoich[0], 2); // H - EXPECT_EQ(stoich[1], 1); // O -} - -TEST(AtELossModelFactoryUtils, CO2Stoichiometry) -{ - // CO2: C ~0.2729, O ~0.7271 - std::vector weights = {0.272916, 0.727084}; - std::vector masses = {12.011, 15.999}; - auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); - - ASSERT_EQ(stoich.size(), 2u); - EXPECT_EQ(stoich[0], 1); // C - EXPECT_EQ(stoich[1], 2); // O -} - -TEST(AtELossModelFactoryUtils, PureElementStoichiometry) -{ - std::vector weights = {1.0}; - std::vector masses = {4.003}; - auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry(weights, masses); - - ASSERT_EQ(stoich.size(), 1u); - EXPECT_EQ(stoich[0], 1); -} - -TEST(AtELossModelFactoryUtils, EmptyInput) -{ - auto stoich = AtELossModelFactory::WeightFractionsToStoichiometry({}, {}); - EXPECT_TRUE(stoich.empty()); -} - -// ---- ExtractComposition tests (heap-allocated materials for TGeoManager ownership) ---- - -TEST_F(GeoFixture, ExtractPureMaterial) -{ - auto *mat = new TGeoMaterial("He_extract", 4.003, 2, 1.664e-4); - auto comp = AtELossModelFactory::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) -{ - // AddElement signature: AddElement(A, Z, weight) - auto *mix = new TGeoMixture("HeCO2_extract", 3, 1.0e-3); - mix->AddElement(4, 2, 0.90); // He - mix->AddElement(12, 6, 0.03); // C - mix->AddElement(16, 8, 0.07); // O - - auto comp = AtELossModelFactory::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(AtELossModelFactoryUtils, ExtractNullMaterial) -{ - auto comp = AtELossModelFactory::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 = AtELossModelFactory::EffectiveMeanIonization(mat); - EXPECT_DOUBLE_EQ(I, 13.5); // 13.5 * Z=1 -} - -TEST_F(GeoFixture, PureHeliumIonization) -{ - auto *mat = new TGeoMaterial("He_ionize", 4.003, 2, 1.664e-4); - double I = AtELossModelFactory::EffectiveMeanIonization(mat); - EXPECT_DOUBLE_EQ(I, 27.0); // 13.5 * Z=2 -} - -TEST_F(GeoFixture, MixtureIonization) -{ - // Pure hydrogen mixture: should give I = 13.5 eV - auto *mix = new TGeoMixture("H2_ionize", 1, 8.376e-5); - mix->AddElement(1, 1, 1.0); - double I = AtELossModelFactory::EffectiveMeanIonization(mix); - EXPECT_NEAR(I, 13.5, 0.5); -} - -TEST(AtELossModelFactoryUtils, NullIonization) -{ - double I = AtELossModelFactory::EffectiveMeanIonization(nullptr); - EXPECT_DOUBLE_EQ(I, 0.0); -} - -// ---- BetheBloch factory CreateModel test ---- - -TEST_F(GeoFixture, CreateFromPureMaterial) -{ - auto *mat = new TGeoMaterial("H_bbfactory", 1.008, 1, 6.5643e-5); - AtELossFactoryBetheBloch factory; - - auto model = factory.CreateModel(1, 1, 1.007825, mat); - ASSERT_NE(model, nullptr); - - double dedx = model->GetdEdx(10.0); - EXPECT_GT(dedx, 0.0); - - double range = model->GetRange(10.0); - EXPECT_GT(range, 0.0); - EXPECT_LT(range, 1e8); -} - -TEST_F(GeoFixture, CreateFromMixture) -{ - auto *mix = new TGeoMixture("HeCO2_bbfactory", 3, 1.0e-3); - mix->AddElement(4, 2, 0.90); - mix->AddElement(12, 6, 0.03); - mix->AddElement(16, 8, 0.07); - - AtELossFactoryBetheBloch factory; - auto model = factory.CreateModel(2, 4, 4.002603, mix); - ASSERT_NE(model, nullptr); - - double dedx = model->GetdEdx(10.0); - EXPECT_GT(dedx, 0.0); -} - -TEST_F(GeoFixture, NullMaterial) -{ - AtELossFactoryBetheBloch factory; - auto model = factory.CreateModel(1, 1, 1.007825, nullptr); - EXPECT_EQ(model, nullptr); -} diff --git a/AtTools/AtPropagator.h b/AtTools/AtPropagator.h index 14e915669..6e5e707d3 100644 --- a/AtTools/AtPropagator.h +++ b/AtTools/AtPropagator.h @@ -52,8 +52,8 @@ class AtPropagator { 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 + 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 @@ -75,8 +75,7 @@ class AtPropagator { * @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) + AtPropagator(double charge, double mass, const AtELossModel *elossModel) : fELossModel(elossModel) { fState.fMass = mass; fState.fQ = charge; @@ -107,6 +106,9 @@ class AtPropagator { } 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; } diff --git a/AtTools/AtToolsLinkDef.h b/AtTools/AtToolsLinkDef.h index 2bce4e70f..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,9 +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::AtELossModelFactory - !; -#pragma link C++ class AtTools::AtELossFactoryBetheBloch - !; -#pragma link C++ class AtTools::AtELossFactoryCATIMA - !; +#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 - !; diff --git a/AtTools/CMakeLists.txt b/AtTools/CMakeLists.txt index 2e88b4ffb..ef4903aca 100644 --- a/AtTools/CMakeLists.txt +++ b/AtTools/CMakeLists.txt @@ -41,8 +41,8 @@ set(SRCS AtELossBetheBloch.cxx AtPropagator.cxx - AtELossModelFactory.cxx - AtELossFactoryBetheBloch.cxx + AtELossManager.cxx + AtELossManagerBetheBloch.cxx ) Set(DEPENDENCIES @@ -68,7 +68,7 @@ endif() if(CATIMA_FOUND) set(SRCS ${SRCS} AtELossCATIMA.cxx - AtELossFactoryCATIMA.cxx + AtELossManagerCATIMA.cxx ) set(DEPENDENCIES ${DEPENDENCIES} CATIMA::catima @@ -86,7 +86,7 @@ set(TEST_SRCS AtELossTableTest.cxx AtELossBetheBlochTest.cxx AtPropagatorTest.cxx - AtELossModelFactoryTest.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 index 1560be0cb..cf8b058e8 100644 --- a/docs/development/simplesim-integration-review.md +++ b/docs/development/simplesim-integration-review.md @@ -1,5 +1,12 @@ # 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. diff --git a/docs/subsystems/energy-loss.md b/docs/subsystems/energy-loss.md index ee7ad9974..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,12 +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 | -| `AtELossModelFactory` | abstract factory for creating models from ROOT geometry materials | -| `AtELossFactoryCATIMA` | CATIMA-backed factory; stores a `catima::Config` applied to all created models | -| `AtELossFactoryBetheBloch` | Bethe-Bloch-backed factory; stateless | -| `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 @@ -28,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: @@ -48,49 +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: -## Model Factories +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`). -`AtELossModelFactory` is an abstract interface for automatically creating energy loss models from ROOT geometry materials (`TGeoMaterial` / `TGeoMixture`). When used with `AtSimpleSimulation::SetModelFactory()`, models are created on demand as new particle species are encountered during transport — no manual `AddModel()` calls needed. +The base class is **accept-only** — if no registration matches the requested particle + material, `GetModel` returns `nullptr`. Two subclasses add auto-generation: -Two concrete factories are provided: +- **`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. -- **`AtELossFactoryCATIMA`** -- wraps CATIMA. Stores a `catima::Config` that is applied to every model it creates. This is the recommended factory for most use cases. -- **`AtELossFactoryBetheBloch`** -- uses the analytic Bethe-Bloch formula with effective Z/A for mixtures and Bloch approximation for mean ionization energy. Lighter weight but less accurate than CATIMA, especially for straggling. +### Registration forms -### Usage +Two overloads of `AddModel` are available: ```cpp -auto sim = std::make_unique(); -auto factory = std::make_shared(); -// Optional: factory->SetConfig(myCatimaConfig); -sim->SetModelFactory(factory); +// 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); ``` -When a particle species (Z, A) is first encountered without a registered model, the factory extracts the material composition and density from the geometry at the particle's position and creates an appropriate `AtELossModel`. +### Lookup priority (inside `GetModel`) -Manually registered models via `AddModel()` take precedence -- the factory is only consulted for species without an explicit model. +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. -### Factory vs manual registration +`ClearCache()` drops auto-generated entries only; registered models survive. -Use the **factory** approach when you want simpler setup or are running exploratory simulations where you may not know all particle species in advance. Use **manual `AddModel()`** when you need specific nuclear masses, custom material compositions, or non-standard density values. +### Usage with `AtSimTransport` / `AtSimpleSimulation` -### Utility methods +```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); -`AtELossModelFactory` provides static utility methods usable by any code that works with ROOT geometry materials: +auto sim = std::make_unique("ATTPC_He1bar.root", manager); +``` -- `ExtractComposition(material)` -- extracts `(A, Z, stoichiometry)` tuples from a `TGeoMaterial` or `TGeoMixture` -- `WeightFractionsToStoichiometry(weights, atomicMasses)` -- converts weight fractions to integer stoichiometry -- `EffectiveMeanIonization(material)` -- computes effective mean ionization energy via Bragg's additivity rule +`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/simplesim-migration.md b/docs/subsystems/simplesim-migration.md index b2dd0f498..53195144e 100644 --- a/docs/subsystems/simplesim-migration.md +++ b/docs/subsystems/simplesim-migration.md @@ -2,6 +2,16 @@ 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: @@ -27,23 +37,25 @@ In a SimpleSim macro, the run gets a dummy generator for the event loop, and the // Give FairRunSim a dummy generator to drive the event loop run->SetGenerator(new FairPrimaryGenerator()); -// Build SimpleSim -- uses the geometry already loaded by FairRunSim -auto sim = std::make_unique(); +// Build an energy-loss manager with the models this run needs +auto manager = std::make_shared(); -// Register energy-loss models for every particle species auto carbonModel = std::make_shared(gasDensity, gasMaterial); carbonModel->SetProjectile(16, 6, 16.014701); -sim->AddModel(6, 16, carbonModel, 16.014701); +manager->AddModel(6, 16, carbonModel); auto protonModel = std::make_shared(gasDensity, gasMaterial); protonModel->SetProjectile(1, 1, 1.0078250322); -sim->AddModel(1, 1, protonModel, 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 AtSimpleSimulationGeneratorTask(std::move(sim)); +auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); simTask->SetPrimaryGenerator(primGen); simTask->SetDetector(tpc); run->AddTask(simTask); @@ -53,39 +65,49 @@ run->AddTask(simTask); ### Energy-loss models -Every particle species that will be transported needs an energy-loss model. There are two approaches: +Every particle species that crosses a given material needs a model. Two approaches: -#### Factory-based registration (recommended) +#### Auto-generating manager (recommended) -Set a model factory and let SimpleSim auto-create models from the geometry materials: +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 sim = std::make_unique(); -sim->SetModelFactory(std::make_shared()); +auto manager = std::make_shared(); +// optional: manager->SetConfig(myCatimaConfig); +auto sim = std::make_unique(manager); ``` -This is the simplest approach -- no per-species configuration needed. Models are created on demand when new particle species are encountered during transport. +No per-species configuration needed. Pre-registering a model with `manager->AddModel(Z, A, model)` still works and takes priority over auto-generation. -#### Manual registration +#### Manual-only registration -For full control, register models explicitly with `sim->AddModel(Z, A, model)` for each species. If a particle has no model and no factory is set, the simulation terminates with a fatal error. Manually registered models take precedence over the factory. +Use the accept-only base class if you want full control (or need to register SRIM/LISE tables that the factory cannot synthesize): -#### Available model types +```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::AtELossFactoryCATIMA` -- CATIMA factory, auto-creates from geometry (recommended) -- `AtTools::AtELossFactoryBetheBloch` -- Bethe-Bloch factory, lighter analytic alternative -- `AtTools::AtELossCATIMA` -- CATIMA model for manual registration -- `AtTools::AtELossTable` -- SRIM table lookup for manual registration +- `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 -`SetDetector(tpc)` connects SimpleSim to the `AtTpc` detector so that steps are processed through the same hit-recording and reaction-trigger logic used by Geant4. This is required for correct output. +`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 `AtSimpleSimulation` instance: +If the experiment uses a magnetic field, set it on the engine: ```cpp sim->SetMagneticField(ROOT::Math::XYZVector(Bx, By, Bz)); // Tesla @@ -93,20 +115,24 @@ 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 -`AtSimpleSimulation()` (default constructor) automatically uses the geometry that `FairRunSim` loads. No separate geometry file is needed. If you need to use a standalone geometry file (e.g., for testing outside FairRunSim), pass it to the constructor: +`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"); +auto sim = std::make_unique("path/to/geomanager.root", manager); ``` ## Replay mode -`AtSimpleSimulationReplayTask` re-transports primary tracks from a prior Geant4 run through SimpleSim. This enables direct A/B comparison with identical kinematics: +`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 AtSimpleSimulationReplayTask(std::move(sim)); +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); @@ -118,8 +144,8 @@ The source file must contain a `cbmsim` TTree with an `MCTrack` branch from a pr 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` -- factory CATIMA drop-in variants -- `simpleSim_fixed_bethebloch.C` -- factory Bethe-Bloch variant -- `compareFixed.C`, `compareKinematic.C` -- automated comparison plots (accept configurable file paths) +- `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 56a17fc58..42cc19154 100644 --- a/docs/subsystems/simulation-pipeline.md +++ b/docs/subsystems/simulation-pipeline.md @@ -17,7 +17,7 @@ FairPrimaryGenerator + AtReactionGenerator ┌──────┴──────────────┐ ▼ ▼ Geant4/VMC transport SimpleSim FairTask - (AtTpc::ProcessHits) (AtSimpleSimulationTask) + (AtTpc::ProcessHits) (AtSimTransportTask) │ │ └──────────┬──────────┘ ▼ @@ -36,12 +36,14 @@ The standard path. `FairPrimaryGenerator` pushes particles onto `AtStack`; Geant ### SimpleSim Path -SimpleSim runs as a `FairTask` inside the same `FairRunSim` event loop. It uses `AtSimpleSimulation` to propagate particles through the geometry with user-configured `AtELossModel` instances, 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. +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. -Two task classes are provided: +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. -- **`AtSimpleSimulationGeneratorTask`** -- generates events live via a `FairPrimaryGenerator`, using the same generator chain as the Geant4 path. This is the primary task for production use. -- **`AtSimpleSimulationReplayTask`** -- reads primary MCTracks from a prior Geant4 run and re-transports them through SimpleSim. Useful for A/B validation with identical kinematics. +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. @@ -89,10 +91,10 @@ For the **Geant4/VMC** path, a simulation run needs: For the **SimpleSim** path, the run additionally needs: -- an `AtSimpleSimulation` instance (uses the FairRunSim geometry automatically) -- energy loss models for each particle species, either registered manually via `AddModel()` or auto-created via `SetModelFactory()` +- 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 -Energy loss models must be available for every (Z, A) pair that will be transported. Models can be registered manually via `AddModel()`, or a factory can be set via `SetModelFactory()` to auto-create models from geometry materials on demand. If a particle has no model and no factory is set, the simulation will terminate with a fatal error. +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, [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/compareFixed.C b/macro/Simulation/AtSimValidation/compareFixed.C index cb2d7b681..d6a9747ef 100644 --- a/macro/Simulation/AtSimValidation/compareFixed.C +++ b/macro/Simulation/AtSimValidation/compareFixed.C @@ -477,7 +477,7 @@ void compareFixed(TString geantFile = "./data/geant4_fixed.root", TString simple auto *braggG4 = MakeResidualRangeGraph(geantEvent.proton, "gFixedBraggG4", kBlue + 1, 1); auto *braggSim = MakeResidualRangeGraph(simpleEvent.proton, "gFixedBraggSim", kRed + 1, 2); - auto *canvas = new TCanvas("cFixedCompare", "AtSimpleSimulation fixed-angle proton comparison", 1900, 1000); + auto *canvas = new TCanvas("cFixedCompare", "AtSimTransport fixed-angle proton comparison", 1900, 1000); canvas->Divide(4, 2); canvas->cd(1); diff --git a/macro/Simulation/AtSimValidation/compareKinematic.C b/macro/Simulation/AtSimValidation/compareKinematic.C index d99742b82..edead4807 100644 --- a/macro/Simulation/AtSimValidation/compareKinematic.C +++ b/macro/Simulation/AtSimValidation/compareKinematic.C @@ -496,7 +496,7 @@ void compareKinematic(TString geantFile = "./data/geant4_kinematic.root", auto *braggG4 = MakeResidualRangeGraph(geantEvent.proton, "gKineBraggG4", kBlue + 1, 1); auto *braggSim = MakeResidualRangeGraph(simpleEvent.proton, "gKineBraggSim", kRed + 1, 2); - auto *canvas = new TCanvas("cKinematicCompare", "AtSimpleSimulation kinematic proton comparison", 1900, 1000); + auto *canvas = new TCanvas("cKinematicCompare", "AtSimTransport kinematic proton comparison", 1900, 1000); canvas->Divide(4, 2); canvas->cd(1); diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed.C b/macro/Simulation/AtSimValidation/simpleSim_fixed.C index cbd9995b8..a86d13f80 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed.C @@ -39,22 +39,24 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th return primGen; } -std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) { - auto sim = std::make_unique(geoFile.Data()); + 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); - sim->AddModel(6, 16, carbonModel, 16.014701); + manager->AddModel(6, 16, carbonModel); auto protonModel = std::make_shared(heDensity, material); protonModel->SetProjectile(1, 1, 1.0078250322); - sim->AddModel(1, 1, protonModel, 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->SetMaxPropagationStep(1e-3); + sim->SetMaxStep(1e-3); return sim; } @@ -97,7 +99,7 @@ void simpleSim_fixed(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt_t seed auto *eventLoopDriver = new FairPrimaryGenerator(); run->SetGenerator(eventLoopDriver); - auto *simTask = new AtSimpleSimulationReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + auto *simTask = new AtSimTransportReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C b/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C index 7e2b7e851..b91af2f91 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed_bethebloch.C @@ -1,10 +1,10 @@ // 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 +#include -#include +#include namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) @@ -87,10 +87,10 @@ void simpleSim_fixed_bethebloch(Double_t thetaCms = 45.0, Int_t nEvents = 100, U // --- SimpleSim drop-in: replace Geant4 transport with Bethe-Bloch factory --- run->SetGenerator(new FairPrimaryGenerator()); - auto sim = std::make_unique(); - sim->SetModelFactory(std::make_shared()); + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); - auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); simTask->SetPrimaryGenerator(BuildElasticGenerator(thetaCms, thetaCms)); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C b/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C index 55122fa30..a2706c7f4 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C +++ b/macro/Simulation/AtSimValidation/simpleSim_fixed_factory.C @@ -1,10 +1,10 @@ // 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 +#include -#include +#include namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) @@ -87,10 +87,10 @@ void simpleSim_fixed_factory(Double_t thetaCms = 45.0, Int_t nEvents = 100, UInt // --- SimpleSim drop-in: replace Geant4 transport with factory-based SimpleSim --- run->SetGenerator(new FairPrimaryGenerator()); - auto sim = std::make_unique(); - sim->SetModelFactory(std::make_shared()); + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); - auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); simTask->SetPrimaryGenerator(BuildElasticGenerator(thetaCms, thetaCms)); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C index 0cd4ebc2c..2a04f4f9e 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic.C @@ -43,22 +43,24 @@ FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t th return primGen; } -std::unique_ptr BuildSimpleSimulation(const TString &geoFile) +std::unique_ptr BuildSimpleSimulation(const TString &geoFile) { - auto sim = std::make_unique(geoFile.Data()); + 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); - sim->AddModel(6, 16, carbonModel, 16.014701); + manager->AddModel(6, 16, carbonModel); auto protonModel = std::make_shared(heDensity, material); protonModel->SetProjectile(1, 1, 1.0078250322); - sim->AddModel(1, 1, protonModel, 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->SetMaxPropagationStep(1e-3); + sim->SetMaxStep(1e-3); return sim; } @@ -99,7 +101,7 @@ void simpleSim_kinematic(Int_t nEvents = 1000, UInt_t seed = 42, run->SetGenerator(new FairPrimaryGenerator()); auto *simTask = - new AtSimpleSimulationReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); + new AtSimTransportReplayTask(BuildSimpleSimulation(dir + "/geometry/ATTPC_He1bar_geomanager.root")); simTask->SetPrimaryTrackSource(geantTruthFile.Data()); simTask->SetDetector(tpc); run->AddTask(simTask); diff --git a/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C index 71ceeea4e..69df9e3c1 100644 --- a/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C +++ b/macro/Simulation/AtSimValidation/simpleSim_kinematic_factory.C @@ -1,9 +1,9 @@ // 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 +#include +#include +#include namespace { FairPrimaryGenerator *BuildElasticGenerator(Double_t thetaMinCmsDeg, Double_t thetaMaxCmsDeg) @@ -87,10 +87,10 @@ void simpleSim_kinematic_factory(Int_t nEvents = 1000, UInt_t seed = 42) // --- SimpleSim drop-in: replace Geant4 transport with factory-based SimpleSim --- run->SetGenerator(new FairPrimaryGenerator()); - auto sim = std::make_unique(); - sim->SetModelFactory(std::make_shared()); + auto manager = std::make_shared(); + auto sim = std::make_unique(manager); - auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim)); + auto *simTask = new AtSimTransportGeneratorTask(std::move(sim)); auto *primGen = BuildElasticGenerator(0.0, 180.0); simTask->SetPrimaryGenerator(primGen); simTask->SetDetector(tpc); diff --git a/macro/e12014/adam/simulation/simpleSim.C b/macro/e12014/adam/simulation/simpleSim.C index e8b5f91d4..02c2b4b3b 100644 --- a/macro/e12014/adam/simulation/simpleSim.C +++ b/macro/e12014/adam/simulation/simpleSim.C @@ -33,12 +33,14 @@ 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); + + auto sim = std::make_unique(geoFile.Data(), manager); auto *simTask = new AtSimpleSimulationGeneratorTask(std::move(sim));