Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/sensitive_detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,43 @@ Two caveats. The id is stable within a run but **not** across geometry changes
or between releases — never persist it as a channel number. And being a hash
it can in principle collide; it identifies a node, it does not encode a
position in the hierarchy.

## merge_across_tracks

By default `merged` keys on `(geometryNodeId, trackId)`, so two particles
depositing in the same volume produce two hits. Setting

```jsonnet
geant4: lib.geant4 { sd_mode: 'merged', merge_across_tracks: true },
```

widens the key to the placement alone: every particle depositing in one volume
yields a single hit. Only `energyDeposit` and `pathLength` combine
meaningfully. The rest degrade:

| field | with `merge_across_tracks` |
|:---|:---|
| `energyDeposit`, `pathLength` | summed over all contributing tracks |
| `trackId`, `pdgCode` | the largest single-track contributor |
| `position`, `momentum`, `time` | the first step to arrive in the placement |

**This discards per-particle truth.** Questions like "was this SBT hit caused
by the signal muon or by a shower stray" and truth matching for tracking
efficiency stop being answerable. The setting is off by default for that
reason.

What it buys is size. On a sample of nine 1 GeV HNL decays through the full
GeoModel geometry, hits per event dropped by factors of 1.3 to 13 (mean around
4), the largest reductions being showers where dozens of secondaries pepper
one scintillator cell.

The alternative is to leave it off and merge across tracks at digitisation
instead, where a straw's several truth hits become one `StrawTubesHit` and the
links back to the contributing particles can be kept. That also fits the
hierarchy in the data model, which combines pile-up from several collisions at
the time-frame level — above a single Geant4 event. Merging inside the
sensitive detector happens before that stage, so the information is gone by
the time it would be needed again.

Use `merge_across_tracks` when output size dominates and per-particle truth is
not needed; prefer digitisation when it is.
10 changes: 7 additions & 3 deletions src/detector_construction.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class ConfigurableDetectorConstruction : public G4VUserDetectorConstruction {
source_; // non-owning; Job-layer product outlives the G4 run
ship::IFieldSource const* field_source_; // non-owning; may have no regions
SDMode sd_mode_;
ship::Energy ke_threshold_; // used by CrossingSD
ship::Energy ke_threshold_; // used by CrossingSD
bool merge_across_tracks_{}; // used by MergedScoringSD
std::vector<std::pair<std::string, ship::Length>>
regions_; // pattern -> production cut

Expand All @@ -45,11 +46,13 @@ class ConfigurableDetectorConstruction : public G4VUserDetectorConstruction {
IGeometrySource const& source, ship::IFieldSource const& field_source,
SDMode sd_mode = SDMode::scoring,
ship::Energy ke_threshold = ship::Energy::zero(),
std::vector<std::pair<std::string, ship::Length>> regions = {})
std::vector<std::pair<std::string, ship::Length>> regions = {},
bool merge_across_tracks = false)
: source_{&source},
field_source_{&field_source},
sd_mode_{sd_mode},
ke_threshold_{ke_threshold},
merge_across_tracks_{merge_across_tracks},
regions_{std::move(regions)} {}

// Called once, on ship::geometry_thread() (the run manager is initialised
Expand Down Expand Up @@ -100,7 +103,8 @@ class ConfigurableDetectorConstruction : public G4VUserDetectorConstruction {
if (sd_mode_ == SDMode::crossing) {
sd = new CrossingSD("CrossingSD", detector_ids, ke_threshold_);
} else if (sd_mode_ == SDMode::merged) {
sd = new MergedScoringSD("MergedScoringSD", detector_ids);
sd = new MergedScoringSD("MergedScoringSD", detector_ids,
merge_across_tracks_);
} else {
sd = new ScoringSD("ScoringSD", detector_ids);
}
Expand Down
10 changes: 8 additions & 2 deletions src/geant4_module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ struct Geant4SimConfig {
std::uint32_t seed = 0;
SDMode sd_mode = SDMode::scoring;
ship::Energy ke_threshold = ship::Energy::zero();
// sd_mode 'merged' only: one hit per placement rather than per
// (placement, track). Discards per-particle truth — see
// docs/sensitive_detectors.md.
bool merge_across_tracks = false;
bool energy_cut = false;
ship::Energy energy_cut_threshold = ship::Energy::zero();
ship::Energy particle_ke_cut = ship::Energy::zero();
Expand Down Expand Up @@ -341,7 +345,8 @@ class Geant4Sim {

field_ = field; // keep alive for the G4 run
detector_ = new ConfigurableDetectorConstruction(
*geo, *field, cfg_.sd_mode, cfg_.ke_threshold, cfg_.regions);
*geo, *field, cfg_.sd_mode, cfg_.ke_threshold, cfg_.regions,
cfg_.merge_across_tracks);

// Master initialisation runs on the process-wide geometry thread: only
// one thread per process may create Geant4 geometry (G4GeomSplitter's
Expand Down Expand Up @@ -500,10 +505,11 @@ PHLEX_REGISTER_ALGORITHMS(m, config) {
.verbosity = config.get<int>("verbosity", 0),
.concurrency = config.get<int>("concurrency", int{active_parallelism}),
.seed = seed,
.sd_mode = sd_mode_str == "crossing" ? SDMode::crossing
.sd_mode = sd_mode_str == "crossing" ? SDMode::crossing
: sd_mode_str == "merged" ? SDMode::merged
: SDMode::scoring,
.ke_threshold = ke_threshold,
.merge_across_tracks = config.get<bool>("merge_across_tracks", false),
.energy_cut = config.get<bool>("energy_cut", false),
.energy_cut_threshold = aegir::get_quantity(
config, "energy_cut_threshold", ship::Energy{ke_threshold}),
Expand Down
73 changes: 54 additions & 19 deletions src/geant4_sim_core.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ using DetectorIdMap = std::unordered_map<G4LogicalVolume*, int>;

// Stable identifier for one placement — a node of the touchable history.
// detectorId only names the subsystem, and a single logical volume can be
// placed thousands of times (9600 copies of /SHiP/trackers/straw_gas share one
// G4LogicalVolume), so the copy numbers along the touchable path are what
// placed thousands of times (9600 copies of /SHiP/trackers/straw_gas share
// one G4LogicalVolume), so the copy numbers along the touchable path are what
// distinguish one straw from another. FNV-1a over (copy number, volume) at
// every depth, folded to 31 bits because SimHit::geometryNodeId is int32 and a
// negative id would read as a sentinel.
// every depth, folded to 31 bits because SimHit::geometryNodeId is int32 and
// a negative id would read as a sentinel.
//
// Stable within a run but not across geometry changes, and being a hash it can
// in principle collide: it identifies a node, it does not encode a position in
// the hierarchy. Never persist it as a channel number.
// The id is stable within a run but not across geometry changes, and being a
// hash it can in principle collide; it identifies a node, it does not encode
// a position in the hierarchy.
inline std::int32_t geometry_node_id(G4VTouchable const* touchable) {
std::uint64_t h = 1469598103934665603ull; // FNV-1a offset basis
auto mix = [&h](std::uint64_t v) {
Expand Down Expand Up @@ -102,27 +102,45 @@ class ScoringSD : public G4VSensitiveDetector {

// One open accumulator per (placement, track) while an event is in flight.
// Namespace-scope thread_local for the same reason as tl_hits: G4 worker
// threads must not share it.
// threads must not share it, and this does not depend on whether the SD
// object itself is per-thread.
struct OpenHit {
SimHit hit; // taken from the first contributing step
double edep = 0; // Geant4 internal units, summed
double path = 0; // Geant4 internal units, summed
// merge_across_tracks only: running total per contributing track, and the
// best seen so far. Held inside the accumulator rather than in a second
// thread_local map — two maps keyed alike but cleared independently drift
// apart as soon as Initialize/EndOfEvent and ProcessHits do not all run on
// the same thread, which crashed under multi-threading.
std::unordered_map<int, double> per_track;
double lead = 0;
};
inline thread_local std::unordered_map<std::uint64_t, OpenHit> tl_open_hits;

// Like ScoringSD, but merges every step a track takes inside one placement
// into a single SimHit, emitted at end of event. This is what readout-like
// output wants: a track crossing a straw produces one hit carrying the total
// deposit, not one hit per Geant4 step.
// into a single SimHit, emitted at end of event. This is what you want for
// readout-like output: a track crossing a straw produces one hit carrying the
// total deposit, not one hit per Geant4 step.
//
// Position, momentum and time come from the first step (the entry point);
// energyDeposit and pathLength are summed. A track that leaves and re-enters
// the same placement merges into the same hit, since the key has no visit
// counter. See docs/sensitive_detectors.md.
// energyDeposit and pathLength are summed over the track's steps in that
// placement. A track that leaves and re-enters the same placement merges into
// the same hit, since the key does not include a visit counter.
//
// merge_across_tracks widens the key to the placement alone, so every particle
// depositing in one volume yields a single hit. Only energyDeposit and
// pathLength combine meaningfully: trackId and pdgCode are set from the
// largest single-track contributor and position/momentum/time from the first
// step to arrive there, so per-particle truth is lost. Off by default — see
// docs/sensitive_detectors.md for when that trade is the right one.
class MergedScoringSD : public G4VSensitiveDetector {
public:
MergedScoringSD(G4String const& name, DetectorIdMap detector_ids)
: G4VSensitiveDetector(name), detector_ids_{std::move(detector_ids)} {}
MergedScoringSD(G4String const& name, DetectorIdMap detector_ids,
bool merge_across_tracks = false)
: G4VSensitiveDetector(name),
detector_ids_{std::move(detector_ids)},
merge_across_tracks_{merge_across_tracks} {}

void Initialize(G4HCofThisEvent*) override { tl_open_hits.clear(); }

Expand All @@ -134,13 +152,29 @@ class MergedScoringSD : public G4VSensitiveDetector {
auto const node = geometry_node_id(pre->GetTouchable());
auto const track = step->GetTrack()->GetTrackID();
auto const key =
(static_cast<std::uint64_t>(static_cast<std::uint32_t>(node)) << 32) |
static_cast<std::uint32_t>(track);
merge_across_tracks_
? static_cast<std::uint64_t>(static_cast<std::uint32_t>(node))
: (static_cast<std::uint64_t>(static_cast<std::uint32_t>(node))
<< 32) |
static_cast<std::uint32_t>(track);

auto [it, inserted] = tl_open_hits.try_emplace(key);
if (inserted) it->second.hit = make_base_hit(step, detector_ids_);
it->second.edep += edep;
it->second.path += step->GetStepLength();

if (merge_across_tracks_) {
// Attribute the hit to whichever track deposited the most in this
// placement. Tracks interleave, so accumulate each one's total and
// rewrite the identity when a new leader emerges.
auto const total = (it->second.per_track[track] += edep);
if (total > it->second.lead) {
it->second.lead = total;
it->second.hit.trackId = track;
it->second.hit.pdgCode =
step->GetTrack()->GetDefinition()->GetPDGEncoding();
}
}
return true;
}

Expand All @@ -152,7 +186,7 @@ class MergedScoringSD : public G4VSensitiveDetector {
tl_hits.push_back(open.hit);
}
// unordered_map iteration order is unspecified and varies with insertion
// history, so sort the batch: the determinism test compares output across
// history, so sort the batch — the determinism test compares output across
// runs and would otherwise fail spuriously.
std::sort(tl_hits.begin() + static_cast<std::ptrdiff_t>(first),
tl_hits.end(), [](SimHit const& a, SimHit const& b) {
Expand All @@ -164,6 +198,7 @@ class MergedScoringSD : public G4VSensitiveDetector {

private:
DetectorIdMap detector_ids_;
bool merge_across_tracks_;
};

// Records a SimHit when a track first enters the volume, regardless of
Expand Down
Loading