Skip to content

Adding handling of decayIndex and other variables used by GatePositroniumSource in the context of Singles and Coincidences - #774

Open
wkrzemien wants to merge 24 commits into
OpenGATE:developfrom
cis-imaging:ref-ref-develop
Open

wkrzemien wants to merge 24 commits into
OpenGATE:developfrom
cis-imaging:ref-ref-develop

Conversation

@wkrzemien

@wkrzemien wkrzemien commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

The code was prepared by @MateuszBala.
Some of the variables used by PositroniumSource were not propagated properly in the case of Singles and Coincidences. It was not a bug, but some of the information was not accessible for Singles and Coincidences. This PR fix it.

The PR is built on top of #773 so it should be merged as a second.

The detailed description is given in the first comment.

MateuszBala and others added 24 commits September 4, 2026 17:52
fix(digitizer): keep sourceID when the interaction volume name is empty
…-output

fix(multiphoton) event without output
fix(output): do not index the hit tree vector after the run cleared it
…ration

feat(multiphoton) septal penetration
…ounting

fix(multiphoton) interaction counting
fix(geometry): refuse crystal SD attachment before creating the detector
…es-policy

fix(digitizer): store the single good pair instead of the whole multiple
…and-coincidences

feat: decay fields in singles and coincidences
@MateuszBala

Copy link
Copy Markdown
Contributor

This pull request is cumulative: its diff contains #773
plus five further changes. #773 should be merged first, as its author notes.

The title names one of the five - the decay information reaching the Singles and Coincidences trees.
The other four are the defect this whole work started from (the interaction counting of
GateMultiPhotonAnalysis), the septal penetration counter, the coincidence sorter under
takeWinnerIfOnlyOneGood, and an attachCrystalSD request reported as ignored while the sensitive
detector has already been registered.

Everything is backed by simulations and tests kept in a separate repository, which builds both
variants of GATE - the commit this work started from and this branch - runs the same macros against
both and compares the resulting trees:

https://github.com/MateuszBala/opengate-gate-multiphoton-analysis-verification

commit change details
c7314708 fix(multiphoton): correct Compton and Rayleigh interaction counting bug-01, bug-03, bug-04
78078a5c feat(multiphoton): record septal penetration counter like GateAnalysis bug-06
63b5f420 feat(digitizer): propagate decay information and interaction counter to digis bug-10
dbc6ee5e fix(digitizer): store the single good pair instead of the whole multiple bug-08
eb44d85a fix(geometry): refuse crystal SD attachment before creating the detector bug-16
f35f62ef, 51b2cdfe, 07a1c614, 0e77deaf documentation of the above bug-12

The commits 8ca6c252, 4aeb6e54, fbc824fa, 548e3460, 450326fa and 9013f5ea, and the
changes they make to GateToRoot.cc, GateHit.cc, GateHitConvertor.cc,
GatePositroniumSourceMessenger.* and GatePositroniumDecayParamsGenerator.cc, belong to #773 and
are described there.

1. The main defect: interaction counting of GateMultiPhotonAnalysis

GateMultiPhotonAnalysis is the analysis output module supporting multi-photon sources: it handles
an ortho-positronium decay into three gammas and a prompt gamma, where GateAnalysis assigns its
counters to the two annihilation gammas only. On the cases where both modules describe the same
quantity, they disagreed.

Running one macro twice and changing only /gate/output/analysis/enable to
/gate/output/multianalysis/enable, simulation back-to-back-with-phantom, 270 476 hits:

column disagreeing rows before after
nCrystalCompton 73 063 (27.01%) - every single Compton hit 0.00%
nPhantomCompton 341 (0.13%) 0.00%
comptVolName, RayleighVolName 270 476 (100%) - empty strings 0.00%

Rows where the two analyses disagree

ProcessTimeline contained two independent errors:

  • the crystal counters were written into the hit before the interaction of that hit was
    accumulated, so every scattering hit reported one scattering too few,
  • the phantom counters were written as a running count along the time axis, while
    GateAnalysis reports the sum over the whole event for every hit of the photon.

Two further differences were found while reading the code and are fixed in the same commit: the
process name was classified by comparing the first four characters instead of searching for a
substring, so LowEnCompt was not recognised where GateAnalysis counts it; and the set of
reference photons was built on a common-vertex criterion, which cannot hold for a prompt gamma -
it does not share the annihilation vertex. The set is now a gamma that is either primary or a child
of the positron of the event, bounded at four photons (three from an ortho-positronium decay plus
one prompt gamma) with a warning when the limit is exceeded.

The scattering volume names were never written at all on this path. An empty string is worse than a
wrong name: uproot refuses to read a branch containing nothing else, so no offline tool could open
the file, and the digitizer reacted to the empty name by resetting sourceID - the coupling that
#773 removes.

Hits whose ancestor photon cannot be resolved are no longer skipped. They used to leave
ProcessTimeline without a single attribute written, while the digitizer digitises every hit with a
non-zero energy deposit; they now receive correct identifiers with zeroed counters, exactly as
GateAnalysis does for photonID == 0.

The same result holds across six simulations - with and without a phantom, with two phantom volumes,
and with Rayleigh scattering enabled.

2. The septal penetration counter was never written

nSeptal counts how many collimator septa a photon crossed before being detected - the quantity a
SPECT analysis uses to separate septal penetration from geometric collimation.
GateDigitizerInitializationModule::Digitize() copies it into the digi, but on the multianalysis
path nothing had ever written it, and GateHit does not initialise m_nSeptal, so the digitizer
read an uninitialised integer.

CountSeptalHits transplants the rule from GateAnalysis: all phantom hits recorded in the
configured septal volume are counted, without assignment to a photon, and the sum for the whole
event is written into every crystal hit. When the counting is disabled the value written is 0,
exactly what GateAnalysis writes.

The configuration - setSeptalVolumeName and recordSeptalPenetration - is read from the
analysis module, exactly as GateRootDefs::GetRecordSeptalFlag() does when it decides whether to
create the septalNb branch. Those two commands therefore live under /gate/output/analysis/ even
when the output is produced by multianalysis; the documentation says so explicitly.

Simulation spect-tc99m-septal-penetration, 13 314 hits - the two modules now agree row by row:

septalNb

There is no "before" distribution to compare against: the values were not merely wrong, they were
not defined.

3. The decay information reaches the detector response

This is the item the title names, and the only one of the five that is an extension of
functionality rather than a defect fix
.

sourceType, decayType, gammaType, decayIndex and nInteractions existed only in GateHit,
and therefore only in the Hits tree. A positronium analysis - separating para- from
ortho-positronium, removing prompt gammas, working on the number of scatterings - had to go back to
Monte Carlo truth instead of working on the simulated detector response.

They now travel through the digitizer into the Singles and Coincidences trees, with merging rules
modelled on what GATE already does for m_sourceEnergy and m_sourcePDG ("equal - keep,
different - sentinel"):

field level merging rule
sourceType decay constant within an event, so merging cannot conflict
decayIndex decay the same; sentinel -1
decayType gamma equal - keep, different - DecayModel::None = 0
gammaType gamma equal - keep, different - GammaKind::Unknown = 0
nInteractions gamma, cumulative maximum, like nPhantomCompton

The rule is extracted into GateVDigitizerModule::MergeEmittedGammaInformation and called from all
four merging functions (CentroidMerge, MergePositionEnergyWin, CentroidMergeCompton,
CentroidMergeComptPhotIdeal) - one definition instead of four copies, so it cannot drift apart
between adders.

trackID is deliberately not propagated: after merging it is ill-defined, which is why
CentroidMerge already clears it.

Reading older ROOT files is safe. GateSingleTree::SetBranchAddresses binds the new branches
only when they exist, so a file written before this change reads with the "not known" values.

Scope of the merging rules. A *Merge rule acts at the level of the adder, that is on digis
sharing a volumeID. GateReadout under the default TakeEnergyWinner policy creates its output as
a copy of the energy winner and replaces only the energy, so values from the losing digis are lost
without any rule - and the new fields behave exactly like the existing counters. The effect is
measurable on those: the share of nCrystalCompton > 0 falls from 39.6% in Hits to 25.8% in
Singles, because a scattered, lower-energy deposit usually loses the readout.

Deliberately out of scope: the old digitizer chain (GatePulse / GateHitConvertor) and the
GateToTree output module, which does not feed the Singles tree of the classic ROOT output.

Measured on positronium-source-mixed-prompt-with-phantom - three decay channels at once (10%
para-positronium, 30% direct, 60% ortho-positronium), prompt gamma enabled, a water phantom, and a
PET chain with an adder and a readout - 216 312 singles:

condition result
sourceType, decayIndex, decayType a value taken from the hits of the event for all 216 312 singles, no sentinels
gammaType the same, with the sentinel 0 occurring exactly once
nInteractions -1 for all singles under GateAnalysis; under GateMultiPhotonAnalysis inside [0, maximum over the event], distribution 0: 29 439, 1: 68 614, 2: 53 249, 3: 29 944, 4: 15 692

Decay fields in the Singles tree

The single sentinel is worth a comment. The "different - sentinel" rule can only fire when hits of
gammas of different kinds are merged in the same crystal of the same event. A 1.3 MeV prompt gamma
and a 511 keV annihilation gamma rarely land in the same crystal, so despite 35.8% of the events
having mixed gammaType, this geometry produces exactly one real conflict. The rule itself was
additionally checked on synthetic data.

The Coincidences tree was checked separately, on a variant with a coincidence sorter: all ten
branches (sourceType1/2, decayType1/2, gammaType1/2, decayIndex1/2, nInteractions1/2) are
filled, and decayIndex1 differs from decayIndex2 in one count - a random coincidence, a pair from
two different decays, which is exactly what these fields are for. That check is a measurement; the
automated test covers the Singles tree only.

4. The coincidence sorter did not respect its own filter

Under takeWinnerIfOnlyOneGood the sorter inserted the unsplit multiple into the output
collection:

if ((m_multiplesPolicy == kTakeWinnerIfOnlyOneGood) && (nGoods == 1))
{
  m_OutputCoincidenceDigiCollection->insert(coincidence);   // the whole multiple, N > 2 digis
  return true;
}

GateCoincidenceDigi derives from std::vector<GateDigi*>, while GateRootCoincBuffer::Fill reads
only GetDigi(0) and GetDigi(1). The output therefore received the first two singles of the
window rather than the one good pair - a pair IsForbiddenCoincidence had just rejected - and the
other singles of the multiple vanished. Every other policy branch uses
CreateSubDigi(coincidence, i, j) and none of them has the problem.

Three scenes differing only in the value of MultiplesPolicy, minSectorDifference = 2:

policy coincidences pairs below the threshold, before after
takeAllGoods 103 448 0 0
takeWinnerIfAllAreGoods 57 600 0 0
takeWinnerIfOnlyOneGood 40 876 13 0

Sector difference

The number of coincidences does not change - the branch now picks the good pair out of the multiple
instead of passing the multiple on. The two correct policies act as a control group and give results
identical to before. The return value changed from true to false as well: the original multiple
is no longer handed over to the output, so the caller has to delete it.

5. A refused attachCrystalSD still registered the sensitive detector

Calling attachCrystalSD on a volume that belongs to no system printed a warning saying the request
was ignored, while the sensitive detector had already been created and registered in
GateDigitizerMgr - and nothing takes it back. Three consequences followed: an empty hit collection
appeared, all hit trees were renamed from Hits to Hits_<name> (the correct ones included), and
the default name of the singles collection stopped being Singles, so a macro with a
CoincidenceSorter aborted with a message pointing nowhere near the cause:

ERROR: The name _Singles_ is unknown for input singles digicollection!

The check now happens before the sensitive detector is created
(GateCrystalSD::CanAttachToCreator, a new static method carrying the same message), so the warning
finally tells the truth. The wording and prefix of the message are unchanged - user macros and logs
may match on it - because a typo in a volume name still has to be reported; that is precisely how
this defect used to present itself.

Both scenes keep the offending volume in the macro; only the build differs:

scene before after
stray-volume trees ['Hits_STRAY', 'Hits_layer0'] ['Hits']
stray-volume-with-coincidences no data.root, sorter error in the log ['Hits'], Singles 9 270, Coincidences 2 907, no error

No regression for legitimate multi-detector configurations: a two-layer scene recomputed from
scratch still gives two trees (Hits_layer0, Hits_layer1) with unchanged content - 139 220 and
58 822 rows, all columns identical.

6. Documentation

docs/data_output_management.rst gains two sections:

  • Analysis output modules - what they are for, the rule that exactly one of analysis,
    fastanalysis and multianalysis has to be enabled whenever Singles or Coincidences are written,
    a table of the three, and a subsection on GateMultiPhotonAnalysis with the three deliberate
    differences from GateAnalysis. Before this work the string multianalysis did not appear
    anywhere in docs.
  • Branches describing the decay and the interaction counters - which tree carries which names,
    where each value comes from, the merging rules, and the note that the unified tree output writes
    four of the five for hits only.

docs/source_and_particle_management.rst no longer restricts those fields to the Hits tree. One
commit is comments only: it replaces the stale name ExtendedVSource with PositroniumSource in
GateDigi.hh and GateRootDefs.hh, including the pre-existing comments of the hit buffer.

Deliberate changes of behaviour

Two items for the release notes (a third, the command rename, belongs to #773):

  1. nInteractions has a new definition. It used to count every process other than
    Transportation, which made it equal to the number of previous hits of the track. It now counts
    Compton and Rayleigh scatterings along the photon path, the current hit included; -1 means "not
    counted", which is what the analysis and fastanalysis paths leave. The distribution moves
    accordingly - mean 1.52 before, 1.79 after - and one test exists purely to make that change
    explicit between the two builds.
  2. photonID is always 0 on the multi-photon path. In GateAnalysis it is the index (1 or 2)
    of the annihilation gamma - a numbering with no meaning for three gammas plus a prompt gamma. The
    field is not propagated beyond the Hits tree; trackID identifies the photon instead.

Everything else is additive: new fields, new branches, and code paths that used to produce nothing
now produce data. ROOT files written by older builds still read - the new branches are bound only
when they exist.

Verification

The repository linked above contains 15 simulation configurations run in both analysis variants
against this branch, 14 of them also recorded on the commit the branch started from, plus dedicated
scenes for the coincidence sorter and the sensitive-detector defect. Thirteen tests read the
resulting trees:

  • four record the state before the fixes and stay green, so the defects they describe cannot
    come back unnoticed,
  • one compares the same analysis between the two builds, to make the changed definition of
    nInteractions explicit,
  • eight are the acceptance criteria of the fixes.
git clone https://github.com/MateuszBala/opengate-gate-multiphoton-analysis-verification.git
cd opengate-gate-multiphoton-analysis-verification
make env
cp external/scripts/environment.sh.template .environment.sh   # set GEANT4 and ROOT
make clone-gate
make build-ref-develop
make build-ref-fix
make run-set SET_DIR=simulations/ref-develop/gate-analysis
make run-set SET_DIR=simulations/ref-develop/gate-multi-photon-analysis
make run-set SET_DIR=simulations/ref-fix/gate-analysis
make run-set SET_DIR=simulations/ref-fix/gate-multi-photon-analysis
make run-attach-sd-sims
make run-multiples-policy-sims
make tests

Per-defect descriptions, each with its symptom, cause, fix, before/after comparison and the exact
commands to reproduce it:
https://github.com/MateuszBala/opengate-gate-multiphoton-analysis-verification/blob/main/docs/bugs/README.md

Not in this pull request

One fix of the same series is still open on the fork and will follow as a separate pull request:
the interaction counters restart at every hit collection, so in a detector built from more than one
sensitive volume a hit in the second layer is counted as if the scatterings in the first layer had
never happened -
bug-11.
It builds on the counting fix in this pull request, which is why it comes after it.

Two further items are documented in that repository and deliberately left out of the series:

  • initialising the remaining plain-data members of GateHit - preventive rather than corrective, as
    in no configuration available today do those fields reach the output unfilled, and it touches
    every sensitive detector and every output module,
  • a segmentation violation in G4RunManager::DeleteUserInitializations() during application
    shutdown. It reproduces identically on the commit this work started from, so it is not a
    consequence of these changes; the data is already written and closed when it happens.

@wkrzemien wkrzemien changed the title [WIP] adding handling of decayIndex and other variables used by GatePositroniumSource in the context of Singles and Coincidences Adding handling of decayIndex and other variables used by GatePositroniumSource in the context of Singles and Coincidences Sep 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants