Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-04-10 07:49:36

0001 #!/usr/bin/env python
0002 """
0003 photon_history_summary.py : Debug analysis of opticks GPU simulation output
0004 =================================================================
0005 
0006 Reads photon.npy, hit.npy, inphoton.npy, record.npy, seq.npy from
0007 an opticks event folder and prints summary tables for debugging.
0008 
0009 Requires OPTICKS_EVENT_MODE=HitPhoton (or DebugLite/DebugHeavy) so
0010 that photon and record arrays are actually saved to disk.
0011 
0012 Usage::
0013 
0014     python optiphy/ana/photon_history_summary.py /tmp/MISSING_USER/opticks/GEOM/GEOM/GPUPhotonSourceMinimal/ALL0_no_opticks_event_name/A000
0015 
0016     # or just the parent (auto-selects A000):
0017     python optiphy/ana/photon_history_summary.py /tmp/MISSING_USER/opticks/GEOM/GEOM/GPUPhotonSourceMinimal/ALL0_no_opticks_event_name
0018 
0019     # show per-photon detail for first 20 photons:
0020     python optiphy/ana/photon_history_summary.py <path> --detail 20
0021 
0022     # show step-by-step record for specific photons:
0023     python optiphy/ana/photon_history_summary.py <path> --trace 0,227,235
0024 
0025     # filter by terminal flag:
0026     python optiphy/ana/photon_history_summary.py <path> --flag BOUNDARY_REFLECT
0027 
0028     # show all non-hit (absorbed/lost) photons:
0029     python optiphy/ana/photon_history_summary.py <path> --lost
0030 """
0031 import sys
0032 import os
0033 import argparse
0034 import numpy as np
0035 
0036 # --- Flag definitions from OpticksPhoton.h ---
0037 FLAG_ENUM = {
0038     0x00001: "CERENKOV",
0039     0x00002: "SCINTILLATION",
0040     0x00004: "TORCH",
0041     0x00008: "BULK_ABSORB",
0042     0x00010: "BULK_REEMIT",
0043     0x00020: "BULK_SCATTER",
0044     0x00040: "SURFACE_DETECT",
0045     0x00080: "SURFACE_ABSORB",
0046     0x00100: "SURFACE_DREFLECT",
0047     0x00200: "SURFACE_SREFLECT",
0048     0x00400: "BOUNDARY_REFLECT",
0049     0x00800: "BOUNDARY_TRANSMIT",
0050     0x01000: "NAN_ABORT",
0051     0x02000: "EFFICIENCY_COLLECT",
0052     0x04000: "EFFICIENCY_CULL",
0053     0x08000: "MISS",
0054     0x10000: "__NATURAL",
0055     0x20000: "__MACHINERY",
0056     0x40000: "__EMITSOURCE",
0057     0x80000: "PRIMARYSOURCE",
0058     0x100000: "GENSTEPSOURCE",
0059     0x200000: "DEFER_FSTRACKINFO",
0060 }
0061 
0062 FLAG_ABBREV = {
0063     0x00001: "CK",
0064     0x00002: "SI",
0065     0x00004: "TO",
0066     0x00008: "AB",
0067     0x00010: "RE",
0068     0x00020: "SC",
0069     0x00040: "SD",
0070     0x00080: "SA",
0071     0x00100: "DR",
0072     0x00200: "SR",
0073     0x00400: "BR",
0074     0x00800: "BT",
0075     0x01000: "NA",
0076     0x02000: "EC",
0077     0x04000: "EL",
0078     0x08000: "MI",
0079     0x10000: "Nat",
0080     0x20000: "Mac",
0081     0x40000: "Emi",
0082     0x80000: "PS",
0083     0x100000: "GS",
0084     0x200000: "DF",
0085 }
0086 
0087 # Terminal flags that indicate a detected photon.
0088 # Default hitmask is SD, but CustomART uses EC instead.
0089 # The script reads the actual hitmask from event metadata when available.
0090 HIT_FLAGS = {0x0040, 0x2000}  # SURFACE_DETECT, EFFICIENCY_COLLECT
0091 
0092 # FFS (find-first-set) value to flag bit, used by seq nibble decoding
0093 FFS_TO_FLAG = {i + 1: 1 << i for i in range(16)}
0094 
0095 
0096 def flag_name(f):
0097     return FLAG_ENUM.get(f, f"0x{f:04x}")
0098 
0099 
0100 def flag_abbrev(f):
0101     return FLAG_ABBREV.get(f, f"?{f:x}")
0102 
0103 
0104 def flagmask_str(fm):
0105     """Decode cumulative flagmask into abbreviated flag names."""
0106     parts = []
0107     for bit in range(22):
0108         f = 1 << bit
0109         if fm & f:
0110             parts.append(flag_abbrev(f))
0111     return "|".join(parts)
0112 
0113 
0114 def decode_seq_history(seqhis):
0115     """Decode seq nibbles into list of (flag_value, abbrev) tuples."""
0116     steps = []
0117     for slot in range(32):
0118         iseq = slot // 16
0119         shift = 4 * (slot - iseq * 16)
0120         if iseq == 0:
0121             nibble = (seqhis[0] >> shift) & 0xF
0122         else:
0123             nibble = (seqhis[1] >> shift) & 0xF
0124         if nibble == 0:
0125             break
0126         f = FFS_TO_FLAG.get(nibble, 0)
0127         steps.append((f, flag_abbrev(f)))
0128     return steps
0129 
0130 
0131 def extract_photon_fields(photon):
0132     """Extract decoded fields from photon array (N, 4, 4) float32."""
0133     q3 = photon[:, 3, :].view(np.uint32)
0134     obf = q3[:, 0]  # orient_boundary_flag
0135     flag = obf & 0xFFFF
0136     boundary = (obf >> 16) & 0x7FFF
0137     orient = (obf >> 31).astype(np.int8)
0138     orient = np.where(orient, -1, 1)
0139     flagmask = q3[:, 3]
0140     identity = q3[:, 1]
0141     index = q3[:, 2] & 0x7FFFFFFF
0142 
0143     pos = photon[:, 0, :3]
0144     time = photon[:, 0, 3]
0145     mom = photon[:, 1, :3]
0146     pol = photon[:, 2, :3]
0147     wavelength = photon[:, 2, 3]
0148 
0149     return dict(
0150         pos=pos, time=time, mom=mom, pol=pol, wavelength=wavelength,
0151         flag=flag, boundary=boundary, orient=orient,
0152         flagmask=flagmask, identity=identity, index=index,
0153     )
0154 
0155 
0156 def count_record_steps(record):
0157     """Count non-zero steps per photon in record array (N, maxstep, 4, 4)."""
0158     n, maxstep = record.shape[:2]
0159     # A step is unused if all 16 floats are zero
0160     step_nonzero = np.any(record.reshape(n, maxstep, -1) != 0, axis=2)
0161     # Assuming used steps are contiguous from the start, the number of
0162     # steps per photon is just the sum of non-zero-step indicators.
0163     nsteps = step_nonzero.sum(axis=1).astype(int)
0164     return nsteps
0165 
0166 
0167 def load_event(path):
0168     """Load arrays from an opticks event folder. Returns dict of arrays."""
0169     arrays = {}
0170     names = ["photon", "hit", "inphoton", "record", "seq", "genstep", "domain"]
0171     for name in names:
0172         fpath = os.path.join(path, f"{name}.npy")
0173         if os.path.exists(fpath):
0174             arrays[name] = np.load(fpath)
0175     return arrays
0176 
0177 
0178 def read_hitmask(path):
0179     """Read hitmask from event metadata. Returns int or None."""
0180     meta_path = os.path.join(path, "NPFold_meta.txt")
0181     if not os.path.exists(meta_path):
0182         return None
0183     with open(meta_path) as f:
0184         for line in f:
0185             if line.startswith("hitmask:"):
0186                 parts = line.split(":", 1)
0187                 if len(parts) < 2:
0188                     return None
0189                 value = parts[1].strip()
0190                 if not value:
0191                     return None
0192                 try:
0193                     # Use base=0 to allow decimal ("64") and hex ("0x40") encodings.
0194                     return int(value, 0)
0195                 except ValueError:
0196                     return None
0197     return None
0198 
0199 
0200 def resolve_event_path(path):
0201     """Resolve event folder path, auto-selecting A000 if needed."""
0202     if os.path.exists(os.path.join(path, "photon.npy")):
0203         return path
0204     a000 = os.path.join(path, "A000")
0205     if os.path.exists(os.path.join(a000, "photon.npy")):
0206         return a000
0207     # Try to find any subfolder with photon.npy
0208     if os.path.isdir(path):
0209         for d in sorted(os.listdir(path)):
0210             dp = os.path.join(path, d)
0211             if os.path.isdir(dp) and os.path.exists(os.path.join(dp, "photon.npy")):
0212                 return dp
0213     return path
0214 
0215 
0216 def print_overview(arrays):
0217     """Print overview table of loaded arrays."""
0218     print("=" * 70)
0219     print("LOADED ARRAYS")
0220     print("=" * 70)
0221     for name, arr in sorted(arrays.items()):
0222         print(f"  {name:12s}  shape={str(arr.shape):24s}  dtype={arr.dtype}")
0223     print()
0224 
0225 
0226 def print_outcome_table(pf, n_photons, n_hits, hitmask, arrays):
0227     """Print table of photon outcomes by terminal flag."""
0228     print("=" * 70)
0229     print("PHOTON OUTCOMES (by terminal flag)")
0230     print("=" * 70)
0231 
0232     hm_str = flagmask_str(hitmask) if hitmask else "unknown"
0233     print(f"  Hitmask: {hm_str} (0x{hitmask:x})" if hitmask else "  Hitmask: not found in metadata")
0234 
0235     flag_vals, flag_counts = np.unique(pf["flag"], return_counts=True)
0236     order = np.argsort(-flag_counts)
0237 
0238     print(f"\n  {'Flag':<22s} {'Count':>7s} {'%':>7s}  {'Boundary indices'}")
0239     print(f"  {'-'*22} {'-'*7} {'-'*7}  {'-'*30}")
0240     for idx in order:
0241         f = flag_vals[idx]
0242         c = flag_counts[idx]
0243         mask = pf["flag"] == f
0244         bnd_vals = np.unique(pf["boundary"][mask])
0245         bnd_str = ", ".join(str(b) for b in bnd_vals)
0246         pct = 100.0 * c / n_photons
0247         print(f"  {flag_name(f):<22s} {c:7d} {pct:6.1f}%  bnd=[{bnd_str}]")
0248 
0249     hit_rate = 100.0 * n_hits / n_photons if n_photons > 0 else 0.0
0250     print(f"\n  Total photons: {n_photons}   Hits: {n_hits}   "
0251           f"Lost: {n_photons - n_hits}   Hit rate: {hit_rate:.1f}%")
0252 
0253     # Report MaxBounce-truncated photons
0254     if "record" in arrays:
0255         record = arrays["record"]
0256         maxstep = record.shape[1]
0257         nsteps = count_record_steps(record)
0258         n_truncated = int(np.sum(nsteps == maxstep))
0259         if n_truncated > 0:
0260             print(f"  Truncated at MaxBounce ({maxstep} steps): {n_truncated}")
0261     print()
0262 
0263 
0264 def print_flagmask_table(pf, n_photons):
0265     """Print table of unique cumulative flagmask histories."""
0266     print("=" * 70)
0267     print("PHOTON HISTORIES (by cumulative flagmask)")
0268     print("=" * 70)
0269 
0270     fm_vals, fm_counts = np.unique(pf["flagmask"], return_counts=True)
0271     order = np.argsort(-fm_counts)
0272 
0273     print(f"  {'Flagmask':<40s} {'Count':>7s} {'%':>7s}")
0274     print(f"  {'-'*40} {'-'*7} {'-'*7}")
0275     for idx in order:
0276         fm = fm_vals[idx]
0277         c = fm_counts[idx]
0278         pct = 100.0 * c / n_photons
0279         desc = flagmask_str(fm)
0280         print(f"  {desc:<40s} {c:7d} {pct:6.1f}%")
0281     print()
0282 
0283 
0284 def print_wavelength_table(pf, arrays):
0285     """Print wavelength shift analysis."""
0286     print("=" * 70)
0287     print("WAVELENGTH ANALYSIS")
0288     print("=" * 70)
0289 
0290     wl = pf["wavelength"]
0291     print(f"  Final photons:  min={wl.min():.1f}  max={wl.max():.1f}  "
0292           f"mean={wl.mean():.1f}  std={wl.std():.1f} nm")
0293 
0294     if "inphoton" in arrays:
0295         inp_wl = arrays["inphoton"][:, 2, 3]
0296         print(f"  Input photons:  min={inp_wl.min():.1f}  max={inp_wl.max():.1f}  "
0297               f"mean={inp_wl.mean():.1f}  std={inp_wl.std():.1f} nm")
0298 
0299         shifted = np.sum(np.abs(wl - inp_wl) > 0.1)
0300         print(f"  Wavelength shifted: {shifted}/{len(wl)}")
0301 
0302     if "hit" in arrays and len(arrays["hit"]) > 0:
0303         hit_wl = arrays["hit"][:, 2, 3]
0304         print(f"  Hit wavelength: min={hit_wl.min():.1f}  max={hit_wl.max():.1f}  "
0305               f"mean={hit_wl.mean():.1f}  std={hit_wl.std():.1f} nm")
0306     elif "hit" in arrays:
0307         print(f"  Hit wavelength: no hits")
0308 
0309     # Energy conservation
0310     hc = 1239.84193  # eV·nm
0311     if "inphoton" in arrays:
0312         inp_wl = arrays["inphoton"][:, 2, 3]
0313         inp_E = hc / inp_wl
0314         out_E = hc / wl
0315         violations = np.sum(out_E > inp_E + 0.001)
0316         print(f"  Energy conservation violations: {violations}")
0317     print()
0318 
0319 
0320 def print_position_table(pf):
0321     """Print position statistics."""
0322     print("=" * 70)
0323     print("POSITION / TIME")
0324     print("=" * 70)
0325 
0326     pos = pf["pos"]
0327     r = np.sqrt(np.sum(pos ** 2, axis=1))
0328     t = pf["time"]
0329 
0330     print(f"  Radius:  min={r.min():.2f}  max={r.max():.2f}  mean={r.mean():.2f} mm")
0331     print(f"  Time:    min={t.min():.3f}  max={t.max():.3f}  mean={t.mean():.3f} ns")
0332     print()
0333 
0334 
0335 def print_seq_table(arrays, n_show=15):
0336     """Print sequence history table from seq.npy."""
0337     if "seq" not in arrays:
0338         return
0339 
0340     seq = arrays["seq"]
0341     n = seq.shape[0]
0342     print("=" * 70)
0343     print(f"STEP SEQUENCE HISTORIES (top {n_show})")
0344     print("=" * 70)
0345     
0346     # seqhis is seq[:, 0, :] as uint64, shape (N, 2)
0347     seqhis = seq[:, 0, :]
0348 
0349     # Count unique sequences directly on raw uint64 pairs (fast, no Python loop)
0350     unique_seqhis, counts = np.unique(seqhis, axis=0, return_counts=True)
0351     order = np.argsort(-counts)
0352 
0353     # Only decode the top-N for display
0354     print(f"  {'#':>4s}  {'Count':>7s} {'%':>7s}  {'Sequence'}")
0355     print(f"  {'-'*4}  {'-'*7} {'-'*7}  {'-'*40}")
0356     for rank, idx in enumerate(order[:n_show]):
0357         c = counts[idx]
0358         pct = 100.0 * c / n
0359         steps = decode_seq_history(unique_seqhis[idx])
0360         label = " ".join(abbr for _, abbr in steps)
0361         print(f"  {rank:4d}  {c:7d} {pct:6.1f}%  {label}")
0362 
0363     if len(order) > n_show:
0364         print(f"  ... ({len(order)} unique sequences total)")
0365     print()
0366 
0367 def print_record_steps_table(arrays):
0368     """Print step count distribution from record.npy."""
0369     if "record" not in arrays:
0370         return
0371 
0372     record = arrays["record"]
0373     nsteps = count_record_steps(record)
0374 
0375     print("=" * 70)
0376     print("RECORD STEP COUNTS")
0377     print("=" * 70)
0378 
0379     step_vals, step_counts = np.unique(nsteps, return_counts=True)
0380     maxstep = record.shape[1]
0381 
0382     print(f"  {'Steps':>6s}  {'Count':>7s} {'%':>7s}  {'Note'}")
0383     print(f"  {'-'*6}  {'-'*7} {'-'*7}  {'-'*20}")
0384     for sv, sc in zip(step_vals, step_counts):
0385         pct = 100.0 * sc / len(nsteps)
0386         note = " <-- max (truncated)" if sv == maxstep else ""
0387         print(f"  {sv:6d}  {sc:7d} {pct:6.1f}%{note}")
0388 
0389     print(f"\n  Mean steps: {nsteps.mean():.1f}  Max allowed: {maxstep}")
0390     print()
0391 
0392 
0393 def print_photon_detail(arrays, pf, indices):
0394     """Print detailed per-photon info for given indices."""
0395     print("=" * 70)
0396     print("PHOTON DETAIL")
0397     print("=" * 70)
0398 
0399     record = arrays.get("record")
0400     seq = arrays.get("seq")
0401     n_photons = len(pf["flag"])
0402 
0403     for i in indices:
0404         if i >= n_photons:
0405             print(f"\n  photon[{i}]: index out of range (max {n_photons - 1})")
0406             continue
0407 
0408         pos = pf["pos"][i]
0409         wl = pf["wavelength"][i]
0410         t = pf["time"][i]
0411         f = pf["flag"][i]
0412         bnd = pf["boundary"][i]
0413         fm = pf["flagmask"][i]
0414 
0415         r = np.sqrt(np.sum(pos ** 2))
0416 
0417         print(f"\n  photon[{i}]")
0418         print(f"    Final: pos=({pos[0]:.2f}, {pos[1]:.2f}, {pos[2]:.2f}) r={r:.2f}mm"
0419               f"  t={t:.3f}ns  wl={wl:.1f}nm")
0420         print(f"    Flag:  {flag_name(f)}  bnd={bnd}  flagmask={flagmask_str(fm)}")
0421 
0422         if seq is not None:
0423             steps = decode_seq_history(seq[i, 0, :])
0424             seq_str = " ".join(abbr for _, abbr in steps)
0425             print(f"    Seq:   {seq_str}")
0426 
0427         if record is not None:
0428             rec = record[i]
0429             maxstep = rec.shape[0]
0430             print(f"    {'Step':>4s}  {'Flag':<18s}  {'Bnd':>3s}  {'Position (x,y,z)':^30s}"
0431                   f"  {'r':>8s}  {'Time':>8s}  {'WL':>7s}")
0432             print(f"    {'-'*4}  {'-'*18}  {'-'*3}  {'-'*30}  {'-'*8}  {'-'*8}  {'-'*7}")
0433 
0434             for s in range(maxstep):
0435                 if np.all(rec[s] == 0):
0436                     break
0437                 sq3 = rec[s, 3, :].view(np.uint32)
0438                 sf = sq3[0] & 0xFFFF
0439                 sb = (sq3[0] >> 16) & 0x7FFF
0440                 spos = rec[s, 0, :3]
0441                 st = rec[s, 0, 3]
0442                 swl = rec[s, 2, 3]
0443                 sr = np.sqrt(np.sum(spos ** 2))
0444                 print(f"    {s:4d}  {flag_name(sf):<18s}  {sb:3d}"
0445                       f"  ({spos[0]:9.2f},{spos[1]:9.2f},{spos[2]:9.2f})"
0446                       f"  {sr:8.2f}  {st:8.3f}  {swl:7.1f}")
0447     print()
0448 
0449 
0450 def print_lost_photons(arrays, pf, hitmask):
0451     """Print detail for all non-detected photons.
0452 
0453     A photon is "lost" if its terminal flag is not in the hit set.
0454     The hitmask is read from event metadata (default: SURFACE_DETECT).
0455     With CustomART PMTs the hitmask may be EFFICIENCY_COLLECT instead.
0456     """
0457     # A photon is a hit if (flagmask & hitmask) == hitmask.
0458     # For --lost we check the terminal flag against known hit flags,
0459     # AND also include photons whose flagmask doesn't satisfy the hitmask.
0460     if hitmask is not None:
0461         lost_mask = (pf["flagmask"] & hitmask) != hitmask
0462     else:
0463         # Fallback: terminal flag not in any known hit flag
0464         lost_mask = np.ones(len(pf["flag"]), dtype=bool)
0465         for hf in HIT_FLAGS:
0466             lost_mask &= pf["flag"] != hf
0467 
0468     lost_idx = np.where(lost_mask)[0]
0469 
0470     if len(lost_idx) == 0:
0471         print("No lost photons — all photons were detected.\n")
0472         return
0473 
0474     print("=" * 70)
0475     hm_str = flagmask_str(hitmask) if hitmask else "SD|EC"
0476     print(f"LOST PHOTONS ({len(lost_idx)} non-detected, hitmask={hm_str})")
0477     print("=" * 70)
0478 
0479     # Summary by terminal flag
0480     lost_flags = pf["flag"][lost_mask]
0481     fvals, fcounts = np.unique(lost_flags, return_counts=True)
0482     for fv, fc in zip(fvals, fcounts):
0483         print(f"  {flag_name(fv):<22s}: {fc}")
0484 
0485     # Flag photons stuck at MaxBounce
0486     if "record" in arrays:
0487         record = arrays["record"]
0488         maxstep = record.shape[1]
0489         nsteps = count_record_steps(record)
0490         lost_at_max = np.sum(nsteps[lost_idx] == maxstep)
0491         if lost_at_max > 0:
0492             print(f"\n  Note: {lost_at_max} lost photon(s) hit the {maxstep}-step"
0493                   f" bounce limit (trapped by total internal reflection?)")
0494     print()
0495 
0496     print_photon_detail(arrays, pf, lost_idx)
0497 
0498 
0499 def expected_output_path(executable="GPUPhotonSourceMinimal"):
0500     """Compute the expected opticks output path from environment variables.
0501 
0502     Opticks saves output to:
0503         $TMP/GEOM/$GEOM/$ExecutableName/ALL0_no_opticks_event_name/A000/
0504 
0505     Where:
0506         $TMP   defaults to /tmp/$USER/opticks
0507         $GEOM  defaults to GEOM
0508     """
0509     tmp = os.environ.get("TMP")
0510     if tmp is None:
0511         user = os.environ.get("USER", "MISSING_USER")
0512         tmp = f"/tmp/{user}/opticks"
0513     geom = os.environ.get("GEOM", "GEOM")
0514     return os.path.join(tmp, "GEOM", geom, executable,
0515                         "ALL0_no_opticks_event_name", "A000")
0516 
0517 
0518 def print_output_path_help():
0519     """Print where opticks writes output files."""
0520     print("OUTPUT FILE LOCATION")
0521     print("=" * 70)
0522     print()
0523     print("Opticks saves .npy arrays to:")
0524     print()
0525     print("    $TMP/GEOM/$GEOM/<ExecutableName>/ALL0_no_opticks_event_name/A000/")
0526     print()
0527     tmp = os.environ.get("TMP")
0528     if tmp is None:
0529         user = os.environ.get("USER", "MISSING_USER")
0530         tmp = f"/tmp/{user}/opticks"
0531         print(f"    $TMP  is not set, defaults to: {tmp}")
0532     else:
0533         print(f"    $TMP  = {tmp}")
0534     geom = os.environ.get("GEOM")
0535     if geom is None:
0536         print(f"    $GEOM is not set, defaults to: GEOM")
0537         geom = "GEOM"
0538     else:
0539         print(f"    $GEOM = {geom}")
0540     print()
0541     print("For common executables, the expected paths are:")
0542     print()
0543     for exe in ("GPUPhotonSourceMinimal", "GPUPhotonSource", "GPUPhotonFileSource"):
0544         p = expected_output_path(exe)
0545         print(f"    {exe}:")
0546         print(f"        {p}")
0547     print()
0548 
0549 
0550 def check_event_mode():
0551     """Check that OPTICKS_EVENT_MODE is set to a mode that saves output arrays."""
0552     valid_modes = ("HitPhoton", "DebugLite", "DebugHeavy")
0553     mode = os.environ.get("OPTICKS_EVENT_MODE")
0554     if mode is None:
0555         print("ERROR: OPTICKS_EVENT_MODE environment variable is not set.")
0556         print()
0557         print("This script requires photon/record arrays that are only saved")
0558         print("when OPTICKS_EVENT_MODE is set to one of:")
0559         print()
0560         for m in valid_modes:
0561             print(f"    export OPTICKS_EVENT_MODE={m}")
0562         print()
0563         print("The default mode (Minimal) gathers hits into memory but does")
0564         print("NOT save photon.npy, record.npy, or other arrays to disk.")
0565         print()
0566         print("Set the variable before running the simulation, e.g.:")
0567         print()
0568         print("    OPTICKS_EVENT_MODE=HitPhoton GPUPhotonSourceMinimal -g geo.gdml -c cfg -m run.mac")
0569         print()
0570         print_output_path_help()
0571         sys.exit(1)
0572     if mode not in valid_modes:
0573         print(f"ERROR: OPTICKS_EVENT_MODE={mode} does not save full output arrays.")
0574         print()
0575         print("This script requires OPTICKS_EVENT_MODE set to one of:")
0576         print()
0577         for m in valid_modes:
0578             print(f"    export OPTICKS_EVENT_MODE={m}")
0579         print()
0580         print(f"Current value '{mode}' may not save photon.npy or record.npy.")
0581         print()
0582         print_output_path_help()
0583         sys.exit(1)
0584 
0585 
0586 def main():
0587     parser = argparse.ArgumentParser(
0588         description="Opticks GPU simulation output analysis",
0589         formatter_class=argparse.RawDescriptionHelpFormatter,
0590         epilog=__doc__,
0591     )
0592     parser.add_argument("path", help="Path to opticks event folder (containing photon.npy)")
0593     parser.add_argument("--detail", type=int, metavar="N", default=0,
0594                         help="Show per-photon detail for first N photons")
0595     parser.add_argument("--trace", type=str, metavar="I,J,...", default=None,
0596                         help="Show step-by-step record for specific photon indices")
0597     parser.add_argument("--flag", type=str, metavar="FLAG", default=None,
0598                         help="Filter: only show photons with this terminal flag (e.g. BULK_ABSORB)")
0599     parser.add_argument("--lost", action="store_true",
0600                         help="Show all non-detected (lost) photons with step traces")
0601     parser.add_argument("--seq-top", type=int, metavar="N", default=15,
0602                         help="Number of top sequence histories to show (default: 15)")
0603 
0604     args = parser.parse_args()
0605 
0606     path = resolve_event_path(args.path)
0607     if not os.path.exists(os.path.join(path, "photon.npy")):
0608         print(f"Error: photon.npy not found in {path}")
0609         print()
0610         print("Make sure the simulation was run with OPTICKS_EVENT_MODE=HitPhoton")
0611         print("(or DebugLite/DebugHeavy) so that output arrays are saved to disk.")
0612         print()
0613         print_output_path_help()
0614         sys.exit(1)
0615 
0616     arrays = load_event(path)
0617     hitmask = read_hitmask(path)
0618     print(f"\nEvent folder: {path}\n")
0619     print_overview(arrays)
0620 
0621     photon = arrays["photon"]
0622     pf = extract_photon_fields(photon)
0623     n_photons = len(pf["flag"])
0624     n_hits = len(arrays["hit"]) if "hit" in arrays else 0
0625 
0626     # Apply flag filter if requested
0627     if args.flag:
0628         # Resolve flag name to value
0629         flag_val = None
0630         for v, name in FLAG_ENUM.items():
0631             if name == args.flag.upper():
0632                 flag_val = v
0633                 break
0634         if flag_val is None:
0635             print(f"Error: unknown flag '{args.flag}'. Available flags:")
0636             for v, name in sorted(FLAG_ENUM.items()):
0637                 print(f"  {name}")
0638             sys.exit(1)
0639 
0640         mask = pf["flag"] == flag_val
0641         indices = np.where(mask)[0]
0642         print(f"Filter: terminal flag = {args.flag.upper()} ({len(indices)} photons)\n")
0643         print_photon_detail(arrays, pf, indices)
0644         return
0645 
0646     # Standard tables
0647     print_outcome_table(pf, n_photons, n_hits, hitmask, arrays)
0648     print_flagmask_table(pf, n_photons)
0649     print_wavelength_table(pf, arrays)
0650     print_position_table(pf)
0651     print_seq_table(arrays, n_show=args.seq_top)
0652     print_record_steps_table(arrays)
0653 
0654     # Optional detail views
0655     if args.detail > 0:
0656         indices = list(range(min(args.detail, n_photons)))
0657         print_photon_detail(arrays, pf, indices)
0658 
0659     if args.trace:
0660         indices = [int(x.strip()) for x in args.trace.split(",")]
0661         print_photon_detail(arrays, pf, indices)
0662 
0663     if args.lost:
0664         print_lost_photons(arrays, pf, hitmask)
0665 
0666 
0667 if __name__ == "__main__":
0668     main()