Skip to content

feat(event-display,ui): Add a loader for ATLAS ESD/AOD files - #975

Merged
EdwardMoyse merged 12 commits into
mainfrom
add-esd-loader
Aug 16, 2026
Merged

feat(event-display,ui): Add a loader for ATLAS ESD/AOD files#975
EdwardMoyse merged 12 commits into
mainfrom
add-esd-loader

Conversation

@EdwardMoyse

Copy link
Copy Markdown
Member

This adds a new loader which reads ROOT ESD/AOD files.

There are some limitations: since we do not have access to the geometry, we cannot look up e.g. calo cell positions, so it relies on the same hack as with JiveXML, to place them on a cylinder. And for PRDs there's just nothing to do - we only have local positions and so these cannot be shown. For tracks, we rely on Curvilinear track parameters, since only these have global positions.

Entirely vibe coded with claude BTW.

EdwardMoyse and others added 12 commits August 16, 2026 21:29
ATLAS ESD/AOD samples are hundreds of MB and are never committed. Ignoring
them also keeps 'git clean -fd' from deleting a local test file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jsroot cannot open any ROOT file compressed with LZMA, which is what ATLAS
writes ESD and AOD with. modules/base/lzma.mjs skipped a hard-coded 29-byte
prefix and ran the LZMA1 core over the remainder in a single pass. That is
only valid when the xz block holds exactly one LZMA2 chunk, and a chunk caps
at 2 MiB uncompressed / 64 KiB compressed, so anything larger failed with
"corrupted input". An ATLAS ESD CollectionTree is ~2.2 MB in two chunks, and
the second inherits the decoder state and dictionary from the first rather
than resetting them.

scripts/jsroot-patch/lzma.mjs is a drop-in replacement, kept whole so the
same file can go upstream. The LZMA1 core is untouched; what changed is:

- the xz stream and block headers are parsed instead of assumed, so blocks
  with stored sizes or a non-default check size work;
- decompress() drives the LZMA2 chunk sequence, carrying decoder state,
  probability models and the dictionary window across chunks according to
  each chunk's reset flags, and loops over blocks for streams that have
  more than one;
- the dictionary size comes from the block header rather than a fixed 8 MiB,
  clamped to the output size, and the window is a Uint8Array instead of a
  holey Array reallocated on every call.

Installed over node_modules by the existing postinstall hook, which is
renamed from patch-jsroot-three.js since it now applies two patches. A
missing payload warns rather than failing the install, and the file carries
a marker so an older copy of the patch is upgraded in place.

Verified against a real ATLAS ESD: all 959 LZMA blocks (32.7 MB, 11 of them
multi-chunk, up to 21 chunks) decode byte-identical to xz, plus 96 synthetic
streams across presets 0/1/6/9e, every check type, and threaded multi-block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads an ATLAS ESD (.pool.root) with jsroot and converts it to Phoenix event
data, alongside the existing PHYSLITE loader.

The formats differ in more than size. PHYSLITE puts each variable in its own
flat branch (AnalysisJetsAuxDyn.pt); an ESD keeps kinematics in the static
auxiliary store, a single unsplit branch holding a streamed C++ object whose
members are the aux vectors, and prefixes branch names with the persistent
class and version:

  xAOD::TrackParticleAuxContainer_v5_InDetTrackParticlesAux.

So branches are matched by pattern and looked up by aux class family, which
keeps the loader working when a container is rewritten at a newer _vN. A
container allow-list keeps the dozens of trigger and HLT containers an ESD
also carries out of the collections menu; it is the documented extension
point, since read cost is per container rather than per field.

Covers tracks, vertices, calo clusters, MET, electrons, photons, muons, jets
and taus. Unlike PHYSLITE the ESD supplies chiSquared/numberDoF and
vertexType, emitted as chi2/dof/vertexType so the existing cut sliders in
object-type-registry pick them up.

Two details worth flagging:

- MET is a container of terms (RefEle, RefJet, ... FinalTrk, FinalClus), so
  the total is selected by name rather than by index.
- PhoenixLoader.getCompound derives charge as `pdgId > 0 ? 1 : -1`, reading
  the sign of pdgId as the sign of the charge, which is inverted from the
  PDG convention for leptons. Where the ESD gives a charge, pdgId is signed
  to match Phoenix's convention so extrapolated tracks bend correctly.

Not handled: POOL type/persistent-separated collections
(Trk::TrackCollection_tlp7_*), calorimeter cells, and ElementLink
resolution, so compound objects carry no LinkedTracks/LinkedClusters and
Phoenix extrapolates from their kinematics, exactly as for PHYSLITE.

Verified against a real ESD: 762 InDetTrackParticles, 93 GSF, 33 vertices,
483 topo clusters, 26 electrons, 20 photons, 77 jets, 37 taus in 0.2 s, all
building Three.js objects with no non-finite vertices and track extent
inside the inner-detector envelope.

The jsroot test mock gains TSelector and treeProcess so loaders that read
trees can be tested at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the ESD format to the IO options dialog and to the ATLAS section, so a
.pool.root can be opened from the menu.

Both ROOT formats accept .root and the dialog renders "Load {{ fileType }}",
which would have given two identically labelled buttons, so PHYSLITE's label
becomes "PHYSLITE (.root)" and the new one "ATLAS ESD (.root)". fileType is
display-only; the real filename check is isFileOfExtension.

Not added to the masterclass section, which is deliberately simplified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The loader silently dropped things, so a collection missing from the menu had
no explanation. It now tallies every skip with a reason and prints a summary
to the console once a load finishes:

  ATLASESDLoader: read 17 collection(s) from 1 event(s); skipped 3 event(s)
    Muons — empty in this event: 1 event(s)
    CombinedMuonTrackParticles — empty in this event: 1 event(s)
    ExtrapolatedMuonTrackParticles — empty in this event: 1 event(s)

Reasons are kept distinct so the summary is actionable, in particular
separating a container that is empty in this event from one that is not in
the file at all — the two most common causes of a collection not appearing.
Object-level skips are attributed to their container, and the track guard's
reasons are now reported separately rather than as one condition.

That guard also gains a finite check on d0/z0/phi. It already existed to keep
NaN out of the Runge-Kutta extrapolation, but only covered qOverP and theta,
so a non-finite perigee parameter would still have reached the renderer.

The resolveCollections warnings fold into the same summary instead of being
separate console.warn calls, so there is one place to look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The headline summed every tally and called the total "skipped", so a file
where two collections happened to be empty in the single event read reported:

  read 17 collection(s) from 1 event(s); skipped 2 event(s)

No events were skipped. The count was a number of events, but the thing
skipped was a collection, and rolling the two together produced a sentence
that was simply untrue.

Each unit is now counted on its own terms. Objects are summed, since dropping
12 tracks is worth stating as 12. Collections are counted once each, whether
unavailable outright or merely empty, and the event count becomes detail on
the line rather than a headline figure:

  read 17 collection(s) from 1 event(s); 3 collection(s) empty
    Muons — empty: 1/1 events
    CombinedMuonTrackParticles — empty: 1/1 events
    ExtrapolatedMuonTrackParticles — empty: 1/1 events

Reported by a user reading the output, who pointed out that one event was
read and none skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the POOL type/persistent-separated track collections alongside the xAOD
ones. These are a different shape entirely: parallel arrays joined by
TPObjRef ({m_typeID:{m_cnvID}, m_index}, where m_cnvID 0 means null), walked

  m_trackCollections[0] -> m_tracks -> m_trackState
    -> m_trackStates -> m_trackParameters -> m_parameters

with the perigee identified by m_surfaceType === 3. There is exactly one per
track; surface types 4 and 5 are measurement surfaces, and their transforms
are not persisted (m_surfaces and m_detElementSurfaces are empty), so no
measured polyline can be reconstructed and tracks are extrapolated from the
perigee just as the xAOD ones are.

Off by default. In practice each collection duplicates an xAOD TrackParticle
container — CombinedInDetTracks and InDetTrackParticles are the same 762
tracks, agreeing to |dtheta| 7e-7 and |dqOverP| 0 — and reading all 13 costs
~460 ms because an unsplit branch streams whole, including arrays never used
(m_hepSymMatrices alone is 32k entries). Opt in per collection:

  new ATLASESDLoader({ extraContainers: ['CombinedInDetTracks'] })

Note this leaves them API-only: the IO options dialog builds the loader with
no options, so they are not reachable from the menu yet.

The per-track guards and their skip reasons move into a shared makeTrack()
used by both converters, so there is still one place where a track can be
rejected. One new reason, 'no perigee parameters', covers a track whose
states yield no perigee.

Verified on a real ESD: CombinedInDetTracks 762, GSFTracks 93,
ResolvedForwardTracks 26, MuonSpectrometerTracks 1, the other nine empty and
reported as such; 882 tracks render with 0 non-finite vertices.

CaloCells and PrepRawData were requested too but are not included: both store
only local coordinates plus detector identifiers, so placing them in 3D needs
the ATLAS detector description that the ESD does not carry. The class TSDoc
records the specifics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Correcting an earlier claim: I said no measured polyline could be
reconstructed because the surface transforms are not persisted. That is only
true of parameters expressed on a detector-element surface. Two cases do
carry global positions:

- Curvilinear parameters (m_surfaceType 6) store *seven* values rather than
  five — global position, global momentum and charge — because the frame is
  defined by the track itself. ResolvedForwardTracks has 78 of them, and the
  momentum direction matches its own perigee (identical theta, exact q/|p|).
- Plane parameters (m_surfaceType 4) sometimes carry their transform, 9
  row-major rotation elements plus a translation, so the global point is
  R * (loc1, loc2, 0) + T. MuonSpectrometerTracks has 18.

Points are collected in track-state order, which is trajectory order, and
consecutive duplicates are dropped because Phoenix fits a CatmullRomCurve3
through them and repeated points give NaN tangents. Line surfaces are
excluded on purpose: their first local parameter is a signed drift distance
perpendicular to the wire, not a Cartesian offset.

dparams is still emitted alongside pos, so cut values stay the perigee ones
rather than being derived from the first two points.

On the sample: MuonSpectrometerTracks 1/1 tracks now follow a 15-point,
8.3 m measured trajectory through the muon endcap (r 2948-3376 mm,
|z| 13.3-21.6 m), and ResolvedForwardTracks 17/26.

The measured extent is only as long as the fit, though, and that cuts both
ways — several forward tracks are 3-point ~250 mm stubs near the beamline,
which is truthful but shorter than the extrapolation it replaces. Hence the
new measuredPositions option (default true) to turn the whole behaviour off,
and a per-collection console line reporting how many tracks used it.

Reported by a user pointing at AtaSurfaceCnv_p1 in Athena.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They were registered but left out of the default allow-list, which in
practice made them unreachable: the IO options dialog constructs the loader
with no options, so nothing short of a code change could switch them on.
Adding them to the default list is what makes them visible in the menu.

The duplication that motivated leaving them out is real and unchanged —
CombinedInDetTracks and InDetTrackParticles are the same 762 tracks, so the
menu now shows both — and reading all 13 adds ~460 ms, taking the sample from
0.2 s to 0.7 s. What they buy is the collections with no xAOD counterpart and
the measured trajectories, which only exist on this side.

`containers` still excludes them for anyone who wants the xAOD view alone.

Three tests encoded the old behaviour and have been rewritten rather than
deleted: the POOL branch that stood in for "unreadable" is now a genuinely
unreadable pair (CaloCompactCellContainer, PixelClusterContainer), and the
opt-in test becomes its inverse plus one asserting the xAOD and Trk
collections coexist rather than shadowing each other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The allow-list was only discoverable by reading the source, so there was no
way to tell from a running app what else a file offered. The loader now lists
the containers it has a converter for but was not asked to read:

  ATLASESDLoader: 131 readable container(s) not in the allow-list; add any of
  these with the containers or extraContainers option
    CaloClusters   CaloCalFwdTopoTowers, ForwardElectronClusters, ...
    Electrons      ForwardElectrons, LRTElectrons
    Tracks         InDetForwardTrackParticles, InDetLargeD0TrackParticles, ...
    (86 trigger container(s) not listed: HLT_*, L1_*)

Two filters keep it useful rather than overwhelming. Containers with no
converter are never mentioned — there are 200 of them in the sample and
nothing could be done about any of them — and trigger containers are counted
rather than named, since 86 HLT and L1 entries would bury the 45 offline ones
anyone is likely to want.

Kept separate from the skip tally so the "skipped" counts continue to mean
what they say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single MuonSpectrometerTracks track was loaded but never drawn. Its
perigee carries qOverP = 1e-8, which is how a fit records that it did not
measure the momentum — a straight-line track. Taken literally that is |p| of
100 TeV and pT of 4.7 TeV, well outside the default pT cut of 50 GeV, so the
track was hidden the moment cuts were applied, with nothing to say why.

pT is now emitted only when the momentum is physically possible; no LHC track
approaches the ~7 TeV beam energy, so anything above 10 TeV is a sentinel.
Tracks without one carry momentumMeasured: false instead. Cuts skip fields
that are absent, so the pT cut simply does not apply to such a track rather
than hiding it, and the collection reports the case on load.

This was not specific to Trk::TrackCollection: MuonSpectrometerTrackParticles
holds the identical value and has been invisible since the loader was first
written. Both are fixed, since both go through makeTrack.

Reported by a user who noticed the collection had one track that never
appeared, and that clearing the cuts did not bring it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI enforces 100% documentation coverage via compodoc and this one interface
member had no comment, taking the package to 99%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@EdwardMoyse
EdwardMoyse merged commit 9654fb6 into main Aug 16, 2026
7 checks passed
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.

1 participant