Skip to content

Phase III: decouple truth (TMS_TrueHit/TMS_TrueParticle) from TMS_Hit/TMS_Track - #301

Open
SFBayLaser wants to merge 7 commits into
phase2-detsim-class-splitfrom
phase3-truth-decoupling
Open

Phase III: decouple truth (TMS_TrueHit/TMS_TrueParticle) from TMS_Hit/TMS_Track#301
SFBayLaser wants to merge 7 commits into
phase2-detsim-class-splitfrom
phase3-truth-decoupling

Conversation

@SFBayLaser

Copy link
Copy Markdown
Member

Fourth and final step of the real-data restructuring proposal (see #298/#299/#300 for Phases 1-2, which this builds on; also discussed in #nd_muon_spectrometer_code). Heads-up issue: #294.

Pulls truth (TMS_TrueHit) out of TMS_Hit and dead truth-particle scaffolding out of TMS_Track, so reconstruction types can represent a hit/track with no truth at all (real data has none). Truth now lives in an event-level side table (TMS_Event::TrueHitByHitId), keyed by a stable per-hit id that survives sorting/merging/slicing -- mirrors LArSoft's Hit/SimChannel association pattern, since no real DAQ format has been specified for TMS yet.

Done in three staged commits:

  1. Additive dual-write: TMS_Hit keeps its embedded TrueHit and gains a HitId + side-table entry, so both paths can be cross-checked.
  2. Remove the embedded TMS_TrueHit member from TMS_Hit entirely, forcing every remaining call site through the side table.
  3. A synthetic real-data smoke test (RealDataSmokeTest.cpp): builds hits directly from real detector geometry (no G4/truth involved at all), asserts they have no truth, and runs them through the full merge -> time-slice -> track-find -> tree-write pipeline end to end.

Plus a follow-up fix for a real bug the smoke test's deadtime stress-test uncovered: event overlay (pileup, AddEvent()) wasn't carrying per-hit truth across combined events -- each event's HitIds were independently zero-based, so overlaying could either collide with an unrelated hit's truth or leave a dangling reference, causing a real segfault under Deadtime/ZombieTime settings that exercise the affected code more heavily than the default config.

Verification: build clean. Full-spill run bit-identical against the pre-refactor baseline at each stage (including a targeted array-level check of every truth/reco field, not just distribution stats), and again after the AddEvent() fix -- both against the pre-refactor baseline and against the pre-fix buggy output. Deadtime forced on (Deadtime=500, ZombieTime=20) runs clean with no crash across all 9 overlaid spills.

Closes #294.

SFBayLaser and others added 5 commits August 13, 2026 11:47
First step of truth/reco decoupling (Phase III of the real-data
restructuring proposal). Adds a stable per-hit HitId (TMS_Hit) and an
event-level truth side table keyed by it (TMS_Event::TrueHitByHitId),
following LArSoft's Hit/SimChannel association pattern rather than a
positional-index array -- hits get sorted/merged/sliced throughout the
pipeline, and a stable ID survives all of that for free while a
positional index would need fragile manual reindexing at every mutation
site.

TMS_Hit's embedded TMS_TrueHit member is deliberately left in place this
round (dual-write): TMS_Event::ProcessTG4Event() populates both the
embedded member and the new side table from the same source data, and
TMS_SignalProcessing::MergeCoincidentHits() runs a parallel by-ID merge
alongside the existing embedded-value merge inside TMS_Hit::MergeWith().
This lets the two paths be cross-checked against each other before the
embedded member is removed in a follow-up commit.

Every consumer (TMS_TreeWriter, TMS_ReadoutTreeWriter, TMS_DetectorSimulation,
TMS_Kalman, TMS_Utils::GetPrimaryIdsByEnergy, DrawEvents, CherryPickEvents)
now reads truth via the new side table with null-checks where one didn't
already exist, so a hit with no truth (e.g. eventually real data) degrades
to skipped/sentinel output instead of undefined behavior.
TMS_Kalman's constructor and TMS_Utils::GetPrimaryIdsByEnergy() needed a
TMS_Event& threaded through from their one caller each.

Also deletes TMS_Track::fTrueParticle and GetTrueParticle() -- confirmed
completely dead (embedded by value, never set anywhere, never read
anywhere) -- along with the ~80-line hand-rolled copy constructor/
assignment operator that existed only to shepherd it around. The real
track-truth association already works close to the target pattern:
computed lazily at tree-write time from a track's hits, ranked by energy,
indexed into TMS_Event::TMS_TrueParticles.

Verified bit-identical against the pre-Phase-III baseline on a real MC
spill: identical slice/hit/track counts per spill, identical RecoHitBar/
TrueHitBar distributions, and a targeted field-by-field comparison of
TrueHitPE/PEAfterFibers*/E/X/Y/Z/T (the trickiest piece -- the write-then-
read round trip through the new side table inside TMS_DetectorSimulation)
all confirmed exactly identical, not just statistically close.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TMS_Hit no longer stores a TMS_TrueHit by value; truth now lives only
in TMS_Event::TrueHitByHitId (Stage A's side table), looked up by
HitId. This is what actually makes constructing a truthless TMS_Hit
possible, since TMS_TrueHit's default constructor is deleted.

TMS_Hit's constructor drops its edep-sim/vertex-id parameters
(reco-level fields only); ProcessTG4Event() now builds the TMS_TrueHit
separately and inserts it via SetTrueHit(). MergeWith() no longer
touches truth at all -- TMS_SignalProcessing::MergeCoincidentHits()
already merges the side-table entries by HitId (added in Stage A).
The four GetTrueDistanceFrom*() helpers move to TMS_DetectorSimulation.cpp
as free functions taking the true hit explicitly, since they no longer
have an embedded TrueHit to call through.

Verified bit-identical against the Stage A baseline: build produced no
new warnings, and a full sand_drift run (3878 entries) matches field-
by-field across Line_Candidates, Truth_Info, and the Readout tree
(RecoHitBar/Pos/Energy/PE, TrueHitBar/X/Y/Z/T/E/PE/PEAfterFibers*,
TrueLeptonic/HadronicEnergy, RecoTrackTrueVisibleEnergy, all 23 Readout
fields).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…a smoke test

Adds TMS_Hit(x, y, z, energy, time, pe) -- the real-data construction path
Stages A/B made possible but didn't yet add. It builds the bar via
TMS_Bar's existing-but-previously-uncalled (x, y, z) constructor (a pure
geometry lookup, no TG4HitSegment/truth involved) and sets reco-level
fields directly.

Adds app/RealDataSmokeTest.cpp, which exercises the "reco path runs with
the truth table entirely empty" claim end to end: it walks the geometry
tree directly to find one real bar-leaf position per plane (coordinate
guessing was tried first and reliably found nothing -- module daughters
only span their own module's local slot, not the full detector width),
builds truthless TMS_Hits at those positions, and runs them through
TMS_SignalProcessing::MergeCoincidentHits() -> TMS_TimeSlicer::RunTimeSlicer()
-> TMS_TrackFinder::FindTracks() -> TMS_TreeWriter::Fill(), confirming
TrueHitByHitId has zero entries throughout.

Verified via an actual run against a local edep-sim file (geometry source
only, its TG4Event/truth branches are never read): completes with no
crash, and the output ROOT file has zero NaN across every field in
Line_Candidates/Reco_Tree/Truth_Info, with all truth-derived fields
sitting at clean empty/zero sentinels rather than garbage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TMS_Event::AddEvent() (used by OverlayEvents() for pileup/spill overlay)
moved incoming hits into TMS_Hits without touching their HitId or
merging in the source event's TrueHitByHitId -- a gap in Phase III
Stage A's original design, invisible before Phase III since truth was
embedded directly in each TMS_Hit and moved with it automatically.

Each incoming hit's HitId was assigned by its own event's independent,
zero-based HitIdCounter, so after a naive move it either collides with
an unrelated hit already in the host event's TrueHitByHitId (silently
wrong truth) or exceeds anything the host has an entry for (a genuine
gap). Confirmed via a real crash: with Deadtime/ZombieTime forced on,
TMS_DetectorSimulation::SimulateOpticalModel() dereferenced a null
TMS_TrueHit* on an overlaid event's hit.

Fix: reassign a fresh HitId from the host event's own counter for each
incoming hit, and carry its truth (if any) over under that new id.

Verified: the deadtime-forced-positive run that previously crashed at
event ~387 now completes cleanly (3878/3878 entries, both output files
written). A default-config re-run is bit-identical, field for field, to
both the pre-Phase-III baseline and the pre-fix Stage A/B/C output for
the sand_drift file used throughout this validation -- this fix changes
nothing already verified correct, it only closes a real gap that this
specific dataset's default-config overlay pattern didn't happen to
trigger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
app/Makefile's target list is hand-maintained (no glob), so the new
Phase III smoke-test app was never actually built by the plain `make`
path (CI) -- only by CMake, which globs app/*.cpp into its own
per-target list separately.
@SFBayLaser
SFBayLaser force-pushed the phase2-detsim-class-split branch from 9dc3827 to f0b5a58 Compare August 13, 2026 18:47
@SFBayLaser
SFBayLaser force-pushed the phase3-truth-decoupling branch from dfe98a1 to 6d1ea6a Compare August 13, 2026 18:47
…geometry, not the detector-wide survey bbox

TMS_Geom's XBarLength()/YBarLength()/XBarPos|NegReadoutLocation()/
YBarReadoutLocation() previously derived a "bar length" from
GetXStartOfTMS()/GetXEndOfTMS()/GetYStartOfTMS()/GetYEndOfTMS() -- the
detector-wide bar-region bbox from the geometry survey -- treating
every bar as if it spanned the entire TMS. TMS_Bar already computes
each bar's own true length from its GDML shape (GetBarLength()).

This mattered because these functions feed TMS_DetectorSimulation's
light-attenuation optical model (GetTrueDistanceFromReadout() and
friends), so the survey bbox's precision was entangled with simulated
PE yield and, downstream, pedestal-suppression hit survival --
confirmed via temporary instrumentation showing CleanHits()'s z-bounds
rejection filter never fires on real data (zRejected=0), so the
hit-count sensitivity to the survey bbox traced entirely through this
path instead.

Pass each hit's own bar length through explicitly; formula shapes
(the pre-existing half-length-for-X vs full-length-for-Y asymmetry)
are preserved unchanged, only the data source moves from the
detector-wide survey to the specific bar.
@jdkio

jdkio commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

I ran a mini-validation with all the PRs. PR 301 is not like the others. The numbers change a lot. I checked 301's commit before 36a69e5275c6c10a701c13ee1881d7a6ef83ba5e and the numbers look more like the others, see pr-301-pre-bar-change. I'm not sure why it makes such a difference. Any ideas?

dune-tms open-PR mini-validation

Input: /exp/dune/data/users/jlarkin/tms/MiniProdN5p3_NDComplex_FHC.spill.full.sanddrift.0000001.EDEPSIM_SPILLS.root

target events n=1 n>=2 tracks mean tracks/event mean hits/track mean reco hits/event mean length mean Edep
main 908 310 116 578 0.6366 44.8858 75.5176 1348.1 187.6108
pr-298 914 (+0.66%) 323 (+4.19%) 115 (-0.86%) 582 (+0.69%) 0.6368 (+0.03%) 44.8110 (-0.17%) 74.6761 (-1.11%) 1351.9 (+0.28%) 184.8272 (-1.48%)
pr-299 911 (+0.33%) 315 (+1.61%) 114 (-1.72%) 573 (-0.87%) 0.6290 (-1.19%) 44.7260 (-0.36%) 74.9627 (-0.73%) 1348.5 (+0.03%) 186.5708 (-0.55%)
pr-300 911 (+0.33%) 315 (+1.61%) 114 (-1.72%) 573 (-0.87%) 0.6290 (-1.19%) 44.7260 (-0.36%) 74.9627 (-0.73%) 1348.5 (+0.03%) 186.5708 (-0.55%)
pr-301 960 (+5.73%) 297 (-4.19%) 92 (-20.69%) 514 (-11.07%) 0.5354 (-15.89%) 44.7957 (-0.20%) 70.8365 (-6.20%) 1370.1 (+1.63%) 190.6064 (+1.60%)
pr-302 908 (+0.00%) 310 (+0.00%) 116 (+0.00%) 578 (+0.00%) 0.6366 (+0.00%) 44.8858 (+0.00%) 75.5176 (+0.00%) 1348.1 (+0.00%) 187.6108 (+0.00%)
pr-305 908 (+0.00%) 310 (+0.00%) 116 (+0.00%) 578 (+0.00%) 0.6366 (+0.00%) 44.8858 (+0.00%) 75.5176 (+0.00%) 1348.1 (+0.00%) 187.6108 (+0.00%)
pr-301-pre-bar-change 911 (+0.33%) 316 (+1.94%) 113 (-2.59%) 572 (-1.04%) 0.6279 (-1.36%) 44.7675 (-0.26%) 74.9627 (-0.73%) 1348.3 (+0.02%) 186.7070 (-0.48%)

Targets

  • main at d3aae04a5da7238354a7b855ab34cabd091cde63
  • PR #298 phase1-geometry-survey at b95ebff4cd2959f134933aadab147b98e5f64525: Phase 1: derive TMS geometry bounds from a GDML survey instead of hardcoded constants
  • PR #299 phase2-detsim-fixes at dc8e09d59034249f3ed3ef08bc40745b66b3ffaf: Phase 2 (prep): fix rare det-sim crash cause, collapse double hit-merge workaround
  • PR #300 phase2-detsim-class-split at f0b5a58672facc782da0843ebb8eefaaabec888e: Phase 2: split TMS_Event's detector-sim pipeline into TMS_DetectorSimulation and TMS_SignalProcessing
  • PR #301 phase3-truth-decoupling at 36a69e5275c6c10a701c13ee1881d7a6ef83ba5e: Phase III: decouple truth (TMS_TrueHit/TMS_TrueParticle) from TMS_Hit/TMS_Track
  • PR #302 build-env-package-versions at b02a246ffbfc90f6098cd2985360cc1a5a8f3ff2: Update build environment to the current DUNE UPS product stack
  • PR #305 fix/kalman-node-chi2-uninitialized at ef2f37d1ea5bc5ff31ac7ec503cf074360a1c2a8: Fix uninitialized TMS_KalmanNode::chi2 read in Kalman fitting

@SFBayLaser

Copy link
Copy Markdown
Member Author

Interesting…
In theory (obviously), this PR (#301) should not result in any differences in truth matching and should also reproduce any reconstruction results from the previous PR. If it is not then that is a bit of a head scratcher… but I agree needs to be tracked down.
My baseline comparison was against the head of the main branch as of last Wednesday (August 12) and I think I put the results of that in the presentation of last Thursday. I can try to reproduce your table above (and will!)… but am away until Monday. So if it can wait until then I will get on it. I can rebaseline to whatever newer changes have occurred since last week and, obviously, my dev branch has all the updates in the PRs I submitted.
Thanks very much for looking!

@jdkio

jdkio commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

I think the positions of the bars are incorrect. Here's ChatGPT's interpretation of the breakdown:

The validation indicates that the large reconstruction change is introduced by the final per-bar optical-model commit. Before that commit (6d1ea6a), the reconstruction remains close to main, with all reported changes within approximately 2.6%. At the current PR head, reconstructed tracks decrease by about 11%, multi-track events by about 21%, and reconstructed hits per event by about 6%.

Looking more closely, the issue appears to be the coordinate assumption used when calculating the new readout positions. GetBarLength() correctly returns the individual bar length, but the new code assumes that every bar is centred at the detector’s global coordinate origin:

XBarPosReadoutLocation = +0.5 * barLength;
XBarNegReadoutLocation = -0.5 * barLength;
YBarReadoutLocation    = +0.5 * barLength;

That does not match the detector layout.

For X bars, x = 0 is the centre of the detector, but the approximately 3.2 m bars occupy the positive and negative halves separately. Their centres are therefore around x = ±1.6 m, while their outer readout ends are around x = ±3.2 m. The new calculation places the readout at the centre of each bar rather than at its outer end.

The Y geometry is also not centred around global y = 0. The existing geometry bounds place the top/readout side near y = +372 mm, whereas the new calculation places it near y = +1600 mm for a 3.2 m bar. This moves the readout by roughly 1.2 m and substantially changes the calculated attenuation distance. With the configured attenuation parameters, that can produce a large change in PE yield and push hits below the pedestal-suppression threshold.

The existing detector-wide calculation is approximate, particularly for individual and tilted bars, but it uses approximately the correct global readout locations. A proper per-bar implementation needs each bar’s global centre and orientation in addition to its length, for example:

readout position =
    global bar centre + direction toward readout × half the bar length

I suggest removing the per-bar optical-model commit from this truth-decoupling PR so that the PR retains its intended reconstruction behavior. We can then make a separate PR for the per-bar correction using the bar’s full global geometry. That will also let us validate the optical-model change independently with PE, hit-survival, and reconstruction comparisons.

…r, not origin-centering

36a69e5 correctly moved XBarLength()/YBarLength()/etc. from a
detector-wide survey bbox to each hit's own TMS_Bar::GetBarLength(),
but its readout-location formulas (XBarPosReadoutLocation() etc.)
computed a bar's readout end as +-0.5*barLength alone, implicitly
assuming every bar is centered at the detector's coordinate origin.
Bars aren't: e.g. an X-bar runs from the detector edge in to x=0, so
its readout end sits at roughly barLength (its own full extent from
origin), not half of it. Reviewer jdkio caught this via a mini-
validation across all four phase PRs: PR #301 alone tracked ~11-21%
away from main while the others stayed within ~1-2%, and traced it to
this commit.

Fix: TMS_Bar now captures its own global-frame center along its
readout axis (new AxisReadoutCenter member/GetAxisReadoutCenter(),
set in FindModules() right before the existing x/y sentinel-overwrite
that already discards that coordinate for other purposes) so the
three ReadoutLocation functions can compute barCenter +- 0.5*barLength
instead of +-0.5*barLength alone. XBarLength()/YBarLength() are left
untouched -- they're pure relative-length quantities with no need for
a global anchor.

Verified against the sand_drift spill file: aggregate reconstruction
metrics (event/track counts, multi-track fraction) now land within
~1-5% of main, matching the band the sibling phase PRs show, down
from the previous ~11-21% swing. No crash across all 3878 entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SFBayLaser

Copy link
Copy Markdown
Member Author

Thanks for catching this, @jdkio — your diagnosis was exactly right. XBarPosReadoutLocation()/XBarNegReadoutLocation()/YBarReadoutLocation() were computing ±0.5*barLength alone, which implicitly assumes every bar is centered at the detector's coordinate origin. It isn't, so that landed the "readout location" at a bar's own center instead of its actual readout end.

Fixed in ee5c735: TMS_Bar now captures its own global-frame center along its readout axis (AxisReadoutCenter, captured in FindModules() right before that coordinate gets sentinelled out for other purposes) and the three readout-location functions now compute barCenter ± 0.5*barLength instead of ±0.5*barLength alone. XBarLength()/YBarLength() didn't need to change — those are pure relative-length quantities, not absolute positions.

Sanity-checked the fix with some temporary instrumentation before trusting it: readout locations now land near the actual detector edges (X ~±3500mm, Y-top ~+398mm) instead of ~1600mm/~1750mm (bar centers).

Reran the same sand_drift file/mini-validation approach. Reproducing your table with a new row for the fixed head:

target events n=1 n>=2 tracks mean tracks/event mean hits/track mean reco hits/event mean length mean Edep
main 908 310 116 578 0.6366 44.8858 75.5176 1348.1 187.6108
pr-301 (36a69e5, broken) 960 (+5.73%) 297 (-4.19%) 92 (-20.69%) 514 (-11.07%) 0.5354 (-15.89%) 44.7957 (-0.20%) 70.8365 (-6.20%) 1370.1 (+1.63%) 190.6064 (+1.60%)
pr-301 (ee5c735, fixed) 896 (-1.32%) 312 (+0.65%) 110 (-5.17%) 557 (-3.63%) 0.6217 (-2.35%) 45.2136 (+0.73%) 76.4955 (+1.29%) 1387.0 (+2.88%) 195.1471 (+4.02%)
pr-301-pre-bar-change (for reference) 911 (+0.33%) 316 (+1.94%) 113 (-2.59%) 572 (-1.04%) 0.6279 (-1.36%) 44.7675 (-0.26%) 74.9627 (-0.73%) 1348.3 (+0.02%) 186.7070 (-0.48%)

Back in the same ~1-5% band as the sibling PRs and your pre-bar-change reference, down from the ~11-21% swing. No crashes across all 3878 entries.

I validated my own comparison script's metric definitions against your postPRs numbers first (matched within ~1-3%, consistent with the RNG-stream/DBSCAN-ordering noise already documented for this branch) before trusting the delta on the fixed run, so this should be an apples-to-apples comparison with your original table.

@SFBayLaser

Copy link
Copy Markdown
Member Author

PS I am away until Monday and will, unfortunately, miss today's TMS Studies meeting. If still issues I can work on addressing them when I am back.

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