Skip to content

Python API

from delphi_eventdisplay import read_event, build, build3d
from delphi_eventdisplay.schema import to_dict

e = read_event("Y13709.126.edm4hep.root", 3990)
fig = build(e, rich=[20, 22, 21], groups={20: ("φ → K⁺K⁻", "#d62728")})
fig.write_html("event.html")

build and build3d take an Event and return a plotly.graph_objects.Figure. Neither touches the filesystem, which is what lets the same code run unmodified in the browser under Pyodide.

Reading

delphi_eventdisplay.read

edm4hep -> a plain Event object.

Everything the display draws is a measured quantity read here; the decoding rules (VD strip coordinates, the perigee track state, AABTAG vertex acceptance) are the ones validated in the original matplotlib display, see README.

Event dataclass

One event, decoded. Lengths in mm, momenta in GeV, angles in rad.

Source code in src/delphi_eventdisplay/read.py
@dataclass
class Event:
    """One event, decoded. Lengths in mm, momenta in GeV, angles in rad."""

    path: str
    entry: int
    # global parameters
    gi: dict = field(default_factory=dict)
    gf: dict = field(default_factory=dict)
    # particles
    charge: np.ndarray = None
    momentum: np.ndarray = None  # (n, 3)
    p_abs: np.ndarray = None
    # perigee helix parameters per particle index: [x0, y0, z0, phi0, omega, tanLambda]
    par: dict = field(default_factory=dict)
    # vertex detector
    vd_x: np.ndarray = None  # r-phi hits, NaN where the hit is a z strip
    vd_y: np.ndarray = None
    vd_z: np.ndarray = None  # z-strip hit positions only
    vd_raw_z: np.ndarray = None  # the raw z column, per hit index (R*phi for type 0, z for type 1)
    vd_r: np.ndarray = None  # |x| = layer radius, every hit
    vd_phi: np.ndarray = None
    vd_type: np.ndarray = None
    vd_layers: list = field(default_factory=list)
    # electromagnetic showers
    sh_x: np.ndarray = None
    sh_y: np.ndarray = None
    sh_z: np.ndarray = None
    sh_e: np.ndarray = None
    r_em: float = 2100.0
    r_stop: float = 2100.0
    # vertices
    pv: np.ndarray = None
    svs: list = field(default_factory=list)
    sv_tracks: dict = field(default_factory=dict)  # particle index -> secondary vertex position
    # jets and thrust
    jets: list = field(default_factory=list)
    thrust: np.ndarray = None
    # track -> VD hit and track -> cluster links
    track_hits: dict = field(default_factory=dict)  # particle index -> VD hit indices
    clusters: list = field(default_factory=list)  # (particle index, x, y) of linked calorimeter clusters
    # RICH / hadron identification, particle index -> HAID parameter vector
    haid: dict = field(default_factory=dict)
    # what was in the file, and which collection each part of the display used
    schema: object = None
    roles: dict = field(default_factory=dict)

    @property
    def run(self):
        return self.gi.get("sDST_EVT_runNumber")

    @property
    def event(self):
        return self.gi.get("sDST_EVT_eventNumber")

    @property
    def n_charged(self):
        return self.gi.get("sDST_EVT_nCharged")

    @property
    def n_neutral(self):
        return self.gi.get("sDST_EVT_nNeutral")

    @property
    def b_field(self):
        v = self.gf.get("sDST_EVT_BField")
        return float(v[0]) if v is not None and len(v) else None

    @property
    def btag_event(self):
        v = self.gf.get("sDST_AABTAG_CombinedTagEvent")
        return float(v[0]) if v is not None and len(v) else None

read_event

read_event(path: str, entry: int, *, collections: dict | None = None, domains: dict | None = None) -> Event

Read one entry of an edm4hep file into an Event.

collections overrides which collection each part of the display draws, by role ("tracks", "secondary_vertices", ...); anything not given is resolved from the file's own podio metadata. See schema.ROLES.

Source code in src/delphi_eventdisplay/read.py
def read_event(path: str, entry: int, *, collections: dict | None = None, domains: dict | None = None) -> Event:
    """Read one entry of an edm4hep file into an Event.

    collections overrides which collection each part of the display draws, by role
    ("tracks", "secondary_vertices", ...); anything not given is resolved from the
    file's own podio metadata. See schema.ROLES."""
    f = uproot.open(path)
    t = f["events"]
    if not 0 <= entry < t.num_entries:
        raise IndexError(f"entry {entry} out of range: {path} holds {t.num_entries} events")
    ev = _Entry(t, entry)
    has, A = ev.has, ev

    schema = read_schema(path, entry=entry, domains=domains)
    roles = schema.resolve(collections)
    missing = [r for r in ("particles", "tracks") if r not in roles]
    if missing:
        raise SystemExit(f"{path}: no collection found for {', '.join(missing)} -- is this an edm4hep file from delphi-edm4hep?")
    # shadow the module defaults with what this file actually has
    P = roles["particles"]
    TRK = roles["tracks"]
    TS = f"_{TRK}_trackStates"
    SV = roles.get("secondary_vertices", "")
    PVA = roles.get("primary_vertex", "")
    JJ = roles.get("jets", "")
    EM = roles.get("em_clusters", "")
    VD = roles.get("vd_hits", "")
    HAID = roles.get("hadron_id", "")

    e = Event(path=path, entry=entry, schema=schema, roles=roles)

    # ---- global parameters (run/event numbers, B field, thrust axis, event b-tag)
    ik = [str(x) for x in A("GPIntKeys")]
    iv = A("GPIntValues")
    fk = [str(x) for x in A("GPFloatKeys")]
    fv = A("GPFloatValues")
    e.gi = {k: int(np.asarray(iv[i])[0]) for i, k in enumerate(ik)}
    e.gf = {k: np.asarray(fv[i], float) for i, k in enumerate(fk)}

    T = e.gf.get("sDST_AABTAG_ThrustAxis", np.array([1.0, 0.0, 0.0]))
    e.thrust = T / (np.linalg.norm(T) or 1.0)

    # ---- particles
    e.momentum = np.stack([A(f"{P}.momentum.{c}") for c in "xyz"], 1).astype(float)
    e.charge = A(f"{P}.charge")
    e.p_abs = np.linalg.norm(e.momentum, axis=1)
    npart = len(e.charge)

    # ---- perigee (AtIP) track state per charged particle
    sb, se = A(f"{TRK}.trackStates_begin"), A(f"{TRK}.trackStates_end")
    loc = A(f"{TS}.location")
    D0, Z0 = A(f"{TS}.D0").astype(float), A(f"{TS}.Z0").astype(float)
    F0, TL, OM = A(f"{TS}.phi").astype(float), A(f"{TS}.tanLambda").astype(float), A(f"{TS}.omega").astype(float)
    RX, RY, RZ = (A(f"{TS}.referencePoint.{c}").astype(float) for c in "xyz")
    ptb, pte, pti = A(f"{P}.tracks_begin"), A(f"{P}.tracks_end"), A(f"_{P}_tracks.index")
    for i in range(npart):
        if e.charge[i] == 0 or pte[i] <= ptb[i]:
            continue
        it = int(pti[ptb[i]])
        if it >= len(sb):
            continue
        for s in range(sb[it], se[it]):
            if loc[s] == LOC_AT_IP:
                # perigee -> a point on the helix: d0 is signed in the plane perpendicular to phi0
                e.par[i] = [RX[s] - D0[s] * np.sin(F0[s]), RY[s] + D0[s] * np.cos(F0[s]), RZ[s] + Z0[s], F0[s], OM[s], TL[s]]
                break

    # ---- electromagnetic showers
    if EM and has(f"{EM}.position.x"):
        e.sh_x, e.sh_y, e.sh_z = (A(f"{EM}.position.{c}").astype(float) for c in "xyz")
        e.sh_e = A(f"{EM}.energy").astype(float)
    else:
        e.sh_x = e.sh_y = e.sh_z = e.sh_e = np.array([])
    if len(e.sh_x):
        rr = np.hypot(e.sh_x, e.sh_y)
        e.r_em, e.r_stop = float(np.median(rr)), float(np.min(rr))

    # ---- vertex detector hits, in strip coordinates
    # type 0 (R-phi strip): x = layer radius R, z = R*phi, no z measurement
    # type 1 (z strip):     x = -R,             z = measured z, no phi measurement
    # Plotting the raw (x, y) instead would pile every hit up near phi = 0.
    if VD and has(f"{VD}.position.x"):
        hx, _hy, hz = (A(f"{VD}.position.{c}").astype(float) for c in "xyz")
        e.vd_type = A(f"{VD}.type")
    else:
        hx = hz = np.array([])
        e.vd_type = np.array([], int)
    e.vd_r = np.abs(hx)
    rphi = (e.vd_type == 0) & (e.vd_r > 0)
    e.vd_phi = np.where(rphi, hz / np.where(e.vd_r > 0, e.vd_r, 1.0), np.nan)
    e.vd_x = np.where(rphi, e.vd_r * np.cos(e.vd_phi), np.nan)
    e.vd_y = np.where(rphi, e.vd_r * np.sin(e.vd_phi), np.nan)
    e.vd_z = hz[(e.vd_type == 1) & (e.vd_r > 0)]
    e.vd_raw_z = hz
    # layer radii clustered out of the hits themselves
    rv = np.sort(e.vd_r[e.vd_r > 0])
    e.vd_layers = [float(np.median(g)) for g in np.split(rv, np.where(np.diff(rv) > 8)[0] + 1) if len(g) >= 3] if len(rv) else []

    # ---- vertices; an AABTAG secondary vertex counts only when ITSEC >= 0
    e.pv = (np.array([A(f"{PVA}.position.{c}")[0] for c in "xyz"], float)
            if PVA and has(f"{PVA}.position.x") and len(A(f"{PVA}.position.x")) else np.zeros(3))
    if not (SV and has(f"{SV}.position.x")):
        SV = None
    spar, spb = (A(f"_{SV}_parameters").astype(float), A(f"{SV}.parameters_begin")) if SV else (np.zeros(0), np.zeros(0, int))
    sxyz = np.stack([A(f"{SV}.position.{c}").astype(float) for c in "xyz"], 1) if len(spb) else np.zeros((0, 3))
    accepted = [k for k in range(len(spb)) if spar[spb[k]] >= 0]
    e.svs = [sxyz[k] for k in accepted]
    svb, sve, svi = (A(f"{SV}.particles_begin"), A(f"{SV}.particles_end"), A(f"_{SV}_particles.index")) if SV else (np.zeros(0, int),) * 3
    for k in accepted:
        for i in [int(x) for x in svi[svb[k]:sve[k]]]:
            if i in e.par:
                e.sv_tracks[i] = sxyz[k]

    # ---- track -> VD hit links
    thb, the, thi = A(f"{TRK}.trackerHits_begin"), A(f"{TRK}.trackerHits_end"), A(f"_{TRK}_trackerHits.index")
    for i in e.par:
        it = int(pti[ptb[i]])
        if it < len(thb):
            e.track_hits[i] = [int(j) for j in thi[thb[it]:the[it]] if 0 <= int(j) < len(e.vd_r)]

    # ---- track -> calorimeter cluster links, used for the curvature-sign check
    try:
        meta = f["podio_metadata"].arrays(["events___CollectionTypeInfo.collectionID", "events___CollectionTypeInfo.name"], entry_stop=1)
        cid = dict(
            zip(
                np.asarray(meta["events___CollectionTypeInfo.collectionID"][0]).tolist(),
                [str(x) for x in meta["events___CollectionTypeInfo.name"][0]],
            )
        )
        cb, ce = A(f"{P}.clusters_begin"), A(f"{P}.clusters_end")
        ci, cc = A(f"_{P}_clusters.index"), A(f"_{P}_clusters.collectionID")
        for i in e.par:
            for k in range(cb[i], ce[i]):
                nm = cid.get(int(cc[k]))
                if nm is None or not has(f"{nm}.position.x"):
                    continue
                cx, cy = float(A(f"{nm}.position.x")[ci[k]]), float(A(f"{nm}.position.y")[ci[k]])
                if np.hypot(cx, cy) >= 500:
                    e.clusters.append((i, cx, cy))
    except Exception:
        pass  # the sign check falls back to omega as stored

    # ---- jets
    if JJ and has(f"{JJ}.momentum.x"):
        for jx, jy, jz, je in zip(A(f"{JJ}.momentum.x"), A(f"{JJ}.momentum.y"), A(f"{JJ}.momentum.z"), A(f"{JJ}.energy")):
            e.jets.append((float(jx), float(jy), float(jz), float(je)))

    # ---- RICH / dE/dx hadron identification
    if HAID and has(f"{HAID}.parameters_begin"):
        hb, he = A(f"{HAID}.parameters_begin"), A(f"{HAID}.parameters_end")
        hp = A(f"_{HAID}_parameters").astype(float)
        hi = A(f"_{HAID}_particle.index")
        for r in range(len(hb)):
            e.haid[int(hi[r])] = hp[hb[r]:he[r]]

    return e

n_entries

n_entries(path: str) -> int
Source code in src/delphi_eventdisplay/read.py
def n_entries(path: str) -> int:
    return uproot.open(path)["events"].num_entries

Collection discovery

delphi_eventdisplay.schema

What is actually in this file: collections, their types, links and provenance.

The display used to pin one collection of each kind by name. A file offers many (three Track collections, seven Vertex, four Cluster, twenty-odd ParticleID), so instead we read podio's own metadata -- the same source the converter's collection map is built from -- and let the caller choose.

Domains are the one field podio does not carry; they come from the converter's published collection map when it is supplied, and are "unknown" otherwise.

Schema dataclass

Source code in src/delphi_eventdisplay/schema.py
@dataclass
class Schema:
    path: str
    collections: dict = field(default_factory=dict)  # name -> Collection
    parameters: list = field(default_factory=list)
    entries: int = 0

    def by_kind(self, kind: str):
        return [c for c in self.collections.values() if c.kind == kind]

    def populated(self):
        return {n: c for n, c in self.collections.items() if c.count}

    def resolve(self, overrides: dict | None = None) -> dict:
        """role -> collection name, from the preference lists (overrides win)."""
        overrides = overrides or {}
        out = {}
        for role, (kind, patterns) in ROLES.items():
            if overrides.get(role):
                out[role] = overrides[role]
                continue
            candidates = [c.name for c in self.by_kind(kind)]
            # a populated collection beats an empty one of the same rank
            for pat in patterns:
                hit = [n for n in candidates if fnmatch.fnmatch(n, pat)]
                hit.sort(key=lambda n: (self.collections[n].count == 0, n))
                if hit:
                    out[role] = hit[0]
                    break
        return out
resolve
resolve(overrides: dict | None = None) -> dict

role -> collection name, from the preference lists (overrides win).

Source code in src/delphi_eventdisplay/schema.py
def resolve(self, overrides: dict | None = None) -> dict:
    """role -> collection name, from the preference lists (overrides win)."""
    overrides = overrides or {}
    out = {}
    for role, (kind, patterns) in ROLES.items():
        if overrides.get(role):
            out[role] = overrides[role]
            continue
        candidates = [c.name for c in self.by_kind(kind)]
        # a populated collection beats an empty one of the same rank
        for pat in patterns:
            hit = [n for n in candidates if fnmatch.fnmatch(n, pat)]
            hit.sort(key=lambda n: (self.collections[n].count == 0, n))
            if hit:
                out[role] = hit[0]
                break
    return out

Collection dataclass

Source code in src/delphi_eventdisplay/schema.py
@dataclass
class Collection:
    name: str
    type: str
    kind: str  # short_type
    provenance: str = "unknown"
    domain: str = "unknown"
    count: int = 0  # objects in the event that was read
    links: list = field(default_factory=list)  # [(relation, target collection)]

read_schema

read_schema(path: str, entry: int | None = None, domains: dict | None = None) -> Schema

Every collection in the file, with per-event counts when an entry is given.

Source code in src/delphi_eventdisplay/schema.py
def read_schema(path: str, entry: int | None = None, domains: dict | None = None) -> Schema:
    """Every collection in the file, with per-event counts when an entry is given."""
    f = uproot.open(path)
    tree = f["events"]
    ids, names, types = _typeinfo(f)
    id_to_name = dict(zip(ids, names))
    prov = _provenance(f)
    domains = domains or {}

    s = Schema(path=path, entries=tree.num_entries)
    for n, t in zip(names, types):
        s.collections[n] = Collection(
            name=n, type=t, kind=short_type(t),
            provenance=prov.get(n, "unknown"), domain=domains.get(n, "unknown"),
        )

    keys = {k.rsplit("/", 1)[-1] for k in tree.keys()}  # drop uproot's "parent/" prefix
    if entry is not None:
        fields = keys
        _cache: dict = {}

        def ev_get(k):
            if k not in _cache:
                _cache[k] = np.asarray(tree[k].array(entry_start=entry, entry_stop=entry + 1, library="np")[0])
            return _cache[k]
        for n, c in s.collections.items():
            # a collection's size is the length of any of its own columns
            for suffix in (".x", "_x", ".energy", ".type", ".charge", ".PDG", ".value", ".index", ".position.x", ".momentum.x"):
                k = n + suffix
                if k in fields:
                    c.count = len(ev_get(k))
                    break
            else:
                own = [k for k in fields if k.startswith(n + ".")]
                if own:
                    c.count = len(ev_get(own[0]))
        # links: a relation branch is _<collection>_<relation>, carrying the target collection ID
        for n, c in s.collections.items():
            for k in keys:
                base = k.split(".")[0]
                if not base.startswith(f"_{n}_"):
                    continue
                relation = base[len(n) + 2:]
                cid = f"{base}.collectionID"
                if cid not in fields:
                    continue
                try:
                    targets = {int(x) for x in ev_get(cid)}
                except Exception:
                    continue
                for t_id in targets:
                    tgt = id_to_name.get(t_id)
                    if tgt and (relation, tgt) not in c.links:
                        c.links.append((relation, tgt))
    return s

to_dict

to_dict(s: Schema, roles: dict | None = None) -> dict

The shape the web page reads: same vocabulary as the converter's collection map.

Source code in src/delphi_eventdisplay/schema.py
def to_dict(s: Schema, roles: dict | None = None) -> dict:
    """The shape the web page reads: same vocabulary as the converter's collection map."""
    return {
        "path": s.path.split("/")[-1],
        "entries": s.entries,
        "roles": roles or {},
        "collections": {
            c.name: {
                "type": c.type, "kind": c.kind, "domain": c.domain,
                "provenance": c.provenance, "count": c.count,
                "links": [{"relation": r, "to": t} for r, t in c.links],
            }
            for c in s.collections.values()
        },
        "kinds": sorted({c.kind for c in s.collections.values()}),
        "domains": sorted({c.domain for c in s.collections.values()}),
    }

load_domains

load_domains(map_json: str | None) -> dict

collection -> domain, from the converter's published collection map.

Source code in src/delphi_eventdisplay/schema.py
def load_domains(map_json: str | None) -> dict:
    """collection -> domain, from the converter's published collection map."""
    if not map_json:
        return {}
    import json
    from pathlib import Path

    p = Path(map_json)
    if not p.exists():
        return {}
    m = json.loads(p.read_text())
    # accepts either the converter's collection_map.json or our flat {"domains": {...}} table
    if "domains" in m and isinstance(m["domains"], dict):
        return dict(m["domains"])
    return {n: c.get("domain", "unknown") for n, c in m.get("collections", {}).items()}

short_type

short_type(t: str) -> str

edm4hep::TrackCollection -> Track.

Source code in src/delphi_eventdisplay/schema.py
def short_type(t: str) -> str:
    """edm4hep::TrackCollection -> Track."""
    return t.replace("edm4hep::", "").removesuffix("Collection")

Geometry

delphi_eventdisplay.geometry

Helices, the curvature-sign check and where each track starts.

The display draws nothing it has not measured: a track is the helix of its own perigee parameters, started at the vertex it belongs to, and the part between the IP and that vertex is drawn dotted so the picture never claims a path the particle did not travel.

helix

helix(p, s, sign=1.0)

Point(s) at arc length s on the helix with perigee parameters p.

p = [x0, y0, z0, phi0, omega, tanLambda]; omega is the signed curvature, so the sign argument lets the caller flip it (see curvature_sign).

Source code in src/delphi_eventdisplay/geometry.py
def helix(p, s, sign=1.0):
    """Point(s) at arc length s on the helix with perigee parameters p.

    p = [x0, y0, z0, phi0, omega, tanLambda]; omega is the signed curvature, so the
    sign argument lets the caller flip it (see curvature_sign)."""
    x0, y0, z0, f0, w, tl = p
    w = w * sign
    if abs(w) < 1e-12:  # straight line
        return x0 + s * np.cos(f0), y0 + s * np.sin(f0), z0 + s * tl
    th = s * w
    return x0 + (np.sin(f0 + th) - np.sin(f0)) / w, y0 - (np.cos(f0 + th) - np.cos(f0)) / w, z0 + s * tl

track_to

track_to(p, rmax, sign=1.0, n=900, s0=0.0)

The helix from arc length s0 out to radius rmax, at most half a turn.

Source code in src/delphi_eventdisplay/geometry.py
def track_to(p, rmax, sign=1.0, n=900, s0=0.0):
    """The helix from arc length s0 out to radius rmax, at most half a turn."""
    w = abs(p[4])
    smax = rmax * 1.7 if w < 1e-12 else min(rmax * 1.7, np.pi / w)
    s = np.linspace(s0, s0 + smax, n)
    x, y, z = helix(p, s, sign)
    r = np.hypot(x, y)
    k = int(np.argmax(r >= rmax)) if np.any(r >= rmax) else len(s) - 1
    return x[: k + 1], y[: k + 1], z[: k + 1], r[: k + 1]

curvature_sign

curvature_sign(e: Event, verbose=False)

+1 or -1: which sign of omega actually bends tracks onto their own calorimeter clusters.

Re-checked per event rather than hard-coded, because the convention has moved between converter versions. Returns (sign, message).

Source code in src/delphi_eventdisplay/geometry.py
def curvature_sign(e: Event, verbose=False):
    """+1 or -1: which sign of omega actually bends tracks onto their own calorimeter clusters.

    Re-checked per event rather than hard-coded, because the convention has moved between
    converter versions. Returns (sign, message)."""
    if not e.clusters:
        return 1.0, "curvature check: no track-cluster links found, using omega as stored"
    dist = {1.0: [], -1.0: []}
    s = np.linspace(0, 4000, 4001)
    for i, cx, cy in e.clusters:
        rc = np.hypot(cx, cy)
        for sg in (1.0, -1.0):
            x, y, _ = helix(e.par[i], s, sg)
            j = int(np.argmin(np.abs(np.hypot(x, y) - rc)))
            dist[sg].append(np.hypot(x[j] - cx, y[j] - cy))
    m1, m2 = np.median(dist[1.0]), np.median(dist[-1.0])
    sign = 1.0 if m1 <= m2 else -1.0
    msg = f"curvature check on {len(dist[1.0])} track-cluster links: median miss {m1:.0f} mm (omega as stored) vs {m2:.0f} mm (flipped) -> sign {sign:+.0f}"
    return sign, msg

start_points

start_points(e: Event, vtx: dict, sign=1.0)

Arc length at which to start each track, and where its dotted back-extrapolation begins.

A track on a vertex starts at that vertex; otherwise it starts at the primary vertex when it passes within 1 mm of it, and at its own perigee when it does not.

Source code in src/delphi_eventdisplay/geometry.py
def start_points(e: Event, vtx: dict, sign=1.0):
    """Arc length at which to start each track, and where its dotted back-extrapolation begins.

    A track on a vertex starts at that vertex; otherwise it starts at the primary vertex when it
    passes within 1 mm of it, and at its own perigee when it does not."""
    s_grid = np.linspace(-80.0, 250.0, 33001)
    s0, s_pv, miss = {}, {}, []
    for i in e.par:
        xs, ys, zs = helix(e.par[i], s_grid, sign)
        jpv = int(np.argmin(np.hypot(xs - e.pv[0], ys - e.pv[1])))
        d_pv = float(np.hypot(xs[jpv] - e.pv[0], ys[jpv] - e.pv[1]))
        if i in vtx:
            v = vtx[i]
            d = (xs - v[0]) ** 2 + (ys - v[1]) ** 2 + (zs - v[2]) ** 2
            jv = int(np.argmin(d))
            s0[i] = float(s_grid[jv])
            s_pv[i] = float(min(s_grid[jpv], s_grid[jv]))
            miss.append(1000 * np.sqrt(d[jv]))
        else:
            s0[i] = float(s_grid[jpv]) if d_pv < 1.0 else 0.0
    n_pv = sum(1 for i in e.par if i not in vtx and abs(s0[i]) > 0)
    msg = (
        f"track start points: {len(vtx)} at their secondary vertex (miss {min(miss, default=0):.0f}-{max(miss, default=0):.0f} um), "
        f"{n_pv} at the primary vertex, {len(e.par) - len(vtx) - n_pv} at their perigee (pass > 1 mm from the PV in r-phi)"
    )
    return s0, s_pv, msg

fit_vertex

fit_vertex(e: Event, ids, sign=1.0)

Least-squares crossing point of the IP direction lines of the given tracks.

Our own straight-line fit, not a DELPHI vertex: the figure labels it as such.

Source code in src/delphi_eventdisplay/geometry.py
def fit_vertex(e: Event, ids, sign=1.0):
    """Least-squares crossing point of the IP direction lines of the given tracks.

    Our own straight-line fit, not a DELPHI vertex: the figure labels it as such."""
    ids = [i for i in ids if i in e.par]
    if len(ids) < 2:
        return None
    lines = []
    for i in ids:
        x0, y0, z0, f0, _w, tl = e.par[i]
        lam = np.arctan(tl)
        lines.append((np.array([x0, y0, z0]), np.array([np.cos(f0) * np.cos(lam), np.sin(f0) * np.cos(lam), np.sin(lam)])))
    M, b = np.zeros((3, 3)), np.zeros(3)
    for p, u in lines:
        T = np.eye(3) - np.outer(u, u)
        M += T
        b += T @ p
    xv = np.linalg.solve(M, b)
    rms = 1000 * np.sqrt(np.mean([np.linalg.norm((xv - p) - ((xv - p) @ u) * u) ** 2 for p, u in lines]))
    return xv, ids, rms

vd_residuals

vd_residuals(e: Event, sign=1.0)

Check the VD strip decoding against each track's own crossing of the layer.

A median r-phi residual of a few microns is what says the strip decoding is right; plotting the raw (x, y) instead would show hundreds of mm here.

Source code in src/delphi_eventdisplay/geometry.py
def vd_residuals(e: Event, sign=1.0):
    """Check the VD strip decoding against each track's own crossing of the layer.

    A median r-phi residual of a few microns is what says the strip decoding is right;
    plotting the raw (x, y) instead would show hundreds of mm here."""
    res_rphi, res_z = [], []
    s = np.linspace(-50.0, 400.0, 9001)
    for i, hits in e.track_hits.items():
        xs, ys, zs = helix(e.par[i], s, sign)
        rs = np.hypot(xs, ys)
        for j in hits:
            if e.vd_r[j] <= 0:
                continue
            k = int(np.argmin(np.abs(rs - e.vd_r[j])))
            if e.vd_type[j] == 0:
                res_rphi.append(1000.0 * e.vd_r[j] * ((e.vd_phi[j] - np.arctan2(ys[k], xs[k]) + np.pi) % (2 * np.pi) - np.pi))
            elif e.vd_type[j] == 1:
                res_z.append(float(e.vd_raw_z[j] - zs[k]))
    msg = (
        f"VD hit decoding check: r-phi hits vs own track |d(R*phi)| median "
        f"{np.median(np.abs(res_rphi)) if res_rphi else float('nan'):.0f} um (n={len(res_rphi)}); "
        f"z hits |dz| median {np.median(np.abs(res_z)) if res_z else float('nan'):.2f} mm (n={len(res_z)})"
    )
    return res_rphi, res_z, msg

impact_parameter

impact_parameter(e: Event, i, sign=1.0)

Closest approach of track i to the primary vertex in r-phi [mm].

Source code in src/delphi_eventdisplay/geometry.py
def impact_parameter(e: Event, i, sign=1.0):
    """Closest approach of track i to the primary vertex in r-phi [mm]."""
    w = abs(e.par[i][4])
    smx = min(3000.0, np.pi / w) if w > 1e-12 else 3000.0
    xs, ys, _ = helix(e.par[i], np.linspace(-smx, smx, 20001), sign)
    return float(np.min(np.hypot(xs - e.pv[0], ys - e.pv[1])))

Figures

delphi_eventdisplay.figure

Build the four-panel DELPHI event display as one Plotly figure.

Panels, all from measured quantities only: (a) r-phi, whole event (b) r-z, r signed by thrust hemisphere (c) vertex detector r-phi -- zoom into the vertex region with the mouse (d) RICH Cherenkov angle vs momentum, liquid and gas radiators

What Plotly buys over the matplotlib original: hover that names every track, a legend that switches classes of object on and off, and a zoom that replaces the fixed inset.

build

build(e: Event, *, groups: dict | None = None, rich: list | None = None, fit_vertices: list | None = None, hide: dict | None = None, jets: bool = False, title: str = '', note: str = '', width: int = 1500, height: int = 1450) -> go.Figure

Assemble the figure. groups maps particle index -> (label, colour).

Source code in src/delphi_eventdisplay/figure.py
def build(
    e: Event,
    *,
    groups: dict | None = None,
    rich: list | None = None,
    fit_vertices: list | None = None,
    hide: dict | None = None,
    jets: bool = False,
    title: str = "",
    note: str = "",
    width: int = 1500,
    height: int = 1450,
) -> go.Figure:
    """Assemble the figure. groups maps particle index -> (label, colour)."""
    groups = groups or {}
    rich = rich or []
    fit_vertices = fit_vertices or []
    hide = hide or {}

    sign, _ = G.curvature_sign(e)
    vtx = dict(e.sv_tracks)
    for xv, ids, _rms in fit_vertices:
        for i in ids:
            vtx[i] = xv
    s0, s_pv, _ = G.start_points(e, vtx, sign)

    drawn = [i for i in e.par if i not in hide]
    order = sorted(drawn, key=lambda i: i in groups)  # highlighted tracks drawn last, on top
    LIM = e.r_em + 450.0
    ZLIM = 3000.0
    RVD = (max(e.vd_layers) if e.vd_layers else 110.0) + 17.0
    Tt = e.thrust[:2] / (np.linalg.norm(e.thrust[:2]) or 1.0)
    side = lambda x, y: 1.0 if (x * Tt[0] + y * Tt[1]) >= 0 else -1.0

    fig = make_subplots(
        rows=4,
        cols=2,
        specs=[[{"rowspan": 2}, {"rowspan": 2}], [None, None], [{"rowspan": 2}, {}], [None, {}]],
        subplot_titles=(
            "<b>(a)</b>  r-φ view",
            "<b>(b)</b>  r-z view",
            "<b>(c)</b>  vertex detector, r-φ: layers and hits — zoom into the vertex region",
            "<b>(d)</b>  RICH Cherenkov angle — liquid radiator (C<sub>6</sub>F<sub>14</sub>)",
            "gas radiator (C<sub>5</sub>F<sub>12</sub>)",
        ),
        horizontal_spacing=0.085,
        vertical_spacing=0.075,
    )

    seen = set()  # legend entries already emitted

    def legend_once(key):
        first = key not in seen
        seen.add(key)
        return first

    # ---------------- detector outlines
    th = np.linspace(0, 2 * np.pi, 241)
    for r in e.vd_layers:
        for rc, cc in ((1, 1), (3, 1)):
            fig.add_trace(
                go.Scatter(
                    x=r * np.cos(th), y=r * np.sin(th), mode="lines", line=dict(color=VDC, width=1), opacity=0.6,
                    name="VD layers (radii from hits)", legendgroup="vd", showlegend=legend_once("vd"),
                    hoverinfo="text", text=f"VD layer, R = {r:.1f} mm",
                ), row=rc, col=cc,
            )
    fig.add_trace(
        go.Scatter(
            x=e.r_em * np.cos(th), y=e.r_em * np.sin(th), mode="lines", line=dict(color=HPCC, width=1.4, dash="dash"),
            name=f"EM calorimeter (HPC) at r = {e.r_em / 1000:.2f} m", legendgroup="hpc", showlegend=legend_once("hpc"),
            hoverinfo="text", text=f"HPC, r = {e.r_em:.0f} mm (from shower positions)",
        ), row=1, col=1,
    )
    if len(e.vd_z):
        zvd = (float(np.nanmin(e.vd_z)), float(np.nanmax(e.vd_z)))
        for r in e.vd_layers:
            for sg in (1, -1):
                fig.add_trace(
                    go.Scatter(x=list(zvd), y=[sg * r, sg * r], mode="lines", line=dict(color=VDC, width=1),
                               opacity=0.7, legendgroup="vd", showlegend=False, hoverinfo="skip"),
                    row=1, col=2,
                )

    # ---------------- tracks, panels (a) (b) (c)
    for i in order:
        col, lw = (groups[i][1], 2.6) if i in groups else (GREY, 1.2)
        lab = groups[i][0] if i in groups else "other charged tracks"
        grp = f"g:{groups[i][0]}" if i in groups else "trkother"  # by label, not by index: one entry per class
        q = int(e.charge[i])
        p = float(e.p_abs[i])
        nvd = len(e.track_hits.get(i, []))
        where = "secondary vertex" if i in e.sv_tracks else ("fitted vertex" if any(i in ids for _, ids, _ in fit_vertices) else "primary vertex / perigee")
        hov = f"<b>track {i}</b><br>q = {q:+d}<br>p = {p:.2f} GeV<br>VD hits: {nvd}<br>starts at the {where}"
        if i in groups:
            hov += f"<br>{groups[i][0]}"

        x, y, z, r = G.track_to(e.par[i], e.r_stop, sign, n=420, s0=s0[i])
        fig.add_trace(
            go.Scatter(x=x, y=y, mode="lines", line=dict(color=col, width=lw), name=lab, legendgroup=grp,
                       showlegend=legend_once(grp), hovertemplate=hov + "<extra></extra>"),
            row=1, col=1,
        )
        # (b) r-z, radius signed by the thrust hemisphere the track's momentum points into
        sg = side(e.momentum[i, 0], e.momentum[i, 1])
        k = int(np.argmax(np.abs(z) > ZLIM)) if np.any(np.abs(z) > ZLIM) else len(z)
        fig.add_trace(
            go.Scatter(x=z[:k], y=sg * r[:k], mode="lines", line=dict(color=col, width=lw), legendgroup=grp,
                       showlegend=False, hovertemplate=hov + "<extra></extra>"),
            row=1, col=2,
        )
        # (c) same track, out to the edge of the vertex detector
        xc, yc, _, _ = G.track_to(e.par[i], RVD * 1.5, sign, n=320, s0=s0[i])
        fig.add_trace(
            go.Scatter(x=xc, y=yc, mode="lines", line=dict(color=col, width=lw), legendgroup=grp,
                       showlegend=False, hovertemplate=hov + "<extra></extra>"),
            row=3, col=1,
        )
        # the piece between the IP and the vertex the track starts at: dotted, it was not travelled
        if i in s_pv and s_pv[i] < s0[i] - 1e-6:
            xb, yb, _ = G.helix(e.par[i], np.linspace(s_pv[i], s0[i], 140), sign)
            for rc, cc in ((1, 1), (3, 1)):
                fig.add_trace(
                    go.Scatter(x=xb, y=yb, mode="lines", line=dict(color=col, width=max(1.1, 0.6 * lw), dash="dot"),
                               opacity=0.75, name="back-extrapolation to the IP", legendgroup="back",
                               showlegend=legend_once("back") and rc == 1, hoverinfo="text",
                               text=f"track {i}: back-extrapolation, not a travelled path"),
                    row=rc, col=cc,
                )

    # ---------------- EM showers as towers, length proportional to energy
    for panel, (rc, cc) in enumerate(((1, 1), (1, 2))):
        xs, ys, txt = [], [], []
        for x0, y0, z0, en in zip(e.sh_x, e.sh_y, e.sh_z, e.sh_e):
            if panel == 0:
                v = np.array([x0, y0])
            else:
                v = np.array([z0, side(x0, y0) * np.hypot(x0, y0)])
            u = v / (np.linalg.norm(v) or 1.0)
            w = v + SHOWER_SCALE * en * u
            xs += [v[0], w[0], None]
            ys += [v[1], w[1], None]
            txt += [f"EM shower, E = {en:.2f} GeV", f"EM shower, E = {en:.2f} GeV", None]
        fig.add_trace(
            go.Scatter(x=xs, y=ys, mode="lines", line=dict(color=SHOWER, width=5),
                       name="EM shower (length ∝ energy)", legendgroup="em", showlegend=legend_once("em"),
                       hoverinfo="text", text=txt),
            row=rc, col=cc,
        )

    # ---------------- jet axes (off by default)
    if jets:
        for jx, jy, _jz, je in e.jets:
            if je < 5:
                continue
            u = np.array([jx, jy]) / (np.hypot(jx, jy) or 1.0)
            fig.add_trace(
                go.Scatter(x=[0, (e.r_em + 330) * u[0]], y=[0, (e.r_em + 330) * u[1]], mode="lines",
                           line=dict(color="#444", width=1, dash="dash"), opacity=0.5, name="jet axis",
                           legendgroup="jet", showlegend=legend_once("jet"), hoverinfo="text", text=f"jet, E = {je:.1f} GeV"),
                row=1, col=1,
            )

    # ---------------- vertex detector hits, panel (c)
    hit_col = {}
    for i in groups:
        if i in drawn:
            for j in e.track_hits.get(i, []):
                hit_col[j] = groups[i][1]
    hidden_hits = {j for i in hide for j in e.track_hits.get(i, [])}
    rphi = np.where(~np.isnan(e.vd_x))[0]
    plain = [j for j in rphi if j not in hit_col and j not in hidden_hits]
    if plain:
        fig.add_trace(
            go.Scatter(x=e.vd_x[plain], y=e.vd_y[plain], mode="markers", marker=dict(size=5, color=HITC),
                       name="VD R-φ hits", legendgroup="hits", showlegend=legend_once("hits"),
                       hoverinfo="text", text=[f"VD R-φ hit, R = {e.vd_r[j]:.1f} mm" for j in plain]),
            row=3, col=1,
        )
    own = [j for j in rphi if j in hit_col]
    if own:
        fig.add_trace(
            go.Scatter(x=e.vd_x[own], y=e.vd_y[own], mode="markers",
                       marker=dict(size=10, color=[hit_col[j] for j in own], line=dict(color="black", width=1)),
                       name="VD hit on a highlighted track", legendgroup="hitsown", showlegend=legend_once("hitsown"),
                       hoverinfo="text", text=[f"VD R-φ hit, R = {e.vd_r[j]:.1f} mm (highlighted track)" for j in own]),
            row=3, col=1,
        )

    # ---------------- vertices
    for rc, cc, ms in ((1, 1, 9), (3, 1, 13)):
        fig.add_trace(
            go.Scatter(x=[e.pv[0]], y=[e.pv[1]], mode="markers",
                       marker=dict(symbol="cross-thin", size=ms, color=PVC, line=dict(color=PVC, width=2.4)),
                       name="primary vertex (IP)", legendgroup="pv", showlegend=legend_once("pv") and rc == 3,
                       hoverinfo="text", text=f"primary vertex<br>({e.pv[0]:.3f}, {e.pv[1]:.3f}, {e.pv[2]:.3f}) mm"),
            row=rc, col=cc,
        )
    for k, v in enumerate(e.svs):
        d = float(np.linalg.norm(v - e.pv))
        fig.add_trace(
            go.Scatter(x=[v[0]], y=[v[1]], mode="markers",
                       marker=dict(symbol="circle-open", size=13, color=SVC, line=dict(color=SVC, width=2.4)),
                       name="reconstructed secondary vertex", legendgroup="sv", showlegend=legend_once("sv"),
                       hoverinfo="text", text=f"AABTAG secondary vertex {k}<br>{d:.2f} mm from the PV"),
            row=3, col=1,
        )
    for xv, ids, rms in fit_vertices:
        d = float(np.linalg.norm(xv - e.pv))
        fig.add_trace(
            go.Scatter(x=[xv[0]], y=[xv[1]], mode="markers",
                       marker=dict(symbol="diamond-open", size=12, color=FITC, line=dict(color=FITC, width=2.4)),
                       name="2-track vertex (our straight-line fit)", legendgroup="fit", showlegend=legend_once("fit"),
                       hoverinfo="text", text=f"fitted vertex from tracks {ids}<br>{d:.2f} mm from the PV, rms {rms:.0f} µm"),
            row=3, col=1,
        )

    # ---------------- (d) RICH
    pp = np.linspace(0.25, 16, 600)
    curves = [(MPI, "π", "solid", "#555555"), (MK, "K", "dash", "#000000"), (MP, "p", "dot", "#555555")]
    liq_meas = [1000 * e.haid[i][13] for i in rich if i in e.haid and len(e.haid[i]) >= 18 and e.haid[i][15] > 0]
    ylo = min(560.0, 10.0 * np.floor((min(liq_meas, default=560.0) - 30.0) / 10.0))
    for (rc, n_ref, ylim) in ((3, N_LIQ, (ylo, 690)), (4, N_GAS, (0, 75))):
        for m, lab, dash, col in curves:
            fig.add_trace(
                go.Scatter(x=pp, y=_cherenkov(pp, m, n_ref), mode="lines", line=dict(color=col, width=1.5, dash=dash),
                           name=f"expected θ<sub>C</sub> for {lab}", legendgroup=f"pid{lab}",
                           showlegend=legend_once(f"pid{lab}"), hoverinfo="skip"),
                row=rc, col=2,
            )
        fig.update_yaxes(range=list(ylim), title_text="θ<sub>C</sub> [mrad]", row=rc, col=2)
        fig.update_xaxes(range=[0.8, 14.5], row=rc, col=2)

    ordered = sorted([i for i in rich if i in e.haid and len(e.haid[i]) >= 18], key=lambda i: e.p_abs[i])
    for k, i in enumerate(ordered, 1):
        h = e.haid[i]
        col = groups.get(i, ("", "#333333"))[1]
        p = float(e.p_abs[i])
        if h[15] > 0:  # liquid radiator saw photons
            fig.add_trace(
                go.Scatter(x=[p], y=[1000 * h[13]], mode="markers+text", text=[str(k)], textposition="top right",
                           textfont=dict(color=col, size=13),
                           marker=dict(size=13, color=col, line=dict(color="black", width=1)),
                           legendgroup="richpt", showlegend=False, hoverinfo="text",
                           hovertext=f"<b>track {i}</b> (#{k})<br>q = {int(e.charge[i]):+d}, p = {p:.2f} GeV"
                                     f"<br>liquid: {int(h[15])} photons, θ<sub>C</sub> = {1000 * h[13]:.1f} mrad"),
                row=3, col=2,
            )
        if h[10] > 0 and h[8] > 0:  # gas radiator saw photons and got an angle
            fig.add_trace(
                go.Scatter(x=[p], y=[1000 * h[8]], mode="markers+text", text=[str(k)], textposition="top right",
                           textfont=dict(color=col, size=13),
                           marker=dict(size=13, color=col, line=dict(color="black", width=1)),
                           legendgroup="richpt", showlegend=False, hoverinfo="text",
                           hovertext=f"<b>track {i}</b> (#{k})<br>p = {p:.2f} GeV"
                                     f"<br>gas: {int(h[10])} photons, θ<sub>C</sub> = {1000 * h[8]:.1f} mrad"),
                row=4, col=2,
            )
        elif h[10] == 0 and p > GAS_THR["π"]:
            # above the pion threshold with no gas photons: that is a veto, not a missing measurement
            fig.add_trace(
                go.Scatter(x=[p], y=[3], mode="markers+text", text=[str(k)], textposition="top center",
                           textfont=dict(color=col, size=13),
                           marker=dict(symbol="triangle-down", size=14, color=col, line=dict(color="black", width=1)),
                           legendgroup="richpt", showlegend=False, hoverinfo="text",
                           hovertext=f"<b>track {i}</b> (#{k})<br>p = {p:.2f} GeV<br>gas: 0 photons above the π threshold — not a pion (veto)"),
                row=4, col=2,
            )
    fig.add_annotation(
        text="a pion above threshold would radiate here;<br>0 photons (▼) = not a pion (veto)",
        xref="x5 domain", yref="y5 domain", x=0.02, y=0.96, showarrow=False, align="left",
        font=dict(size=11, color="#333333"),
    )

    # ---------------- axes
    fig.update_xaxes(title_text="x [mm]", range=[-LIM, LIM], row=1, col=1)
    fig.update_yaxes(title_text="y [mm]", range=[-LIM, LIM], scaleanchor="x", scaleratio=1, row=1, col=1)
    fig.update_xaxes(title_text="z [mm]  (beam axis)", range=[-ZLIM, ZLIM], row=1, col=2)
    fig.update_yaxes(title_text="±r [mm]  (sign = thrust hemisphere)", range=[-LIM, LIM], scaleanchor="x2", scaleratio=1, row=1, col=2)
    fig.update_xaxes(title_text="x [mm]", range=[-RVD, RVD], row=3, col=1)
    fig.update_yaxes(title_text="y [mm]", range=[-RVD, RVD], scaleanchor="x3", scaleratio=1, row=3, col=1)
    fig.update_xaxes(title_text="track momentum [GeV]", row=4, col=2)

    # No title or run/event line is drawn into the figure: every number is carried in
    # layout.meta below, so the page renders them as live text that can be restyled,
    # copied and re-laid-out. Baking them into the image only freezes them.
    fig.update_layout(
        # carried into the figure JSON so the Pages manifest can be built without reopening the ROOT file
        meta=dict(run=e.run, event=e.event, entry=e.entry, source=e.path.split("/")[-1],
                  n_charged=e.n_charged, n_neutral=e.n_neutral, b_field=e.b_field,
                  btag=e.btag_event, title=title, note=note),
        template=_template(),
        showlegend=True,  # the mplhep template defaults it off, as matplotlib does
        width=width,
        height=height,
        images=[logo_image(size=0.055)],
        legend=dict(orientation="h", yanchor="top", y=-0.045, xanchor="center", x=0.5, font=dict(size=12)),
        margin=dict(l=70, r=30, t=76, b=130),
        hovermode="closest",
        hoverlabel=dict(bgcolor="white", font_size=12),
        plot_bgcolor="white",
        paper_bgcolor="white",
    )
    role_of_group = {
        "vd": "vd_hits", "hits": "vd_hits", "hitsown": "vd_hits",
        "hpc": "em_clusters", "em": "em_clusters",
        "trkother": "tracks", "back": "tracks",
        "jet": "jets", "pv": "primary_vertex", "sv": "secondary_vertices",
        "richpt": "hadron_id", "pidπ": "hadron_id", "pidK": "hadron_id", "pidp": "hadron_id",
    }
    roles = getattr(e, "roles", {}) or {}
    for tr in fig.data:
        grp = tr.legendgroup or ""
        role = role_of_group.get(grp, "tracks" if grp.startswith("g:") else None)
        if role:
            tr.meta = {"role": role, "collection": roles.get(role, "")}

    for ann, ax in zip(fig.layout.annotations[:5], ("xaxis", "xaxis2", "xaxis3", "xaxis4", "xaxis5")):
        ann.update(font=dict(size=13.5), x=fig.layout[ax].domain[0], xanchor="left")
    return fig

logo_image

logo_image(x: float = 1.0, y: float = 1.0, size: float = 0.055, opacity: float = 0.95) -> dict

The DELPHI + EDM4hep mark, as a layout image in the top margin.

Anchored top-RIGHT: the subplot titles are left-aligned over their own panel, so a top-left mark lands straight on top of "(a) r-phi view".

Embedded as a data URI, so it survives PNG/SVG export and an offline HTML file.

Source code in src/delphi_eventdisplay/figure.py
def logo_image(x: float = 1.0, y: float = 1.0, size: float = 0.055, opacity: float = 0.95) -> dict:
    """The DELPHI + EDM4hep mark, as a layout image in the top margin.

    Anchored top-RIGHT: the subplot titles are left-aligned over their own panel, so a
    top-left mark lands straight on top of "(a) r-phi view".

    Embedded as a data URI, so it survives PNG/SVG export and an offline HTML file."""
    return dict(source=LOGO_DATA_URI, xref="paper", yref="paper", x=x, y=y,
                sizex=size, sizey=size, xanchor="right", yanchor="bottom",
                opacity=opacity, layer="above")

delphi_eventdisplay.figure3d

The event as a 3-D scene, the way a general-purpose event display shows it.

Same rule as the panels: only measured quantities. That rule bites hardest here. A vertex-detector hit is a strip, not a space point -- a type-0 hit measures R and phi but not z, a type-1 hit measures R and z but not phi -- so in three dimensions a single hit is a line, not a dot. Hits that belong to a track are placed at the z (or phi) their own track supplies, which is a real measurement; hits belonging to no track are drawn as the line they actually constrain, not as invented points.

build3d

build3d(e: Event, *, groups: dict | None = None, fit_vertices: list | None = None, hide: dict | None = None, jets: bool = False, title: str = '', note: str = '', show_barrel: bool = True, width: int = 1250, height: int = 900) -> go.Figure
Source code in src/delphi_eventdisplay/figure3d.py
def build3d(
    e: Event,
    *,
    groups: dict | None = None,
    fit_vertices: list | None = None,
    hide: dict | None = None,
    jets: bool = False,
    title: str = "",
    note: str = "",
    show_barrel: bool = True,
    width: int = 1250,
    height: int = 900,
) -> go.Figure:
    groups = groups or {}
    fit_vertices = fit_vertices or []
    hide = hide or {}

    sign, _ = G.curvature_sign(e)
    vtx = dict(e.sv_tracks)
    for xv, ids, _r in fit_vertices:
        for i in ids:
            vtx[i] = xv
    s0, s_pv, _ = G.start_points(e, vtx, sign)

    drawn = [i for i in e.par if i not in hide]
    order = sorted(drawn, key=lambda i: i in groups)
    roles = getattr(e, "roles", {}) or {}
    fig = go.Figure()
    seen = set()

    def once(k):
        first = k not in seen
        seen.add(k)
        return first

    # ---------------- detector barrels
    if show_barrel:
        zvd = (float(np.nanmin(e.vd_z)), float(np.nanmax(e.vd_z))) if len(e.vd_z) else (-100.0, 100.0)
        for r in e.vd_layers:
            x, y, z = _cylinder(r, *zvd)
            fig.add_trace(go.Surface(x=x, y=y, z=z, showscale=False, opacity=0.18,
                                     colorscale=[[0, VDC], [1, VDC]], hoverinfo="skip",
                                     name="VD layers", showlegend=once("vd"), legendgroup="vd",
                                     meta={"role": "vd_hits", "collection": roles.get("vd_hits", "")}))
        x, y, z = _cylinder(e.r_em, -ZLIM * 0.75, ZLIM * 0.75)
        fig.add_trace(go.Surface(x=x, y=y, z=z, showscale=False, opacity=0.05,
                                 colorscale=[[0, HPCC], [1, HPCC]], hoverinfo="skip",
                                 name=f"HPC barrel, r = {e.r_em/1000:.2f} m", showlegend=once("hpc"), legendgroup="hpc",
                                 meta={"role": "em_clusters", "collection": roles.get("em_clusters", "")}))
        # beam line
        fig.add_trace(go.Scatter3d(x=[0, 0], y=[0, 0], z=[-ZLIM, ZLIM], mode="lines",
                                   line=dict(color="#c9ced6", width=2), hoverinfo="skip",
                                   name="beam axis", showlegend=once("beam"), legendgroup="beam"))

    # ---------------- tracks
    for i in order:
        col, lw = (groups[i][1], 5) if i in groups else (GREY, 2)
        lab = groups[i][0] if i in groups else "other charged tracks"
        grp = f"g:{groups[i][0]}" if i in groups else "trkother"
        q, p = int(e.charge[i]), float(e.p_abs[i])
        nvd = len(e.track_hits.get(i, []))
        where = "secondary vertex" if i in e.sv_tracks else "primary vertex / perigee"
        hov = f"<b>track {i}</b><br>q = {q:+d}<br>p = {p:.2f} GeV<br>VD hits: {nvd}<br>starts at the {where}"
        x, y, z, _r = G.track_to(e.par[i], e.r_stop, sign, n=400, s0=s0[i])
        k = int(np.argmax(np.abs(z) > ZLIM)) if np.any(np.abs(z) > ZLIM) else len(z)
        fig.add_trace(go.Scatter3d(x=x[:k], y=y[:k], z=z[:k], mode="lines",
                                   line=dict(color=col, width=lw), name=lab, legendgroup=grp,
                                   showlegend=once(grp), hovertemplate=hov + "<extra></extra>",
                                   meta={"role": "tracks", "collection": roles.get("tracks", "")}))
        if i in s_pv and s_pv[i] < s0[i] - 1e-6:
            xb, yb, zb = G.helix(e.par[i], np.linspace(s_pv[i], s0[i], 120), sign)
            fig.add_trace(go.Scatter3d(x=xb, y=yb, z=zb, mode="lines",
                                       line=dict(color=col, width=max(2, lw // 2), dash="dot"),
                                       name="back-extrapolation to the IP", legendgroup="back",
                                       showlegend=once("back"), hoverinfo="text",
                                       text=f"track {i}: back-extrapolation, not a travelled path",
                                       meta={"role": "tracks", "collection": roles.get("tracks", "")}))

    # ---------------- EM showers, towers pointing outward from the IP
    xs, ys, zs, txt = [], [], [], []
    for x0, y0, z0, en in zip(e.sh_x, e.sh_y, e.sh_z, e.sh_e):
        v = np.array([x0, y0, z0], float)
        u = v / (np.linalg.norm(v) or 1.0)
        w = v + SHOWER_SCALE * en * u
        xs += [v[0], w[0], None]; ys += [v[1], w[1], None]; zs += [v[2], w[2], None]
        txt += [f"EM shower, E = {en:.2f} GeV"] * 2 + [None]
    if xs:
        fig.add_trace(go.Scatter3d(x=xs, y=ys, z=zs, mode="lines", line=dict(color=SHOWER, width=9),
                                   name="EM shower (length ∝ energy)", legendgroup="em",
                                   showlegend=once("em"), hoverinfo="text", text=txt,
                                   meta={"role": "em_clusters", "collection": roles.get("em_clusters", "")}))

    # ---------------- vertex-detector hits: a strip is a line in 3-D
    hidden = {j for i in hide for j in e.track_hits.get(i, [])}
    z_of_hit, own_col = {}, {}
    sgrid = np.linspace(-50.0, 400.0, 9001)
    for i in drawn:
        xs_, ys_, zs_ = G.helix(e.par[i], sgrid, sign)
        rs = np.hypot(xs_, ys_)
        for j in e.track_hits.get(i, []):
            if e.vd_r[j] <= 0:
                continue
            k = int(np.argmin(np.abs(rs - e.vd_r[j])))
            z_of_hit[j] = float(zs_[k])          # z comes from the track, which does measure it
            if i in groups:
                own_col[j] = groups[i][1]
    rphi = [j for j in np.where(~np.isnan(e.vd_x))[0] if j not in hidden]
    placed = [j for j in rphi if j in z_of_hit]
    if placed:
        fig.add_trace(go.Scatter3d(
            x=e.vd_x[placed], y=e.vd_y[placed], z=[z_of_hit[j] for j in placed], mode="markers",
            marker=dict(size=[5 if j not in own_col else 7 for j in placed],
                        color=[own_col.get(j, HITC) for j in placed],
                        line=dict(color="black", width=1)),
            name="VD R-φ hit (z from its track)", legendgroup="hits", showlegend=once("hits"),
            hoverinfo="text", text=[f"VD R-φ hit, R = {e.vd_r[j]:.1f} mm<br>z taken from its own track" for j in placed],
            meta={"role": "vd_hits", "collection": roles.get("vd_hits", "")}))
    # hits on no track constrain only a line parallel to the beam
    loose = [j for j in rphi if j not in z_of_hit]
    if loose:
        zspan = (float(np.nanmin(e.vd_z)), float(np.nanmax(e.vd_z))) if len(e.vd_z) else (-60.0, 60.0)
        lx, ly, lz = [], [], []
        for j in loose:
            lx += [e.vd_x[j], e.vd_x[j], None]; ly += [e.vd_y[j], e.vd_y[j], None]; lz += [zspan[0], zspan[1], None]
        fig.add_trace(go.Scatter3d(x=lx, y=ly, z=lz, mode="lines",
                                   line=dict(color=HITC, width=2), opacity=0.55,
                                   name="VD R-φ hit on no track (z unmeasured)", legendgroup="hitsloose",
                                   showlegend=once("hitsloose"), hoverinfo="text",
                                   text="VD R-φ strip: R and φ measured, z is not",
                                   meta={"role": "vd_hits", "collection": roles.get("vd_hits", "")}))

    # ---------------- vertices
    fig.add_trace(go.Scatter3d(x=[e.pv[0]], y=[e.pv[1]], z=[e.pv[2]], mode="markers",
                               marker=dict(symbol="cross", size=5, color=PVC),
                               name="primary vertex (IP)", legendgroup="pv", showlegend=once("pv"),
                               hoverinfo="text", text=f"primary vertex<br>({e.pv[0]:.3f}, {e.pv[1]:.3f}, {e.pv[2]:.3f}) mm",
                               meta={"role": "primary_vertex", "collection": roles.get("primary_vertex", "")}))
    for k, v in enumerate(e.svs):
        fig.add_trace(go.Scatter3d(x=[v[0]], y=[v[1]], z=[v[2]], mode="markers",
                                   marker=dict(symbol="circle-open", size=7, color=SVC, line=dict(color=SVC, width=3)),
                                   name="reconstructed secondary vertex", legendgroup="sv", showlegend=once("sv"),
                                   hoverinfo="text",
                                   text=f"secondary vertex {k}<br>{np.linalg.norm(v - e.pv):.2f} mm from the PV",
                                   meta={"role": "secondary_vertices", "collection": roles.get("secondary_vertices", "")}))

    if jets:
        for jx, jy, jz, je in e.jets:
            if je < 5:
                continue
            u = np.array([jx, jy, jz], float); u /= np.linalg.norm(u) or 1.0
            L = e.r_em + 330
            fig.add_trace(go.Scatter3d(x=[0, L*u[0]], y=[0, L*u[1]], z=[0, L*u[2]], mode="lines",
                                       line=dict(color="#444", width=3, dash="dash"), opacity=0.55,
                                       name="jet axis", legendgroup="jet", showlegend=once("jet"),
                                       hoverinfo="text", text=f"jet, E = {je:.1f} GeV",
                                       meta={"role": "jets", "collection": roles.get("jets", "")}))

    # see figure.build: the numbers live in layout.meta and are rendered by the page
    fig.update_layout(
        meta=dict(run=e.run, event=e.event, entry=e.entry, source=e.path.split("/")[-1],
                  n_charged=e.n_charged, n_neutral=e.n_neutral, b_field=e.b_field,
                  btag=e.btag_event, title=title, note=note, view="3d"),
        template=_template3d(), showlegend=True, width=width, height=height,
        font=dict(size=12),
        images=[logo_image(x=0.995, size=0.085)],
        legend=dict(orientation="h", yanchor="top", y=-0.02, xanchor="center", x=0.5, font=dict(size=12)),
        margin=dict(l=0, r=0, t=66, b=60),
        scene=dict(
            aspectmode="data",  # physically proportional: the barrel is not stretched
            xaxis=dict(**AXIS, title_text="x [mm]"),
            yaxis=dict(**AXIS, title_text="y [mm]"),
            zaxis=dict(**AXIS, title_text="z [mm] (beam)"),
            camera=dict(eye=dict(x=1.45, y=1.05, z=0.85)),
        ),
        paper_bgcolor="white",
    )
    return fig

Site manifest

delphi_eventdisplay.pages

Build the manifest that the GitHub Pages viewer and editor read.

Scans docs/events/*.json (the figure JSON written by delphi-display) and writes docs/events/index.json. The pages are static: publishing an event is committing its JSON.

build_index

build_index(docs: Path) -> dict
Source code in src/delphi_eventdisplay/pages.py
def build_index(docs: Path) -> dict:
    events = []
    for js in sorted((docs / "events").glob("*.json")):
        if js.name == "index.json" or js.name.endswith((".schema.json", ".3d.json")):
            continue  # sidecars of an event, not events of their own
        try:
            fig = json.loads(js.read_text())
        except json.JSONDecodeError:
            print(f"skipping {js.name}: not valid JSON", file=sys.stderr)
            continue
        meta = (fig.get("layout") or {}).get("meta") or {}
        title = meta.get("title") or ""
        if meta.get("run") is not None:
            label = f"run {meta['run']}, event {meta['event']}"
            title = f"{label} — {title}" if title else label
        events.append(
            {
                "key": js.stem,
                "file": f"events/{js.name}",
                "title": title or js.stem,
                "run": meta.get("run"),
                "event": meta.get("event"),
                "source": meta.get("source"),
                "entry": meta.get("entry"),
                "n_charged": meta.get("n_charged"),
                "n_neutral": meta.get("n_neutral"),
                "btag": meta.get("btag"),
                "note": meta.get("note"),
                "bytes": js.stat().st_size,
                "schema": f"events/{js.stem}.schema.json" if (docs / "events" / f"{js.stem}.schema.json").exists() else None,
                "figure3d": f"events/{js.stem}.3d.json" if (docs / "events" / f"{js.stem}.3d.json").exists() else None,
            }
        )
    return {"events": events}