Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
91da573
feat: add StandAloneGeant4Validation for CPU-only optical photon benc…
ggalgoczi Mar 26, 2026
fc550be
feat: add --aligned mode to StandAloneGeant4Validation
ggalgoczi Mar 28, 2026
503af97
simplify --aligned mode: drop U4Recorder/InstrumentedG4OpBoundaryProc…
ggalgoczi Mar 28, 2026
49e3cd2
add AlignedOpticalPhysics with ShimG4Op* for better RNILL alignment
ggalgoczi Mar 28, 2026
4ac2651
add standalone precooked curand sequence generator
ggalgoczi Mar 28, 2026
61bd61c
add photon-by-photon aligned comparison script
ggalgoczi Mar 28, 2026
3545f4b
add chi2 and glancing-angle analysis to comparison script
ggalgoczi Mar 28, 2026
a502329
add G4ValidationGenstep: electron primary for genstep validation
ggalgoczi Mar 29, 2026
cd86a85
allow OPTICKS_MAX_RECORD env var to override DebugLite default
ggalgoczi Apr 2, 2026
1de687f
fix WLS time profile: use exponential instead of default delta
ggalgoczi Apr 2, 2026
5049d68
revert WLS time profile from GPURaytrace
ggalgoczi Apr 2, 2026
b9cf990
style: clang-format new G4 validation sources
ggalgoczi May 9, 2026
5888c19
docs(tools): clarify generate_precooked_rng vs QCurandStateMonolithic…
ggalgoczi May 9, 2026
8f92576
style: apply clang-format to touched assert in SEventConfig.cc
ggalgoczi May 9, 2026
676cfe9
Merge branch 'main' into feat/aligned-validation
plexoos May 12, 2026
aca45bd
Merge branch 'main' into feat/aligned-validation
plexoos May 13, 2026
7fa5472
Merge branch 'main' into feat/aligned-validation
plexoos May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions optiphy/ana/compare_aligned.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""
compare_aligned.py - Photon-by-photon comparison of GPU vs G4 aligned simulations.

Usage:
python compare_aligned.py <gpu_photon.npy> <g4_photon.npy>

Performs:
1. Per-photon flag comparison (exact match rate)
2. Position comparison at multiple thresholds
3. Chi-squared test on flag distributions (gold-standard validation metric)
4. Glancing-angle photon identification (normal sign ambiguity)
5. Divergent photon listing
"""
import sys
import numpy as np

FLAG_NAMES = {
0x0004: "TORCH", 0x0008: "BULK_ABSORB", 0x0010: "BULK_REEMIT",
0x0020: "BULK_SCATTER", 0x0040: "SURFACE_DETECT", 0x0080: "SURFACE_ABSORB",
0x0100: "SURFACE_DREFLECT", 0x0200: "SURFACE_SREFLECT",
0x0400: "BOUNDARY_REFLECT", 0x0800: "BOUNDARY_TRANSMIT", 0x8000: "MISS",
}

def flag_name(f):
return FLAG_NAMES.get(f, f"0x{f:04x}")

def extract_flag(photon):
"""Extract flag from q3.x (orient_boundary_flag) - lower 16 bits."""
q3 = photon.view(np.uint32).reshape(-1, 4, 4)
return q3[:, 3, 0] & 0xFFFF

def chi2_flag_distribution(gpu_flags, g4_flags):
"""
Chi-squared comparison of flag distributions.

Compares the frequency of each flag value between GPU and G4.
This is the opticks gold-standard validation metric.

Returns (chi2, ndof, flags_used, gpu_counts, g4_counts).
"""
all_flags = sorted(set(gpu_flags) | set(g4_flags))
gpu_counts = np.array([(gpu_flags == f).sum() for f in all_flags], dtype=float)
g4_counts = np.array([(g4_flags == f).sum() for f in all_flags], dtype=float)

total = gpu_counts + g4_counts
mask = total > 0
gpu_c = gpu_counts[mask]
g4_c = g4_counts[mask]
tot = total[mask]
flags_used = [f for f, m in zip(all_flags, mask) if m]

n_gpu = gpu_c.sum()
n_g4 = g4_c.sum()
expected_gpu = tot * n_gpu / (n_gpu + n_g4)
expected_g4 = tot * n_g4 / (n_gpu + n_g4)

chi2 = 0.0
for i in range(len(flags_used)):
if expected_gpu[i] > 0:
chi2 += (gpu_c[i] - expected_gpu[i])**2 / expected_gpu[i]
if expected_g4[i] > 0:
chi2 += (g4_c[i] - expected_g4[i])**2 / expected_g4[i]

ndof = max(len(flags_used) - 1, 1)
return chi2, ndof, flags_used, gpu_c, g4_c

def identify_glancing(gpu, g4):
"""
Identify glancing-angle photons where the normal sign ambiguity
causes momentum negation between GPU and G4.

At glancing incidence cos(theta) ~ 0, float32 vs float64 can produce
opposite normal signs, reflecting the photon in the opposite direction.
These photons have matching flags but very different positions.

Returns boolean mask of glancing photons.
"""
gpu_mom = gpu[:, 1, :3]
g4_mom = g4[:, 1, :3]

# Normalize momenta (should already be unit vectors, but be safe)
gpu_norm = np.linalg.norm(gpu_mom, axis=1, keepdims=True)
g4_norm = np.linalg.norm(g4_mom, axis=1, keepdims=True)
gpu_norm[gpu_norm == 0] = 1
g4_norm[g4_norm == 0] = 1

gpu_hat = gpu_mom / gpu_norm
g4_hat = g4_mom / g4_norm

# Dot product of momentum directions: -1 = fully negated (normal flip)
mom_dot = np.sum(gpu_hat * g4_hat, axis=1)

# Glancing: momentum vectors are nearly anti-parallel (dot ~ -1)
glancing = mom_dot < -0.5
return glancing, mom_dot

def main():
if len(sys.argv) < 3:
print(f"Usage: {sys.argv[0]} <gpu_photon.npy> <g4_photon.npy>")
sys.exit(1)

gpu = np.load(sys.argv[1])
g4 = np.load(sys.argv[2])

print(f"GPU shape: {gpu.shape}")
print(f"G4 shape: {g4.shape}")

n = min(len(gpu), len(g4))
gpu = gpu[:n]
g4 = g4[:n]

gpu_flags = extract_flag(gpu)
g4_flags = extract_flag(g4)

# ---- 1. Per-photon flag comparison ----
match = gpu_flags == g4_flags
n_match = match.sum()
n_diff = n - n_match
print(f"\n{'='*60}")
print(f"FLAG COMPARISON ({n} photons)")
print(f"{'='*60}")
print(f" Matching: {n_match} ({100*n_match/n:.1f}%)")
print(f" Differ: {n_diff} ({100*n_diff/n:.1f}%)")

# ---- 2. Position comparison ----
gpu_pos = gpu[:, 0, :3]
g4_pos = g4[:, 0, :3]
pos_diff = np.linalg.norm(gpu_pos - g4_pos, axis=1)
zero_g4 = np.all(g4_pos == 0, axis=1)

valid = ~zero_g4
n_valid = valid.sum()
print(f"\n{'='*60}")
print(f"POSITION COMPARISON ({n_valid} valid, {zero_g4.sum()} unrecorded)")
print(f"{'='*60}")
if n_valid > 0:
vdiff = pos_diff[valid]
print(f" Mean dist: {vdiff.mean():.4f} mm")
print(f" Max dist: {vdiff.max():.4f} mm")
print(f" < 0.01 mm: {(vdiff < 0.01).sum()} ({100*(vdiff < 0.01).sum()/n_valid:.1f}%)")
print(f" < 0.1 mm: {(vdiff < 0.1).sum()} ({100*(vdiff < 0.1).sum()/n_valid:.1f}%)")
print(f" < 1.0 mm: {(vdiff < 1.0).sum()} ({100*(vdiff < 1.0).sum()/n_valid:.1f}%)")

# ---- 3. Chi-squared test on flag distributions ----
print(f"\n{'='*60}")
print(f"CHI-SQUARED TEST (flag distribution)")
print(f"{'='*60}")

chi2_val, ndof, flags_used, gpu_c, g4_c = chi2_flag_distribution(gpu_flags, g4_flags)

print(f" {'Flag':<20s} {'GPU':>8s} {'G4':>8s} {'Diff':>8s}")
print(f" {'-'*20} {'-'*8} {'-'*8} {'-'*8}")
for i, f in enumerate(flags_used):
diff = int(gpu_c[i] - g4_c[i])
sign = "+" if diff > 0 else ""
print(f" {flag_name(f):<20s} {int(gpu_c[i]):>8d} {int(g4_c[i]):>8d} {sign}{diff:>7d}")

deviant_frac = 100 * n_diff / n if n > 0 else 0
print(f"\n chi2/ndof = {chi2_val:.2f}/{ndof} = {chi2_val/ndof:.2f}")
print(f" deviant fraction: {deviant_frac:.2f}% ({n_diff}/{n})")

# ---- 4. Glancing-angle analysis ----
print(f"\n{'='*60}")
print(f"GLANCING-ANGLE ANALYSIS (normal sign ambiguity)")
print(f"{'='*60}")

glancing, mom_dot = identify_glancing(gpu, g4)
n_glancing = glancing.sum()

# Among matching-flag photons, how many are glancing with large pos diff?
match_glancing = match & glancing
match_large_pos = match & (pos_diff > 1.0)
match_glancing_large = match & glancing & (pos_diff > 1.0)

print(f" Glancing photons (mom dot < -0.5): {n_glancing}")
print(f" Matching flag + pos diff > 1mm: {match_large_pos.sum()}")
print(f" Of those, glancing: {match_glancing_large.sum()}")
if match_large_pos.sum() > 0:
frac = 100 * match_glancing_large.sum() / match_large_pos.sum()
print(f" Fraction explained by glancing: {frac:.0f}%")

# Position stats excluding glancing photons
non_glancing_match = match & ~glancing & valid
if non_glancing_match.sum() > 0:
ng_diff = pos_diff[non_glancing_match]
print(f"\n Position (matching, non-glancing, {non_glancing_match.sum()} photons):")
print(f" Max dist: {ng_diff.max():.6f} mm")
print(f" Mean dist: {ng_diff.mean():.6f} mm")
print(f" < 0.01 mm: {(ng_diff < 0.01).sum()} ({100*(ng_diff < 0.01).sum()/non_glancing_match.sum():.1f}%)")

# ---- 5. Divergent photon listing ----
if n_diff > 0:
div_idx = np.where(~match)[0]
print(f"\n{'='*60}")
print(f"DIVERGENT PHOTONS (first 10 of {n_diff})")
print(f"{'='*60}")
for i in div_idx[:10]:
gf = flag_name(gpu_flags[i])
cf = flag_name(g4_flags[i])
gp = gpu_pos[i]
cp = g4_pos[i]
print(f" [{i:5d}] GPU: {gf:20s} pos=({gp[0]:8.2f},{gp[1]:8.2f},{gp[2]:8.2f})")
print(f" G4: {cf:20s} pos=({cp[0]:8.2f},{cp[1]:8.2f},{cp[2]:8.2f})")

if __name__ == "__main__":
main()
171 changes: 171 additions & 0 deletions optiphy/ana/run_genstep_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""
run_genstep_comparison.py
==========================

Runs GPU (simg4ox) and G4 (G4ValidationGenstep) simulations with the same
electron primary, then compares the optical photon hit distributions.

Usage:
python run_genstep_comparison.py [--gdml det.gdml] [--energy 1.0] [--nevents 10] [--seed 42]
"""
import os
import sys
import subprocess
import argparse
import numpy as np
from pathlib import Path

def find_gpu_hits():
"""Find the most recent GPU hit.npy output."""
base = Path(f"/tmp/{os.environ.get('USER','MISSING_USER')}/opticks")
candidates = sorted(base.rglob("hit.npy"), key=lambda p: p.stat().st_mtime, reverse=True)
return str(candidates[0]) if candidates else None

def run_g4(gdml, energy, nevents, seed, pos, direction):
"""Run pure G4 simulation with electron primary."""
cmd = [
"G4ValidationGenstep",
"-g", gdml,
"-e", str(energy),
"-n", str(nevents),
"-s", str(seed),
"--pos", pos,
"--dir", direction,
]
print(f"=== Running G4: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)

# Extract hit count from output
g4_hits = 0
for line in result.stdout.split('\n'):
if "Total hits:" in line:
g4_hits = int(line.split("Total hits:")[-1].strip())

print(f"G4: {g4_hits} hits")
if result.returncode != 0:
print(f"G4 STDERR (last 5 lines):")
for line in result.stderr.strip().split('\n')[-5:]:
print(f" {line}")
return g4_hits

def run_gpu(gdml, config, macro, seed):
"""Run GPU simulation via simg4ox."""
env = os.environ.copy()
env["OPTICKS_INTEGRATION_MODE"] = "1" # Minimal mode: G4 tracks electron, GPU propagates optical

cmd = [
"simg4ox",
"-g", gdml,
"-c", config,
"-m", macro,
]
print(f"\n=== Running GPU: {' '.join(cmd)}")
print(f" OPTICKS_INTEGRATION_MODE=1 (Minimal)")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, env=env)

if result.returncode != 0:
print(f"GPU STDERR (last 10 lines):")
for line in result.stderr.strip().split('\n')[-10:]:
print(f" {line}")
return 0

# Find hit output
hit_path = find_gpu_hits()
if hit_path and os.path.exists(hit_path):
hits = np.load(hit_path)
print(f"GPU: {len(hits)} hits (from {hit_path})")
return len(hits)
else:
print("GPU: no hit.npy found")
return 0

def compare_hits(g4_path, gpu_path):
"""Compare G4 and GPU hit arrays."""
if not os.path.exists(g4_path):
print(f"G4 hits not found: {g4_path}")
return
if not gpu_path or not os.path.exists(gpu_path):
print(f"GPU hits not found")
return

g4 = np.load(g4_path)
gpu = np.load(gpu_path)

print(f"\n{'='*60}")
print(f"HIT COMPARISON")
print(f"{'='*60}")
print(f" G4 hits: {len(g4)}")
print(f" GPU hits: {len(gpu)}")

if len(g4) > 0 and len(gpu) > 0:
diff = len(gpu) - len(g4)
pct = 100 * diff / len(g4) if len(g4) > 0 else 0
sign = "+" if diff > 0 else ""
print(f" Diff: {sign}{diff} ({sign}{pct:.1f}%)")

# Position distributions
if len(g4) > 0:
g4_pos = g4[:, 0, :3]
print(f"\n G4 hit positions:")
print(f" x: [{g4_pos[:,0].min():.1f}, {g4_pos[:,0].max():.1f}] mm")
print(f" y: [{g4_pos[:,1].min():.1f}, {g4_pos[:,1].max():.1f}] mm")
print(f" z: [{g4_pos[:,2].min():.1f}, {g4_pos[:,2].max():.1f}] mm")

if len(gpu) > 0:
gpu_pos = gpu[:, 0, :3]
print(f"\n GPU hit positions:")
print(f" x: [{gpu_pos[:,0].min():.1f}, {gpu_pos[:,0].max():.1f}] mm")
print(f" y: [{gpu_pos[:,1].min():.1f}, {gpu_pos[:,1].max():.1f}] mm")
print(f" z: [{gpu_pos[:,2].min():.1f}, {gpu_pos[:,2].max():.1f}] mm")

# Wavelength distributions
if len(g4) > 0:
g4_wl = g4[:, 2, 3]
print(f"\n G4 wavelength: mean={g4_wl.mean():.1f} std={g4_wl.std():.1f} nm")
if len(gpu) > 0:
gpu_wl = gpu[:, 2, 3]
print(f" GPU wavelength: mean={gpu_wl.mean():.1f} std={gpu_wl.std():.1f} nm")

# Time distributions
if len(g4) > 0:
g4_t = g4[:, 0, 3]
print(f"\n G4 time: mean={g4_t.mean():.2f} max={g4_t.max():.2f} ns")
if len(gpu) > 0:
gpu_t = gpu[:, 0, 3]
print(f" GPU time: mean={gpu_t.mean():.2f} max={gpu_t.max():.2f} ns")


def main():
parser = argparse.ArgumentParser(description="Compare GPU vs G4 electron genstep simulation")
parser.add_argument("--gdml", default="det.gdml", help="GDML geometry file")
parser.add_argument("--energy", type=float, default=1.0, help="Electron energy in MeV")
parser.add_argument("--nevents", type=int, default=10, help="Number of events")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument("--pos", default="0,0,100", help="Electron position x,y,z mm")
parser.add_argument("--dir", default="0,0,1", help="Electron direction x,y,z")
args = parser.parse_args()

# Run G4
g4_hits = run_g4(args.gdml, args.energy, args.nevents, args.seed, args.pos, args.dir)

# Compare
g4_path = "g4_genstep_hits.npy"
gpu_path = find_gpu_hits()

if os.path.exists(g4_path):
g4 = np.load(g4_path)
print(f"\n{'='*60}")
print(f"G4 RESULTS ({args.nevents} events, {args.energy} MeV electron)")
print(f"{'='*60}")
print(f" Total hits: {len(g4)}")
print(f" Hits/event: {len(g4)/args.nevents:.1f}")
if len(g4) > 0:
g4_wl = g4[:, 2, 3]
g4_pos = g4[:, 0, :3]
print(f" Wavelength: mean={g4_wl.mean():.1f} nm")
print(f" Hit y range: [{g4_pos[:,1].min():.1f}, {g4_pos[:,1].max():.1f}] mm")


if __name__ == "__main__":
main()
Loading
Loading