Files
aare/python/tests/helper.py
kferjaoui 3504d96336 ClusterFinderFrozen: CPU finder for CUDA correctness study
- Diagnostic twin of ClusterFinder: identical decisions, but the pedestal is
  frozen per frame (snapshot at frame start, updates deferred to frame end),
  matching the CUDA kernel's update model.
- Isolates pedestal-update timing as the sole remaining CPU/GPU mismatch.
- Adds class + bindings + factory, validation notebook, and helper utilities.
2026-08-03 11:59:27 +02:00

405 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Helpers for the CPU-vs-CUDA ClusterFinder mismatch analysis.
Pure, parameterised versions of the functions that used to live inline in the
notebook's forensic-view cell, plus the reusable scan + plot routines so the
notebook cells stay thin. Import in the notebook with::
from helper import (centers, only_sets, footprint_mask, shift_dist,
train_pedestal, scan_mismatches, plot_masked_mismatch)
None of these rely on notebook globals — cluster geometry (rx, ry) and the
frame shape (rows, cols) are passed in explicitly.
"""
import numpy as np
import matplotlib.pyplot as plt
import boost_histogram as bh
# --------------------------------------------------------------------------- #
# Pure helpers
# --------------------------------------------------------------------------- #
def print_pinning_budget(rows, cols, dtype=np.uint16, headroom_gb=4.0):
"""
Print system RAM stats and estimate the maximum number of frames
that can be safely registered with cudaHostRegister / register_input_buffer.
Parameters
----------
rows, cols : int Detector frame dimensions.
dtype : np.dtype Frame element type (default uint16 = 2 bytes/pixel).
headroom_gb: float RAM to keep free for OS + CUDA context (default 4 GB).
"""
# /proc/meminfo is always available on Linux — no extra dependency needed
meminfo = {}
with open('/proc/meminfo') as f:
for line in f:
key, val = line.split(':')
meminfo[key.strip()] = int(val.split()[0]) * 1024 # kB → bytes
total_bytes = meminfo['MemTotal']
available_bytes = meminfo['MemAvailable'] # free + reclaimable cache
safe_bytes = max(0, available_bytes - int(headroom_gb * 1024**3))
frame_bytes = rows * cols * np.dtype(dtype).itemsize
max_frames = safe_bytes // frame_bytes
GiB = 1024**3
print("── System RAM ──────────────────────────────────────────")
print(f" Total RAM : {total_bytes/GiB:.1f} GiB")
print(f" Currently available : {available_bytes/GiB:.1f} GiB "
f"(free + reclaimable cache)")
print(f" Reserved headroom : {headroom_gb:.1f} GiB "
f"(OS + CUDA context)")
print(f" Safe pinning budget : {safe_bytes/GiB:.1f} GiB")
print()
print("── Frame layout ────────────────────────────────────────")
print(f" Frame size : {rows} × {cols} × "
f"{np.dtype(dtype).itemsize} B = {frame_bytes/1024:.1f} kB")
print()
print("── Pinning estimate ────────────────────────────────────")
print(f" Max frames pinnable : {max_frames:,} "
f"({max_frames * frame_bytes / GiB:.1f} GiB)")
print()
print(" Note: no swap on this machine — exceeding available RAM")
print(" will trigger the OOM killer. Stay within the budget.")
def centers(cv):
"""Set of ``(x, y)`` integer cluster centres from a ClusterVector."""
if cv.size == 0:
return set()
a = np.asarray(cv)
return {(int(x), int(y)) for x, y in zip(a["x"], a["y"])}
def only_sets(cpu_c, cu_c, tol=1):
"""CPU-only / CUDA-only centres, ignoring ``<=tol`` px 'shifted' matches.
``tol=0`` returns the exact set difference (shifted twins stay counted as
mismatches); ``tol=1`` drops any mismatch that has a counterpart in the
other finder's 8-neighbourhood.
"""
def near(p, other):
x, y = p
return any((x + dx, y + dy) in other
for dx in range(-tol, tol + 1)
for dy in range(-tol, tol + 1))
cpu_only = {p for p in cpu_c - cu_c if not near(p, cu_c)}
cu_only = {p for p in cu_c - cpu_c if not near(p, cpu_c)}
return cpu_only, cu_only
def footprint_mask(cs, shape, rx, ry):
"""Boolean map: True where a pixel lies in some cluster's footprint.
Each centre paints a ``(2*ry+1) x (2*rx+1)`` box, clipped at the borders.
"""
m = np.zeros(shape, bool)
for (cx, cy) in cs:
m[max(0, cy - ry):cy + ry + 1, max(0, cx - rx):cx + rx + 1] = True
return m
def shift_dist(p, other, R=4):
"""Chebyshev distance from ``p`` to the nearest member of ``other``.
Rings are searched inner-to-outer so the first hit is the nearest.
Returns ``-1`` if nothing lies within ``R`` px.
"""
x, y = p
for r in range(1, R + 1):
for dx in range(-r, r + 1):
for dy in range(-r, r + 1):
if max(abs(dx), abs(dy)) == r and (x + dx, y + dy) in other:
return r
return -1
# --------------------------------------------------------------------------- #
# Reusable scan + plot
# --------------------------------------------------------------------------- #
def train_pedestal(finders, f, n_frames, seek=0):
"""Push the first ``n_frames`` frames of ``f`` into every finder given."""
f.seek(seek)
for _ in range(n_frames):
img = f.read_frame().copy()
for cf in finders:
cf.push_pedestal_frame(img)
def scan_mismatches(cf_cpu, cf_cuda, data, rx, ry,
scan_count=1000, n_show=8, tol=0):
"""Run both finders over ``scan_count`` frames sampled across ``data``.
Before every frame it snapshots the pedestal each finder will DECIDE with
(CPU: host mean/rms; CUDA: device mean/rms on stream 0), so a later
recompute uses the exact decision-time baseline.
``tol`` is forwarded to :func:`only_sets` when scoring a frame; ``tol=0``
keeps shifted twins as mismatches. Returns ``(show, totals)`` where
``show`` is the ``n_show`` frames with the most mismatches, each a dict
with ``fid``, ``score``, ``cpu_c``, ``cu_c``, ``mism`` and the four
pedestal arrays ``ped_cpu/noise_cpu/ped_cu/noise_cu``.
"""
show = []
tot_cpu_only = tot_cu_only = 0
for fid in np.linspace(0, len(data) - 1, scan_count, dtype=int):
snap = dict(ped_cpu=np.asarray(cf_cpu.pedestal).copy(),
noise_cpu=np.asarray(cf_cpu.noise).copy(),
ped_cu=np.asarray(cf_cuda.device_pedestal(0)).copy(),
noise_cu=np.asarray(cf_cuda.device_noise(0)).copy())
cf_cpu.find_clusters(data[fid])
cpu_c = centers(cf_cpu.steal_clusters(realloc_same_capacity=True))
cf_cuda.find_clusters(data[fid])
cu_c = centers(cf_cuda.steal_clusters(realloc_same_capacity=True))
cpu_only, cu_only = only_sets(cpu_c, cu_c, tol=tol)
tot_cpu_only += len(cpu_only)
tot_cu_only += len(cu_only)
mism = cpu_only | cu_only
if not mism:
continue
if len(show) < n_show or len(mism) > show[-1]["score"]:
show.append(dict(score=len(mism), fid=int(fid),
cpu_c=cpu_c, cu_c=cu_c, mism=mism, **snap))
show.sort(key=lambda e: -e["score"])
del show[n_show:]
return show, dict(cpu_only=tot_cpu_only, cu_only=tot_cu_only)
def compare_finders(finders, data, scan_count=1000, n_bins=200, e_range=(-2, 4000)):
"""Run several finders over the same frames and score pairwise agreement.
``finders`` is a dict ``{name: finder}``; every finder must already be
trained on the SAME pedestal frames. Each is run over the same
``scan_count`` frames sampled across ``data`` (identical ``find_clusters``/
``steal_clusters`` API for CPU, frozen-CPU and CUDA). In the same pass it
accumulates a per-finder cluster-energy histogram (from ``cv.sum()``), so no
extra scan is needed to draw the spectra.
Returns ``(totals, pairs, frames_scanned, hists)``:
* ``totals[name]`` total clusters found by that finder
* ``pairs[(a, b)]`` dict with ``a_only``/``b_only``/``mismatch``
summed over frames (exact, tol=0)
* ``hists[name]`` boost ``Histogram`` of cluster energies
Companions :func:`print_comparison` and :func:`plot_spectra` render these.
"""
names = list(finders)
totals = {n: 0 for n in names}
hists = {n: bh.Histogram(bh.axis.Regular(n_bins, *e_range)) for n in names}
pairs = {}
for i, a in enumerate(names):
for b in names[i + 1:]:
pairs[(a, b)] = dict(a_only=0, b_only=0, mismatch=0)
fids = np.linspace(0, len(data) - 1, scan_count, dtype=int)
for fid in fids:
cs = {}
for n, cf in finders.items():
cf.find_clusters(data[fid])
cv = cf.steal_clusters(realloc_same_capacity=True)
cs[n] = centers(cv)
totals[n] += len(cs[n])
if cv.size:
hists[n].fill(np.asarray(cv.sum()).ravel())
for (a, b), acc in pairs.items():
a_only, b_only = only_sets(cs[a], cs[b], tol=0)
acc["a_only"] += len(a_only)
acc["b_only"] += len(b_only)
acc["mismatch"] += len(a_only) + len(b_only)
return totals, pairs, len(fids), hists
def plot_spectra(hists, totals=None, title="Cluster energy spectrum"):
"""Overlay per-finder cluster-energy spectra with a ratio panel.
``hists`` is the ``{name: Histogram}`` returned by :func:`compare_finders`;
the first finder is the reference for the ratio panel. Returns the Figure.
"""
names = list(hists)
ref = names[0]
edges = hists[ref].axes[0].edges
vals = {n: hists[n].values() for n in names}
fig, (ax_spec, ax_ratio) = plt.subplots(
2, 1, figsize=(8, 6), sharex=True,
gridspec_kw={"height_ratios": [3, 1]})
styles = ["-", "--", "-.", ":"]
for i, n in enumerate(names):
lbl = n if totals is None else f"{n} ({totals[n]:,} clusters)"
ax_spec.stairs(vals[n], edges, label=lbl, linestyle=styles[i % len(styles)])
ax_spec.set_ylabel("Counts")
ax_spec.set_title(title)
ax_spec.legend()
ax_spec.grid(alpha=0.2)
with np.errstate(divide="ignore", invalid="ignore"):
for i, n in enumerate(names[1:], start=1):
ratio = np.where(vals[ref] > 0, vals[n] / vals[ref], np.nan)
ax_ratio.stairs(ratio, edges, label=f"{n} / {ref}",
color=f"C{i}", linestyle=styles[i % len(styles)])
ax_ratio.axhline(1.0, color="gray", linewidth=0.5)
ax_ratio.set_ylabel(f"/ {ref}")
ax_ratio.set_xlabel("Energy [ADU]")
ax_ratio.set_ylim(0.5, 2.0)
ax_ratio.legend(fontsize=8)
ax_ratio.grid(alpha=0.3)
plt.tight_layout()
plt.show()
return fig
def print_comparison(totals, pairs, frames_scanned):
"""Pretty-print the output of :func:`compare_finders`."""
print(f"Scanned {frames_scanned} frames\n")
print("Total clusters per finder:")
for n, t in totals.items():
print(f" {n:<16} {t:>12,}")
print("\nPairwise exact mismatches (tol=0):")
print(f" {'pair':<28} {'A-only':>10} {'B-only':>10} {'total':>10}")
for (a, b), acc in pairs.items():
ref = max(totals[a], totals[b], 1)
pct = 100.0 * acc["mismatch"] / ref
print(f" {a+' vs '+b:<28} {acc['a_only']:>10,} "
f"{acc['b_only']:>10,} {acc['mismatch']:>10,} ({pct:.4f}%)")
def walkthrough(show, data, rx, ry, rows, cols, n_sigma, pick=0,
labels=('A', 'B')):
"""Manual Test1/Test3 recompute of the strongest residual mismatch.
Dissects ``show[pick]`` (from :func:`scan_mismatches`, run as ``(a, b)``)
under each finder's decision-time snapshot pedestal — ``a`` = ``ped_cpu`` /
``noise_cpu``, ``b`` = ``ped_cu`` / ``noise_cu`` — and prints the raw and
pedestal-subtracted window plus the accept/reject each finder reaches. With
the double/double build the recompute reproduces the kernel exactly, so a
lone surviving mismatch (e.g. the single 7x7 residual) is fully explained:
the test that flips (Test1/Test3) and the pedestal gap name the cause.
"""
if not show:
print("No residual mismatches to walk through.")
return
e = show[pick]
sx, sy = 2 * rx + 1, 2 * ry + 1
c3 = np.sqrt(sx * sy)
frame = data[e['fid']].astype(np.float64)
sub_a = frame - e['ped_cpu']
sub_b = frame - e['ped_cu']
# strongest mismatch pixel whose full window stays inside the frame
cand = [p for p in e['mism']
if rx <= p[0] < cols - rx and ry <= p[1] < rows - ry]
if not cand:
print(f"frame {e['fid']}: all mismatches on the border — pick another.")
return
X0, Y0 = max(cand, key=lambda p: max(sub_a[p[1], p[0]], sub_b[p[1], p[0]]))
def evaluate(mean, rms_img):
sig = (frame[Y0 - ry:Y0 + ry + 1, X0 - rx:X0 + rx + 1]
- mean[Y0 - ry:Y0 + ry + 1, X0 - rx:X0 + rx + 1])
value = frame[Y0, X0] - mean[Y0, X0]
rms = rms_img[Y0, X0]
thr1, thr3 = n_sigma * rms, c3 * n_sigma * rms
mx, total = sig.max(), sig.sum()
localmax = bool(value >= mx)
t1, t3 = bool(mx > thr1), bool(total > thr3)
accept = bool(value >= -thr1) and localmax and (t1 or t3)
return dict(sig=sig, value=value, rms=rms, thr1=thr1, thr3=thr3,
mx=mx, total=total, localmax=localmax, t1=t1, t3=t3,
accept=accept)
la, lb = labels
owner = la if (X0, Y0) in e['cpu_c'] else lb
print(f"frame {e['fid']} centre (x={X0}, y={Y0}) accepted only by {owner}")
print("raw window (ADU):")
print(np.array2string(frame[Y0 - ry:Y0 + ry + 1,
X0 - rx:X0 + rx + 1].astype(int)))
print()
ra = evaluate(e['ped_cpu'], e['noise_cpu'])
rb = evaluate(e['ped_cu'], e['noise_cu'])
def detail(tag, r):
print(f"--- {tag} ---")
print(" subtracted window:")
print(" ", np.array2string(r['sig'], precision=1, prefix=' '))
print(f" centre value = {float(r['value']):8.2f} "
f"(local max? {r['localmax']})")
print(f" max = {float(r['mx']):8.2f} "
f"total = {float(r['total']):8.2f}")
print(f" rms(centre) = {float(r['rms']):8.3f}")
print(f" Test1: max > {n_sigma}*rms = {float(r['thr1']):8.2f} -> {r['t1']}")
print(f" Test3: total > {c3:.0f}*{n_sigma}*rms = {float(r['thr3']):8.2f} -> {r['t3']}")
print(f" ACCEPT = {r['accept']}")
print()
detail(la, ra)
detail(lb, rb)
print(f"RESULT: {la} = {'ACCEPT' if ra['accept'] else 'reject'} , "
f"{lb} = {'ACCEPT' if rb['accept'] else 'reject'}")
gap = abs(float(e['ped_cpu'][Y0, X0] - e['ped_cu'][Y0, X0]))
rgap = abs(float(e['noise_cpu'][Y0, X0] - e['noise_cu'][Y0, X0]))
print(f"pedestal mean gap @centre = {gap:.4f} ADU; rms gap = {rgap:.4f}")
if ra['accept'] == rb['accept']:
print("NOTE: recompute agrees for this centre pixel — the split is FP "
"rounding right at threshold or a window-neighbour effect; "
"try another pick.")
def plot_masked_mismatch(show, data, rx, ry, rows, cols,
zoom=30, show_vals=True):
"""Side-by-side masked view of each frame in ``show``.
Cluster pixels are coloured by pedestal-subtracted value (value printed
when ``show_vals``); non-cluster pixels are white; a red dot marks every
cluster centre. The cut is centred on the strongest mismatch pixel of the
frame. No box, no tolerance discarding — a shifted twin in the other
finder stays visible. Returns the Figure.
"""
cmap = plt.cm.viridis.copy()
cmap.set_bad("white")
fig, axes = plt.subplots(len(show), 2, figsize=(11, 5.3 * len(show)),
squeeze=False)
for r, e in enumerate(show):
fid = e["fid"]
sub_cpu = data[fid].astype(np.float64) - e["ped_cpu"]
sub_cu = data[fid].astype(np.float64) - e["ped_cu"]
X0, Y0 = max(e["mism"],
key=lambda p: max(sub_cpu[p[1], p[0]], sub_cu[p[1], p[0]]))
half = zoom // 2
r0, r1 = max(0, Y0 - half), min(rows, Y0 + half)
c0, c1 = max(0, X0 - half), min(cols, X0 + half)
win_cpu, win_cu = sub_cpu[r0:r1, c0:c1], sub_cu[r0:r1, c0:c1]
mask_cpu = footprint_mask(e["cpu_c"], sub_cpu.shape, rx, ry)[r0:r1, c0:c1]
mask_cu = footprint_mask(e["cu_c"], sub_cu.shape, rx, ry)[r0:r1, c0:c1]
union = mask_cpu | mask_cu
vmax = max(np.percentile(np.concatenate([win_cpu[union], win_cu[union]]),
99) if union.any() else 50.0, 50.0)
for ax, win, mask, cs, name in [
(axes[r][0], win_cpu, mask_cpu, e["cpu_c"], "CPU"),
(axes[r][1], win_cu, mask_cu, e["cu_c"], "CUDA")]:
ax.imshow(np.ma.masked_where(~mask, win), cmap=cmap, vmin=0,
vmax=vmax, interpolation="nearest")
for (cx, cy) in cs:
if c0 <= cx < c1 and r0 <= cy < r1:
ax.plot(cx - c0, cy - r0, ".", color="red", ms=7)
if show_vals:
for i in range(win.shape[0]):
for j in range(win.shape[1]):
if mask[i, j]:
ax.text(j, i, f"{win[i, j]:.0f}", ha="center",
va="center", fontsize=6,
color="white" if win[i, j] < 0.55 * vmax
else "black")
ax.set_title(f"frame {fid}{name}: {len(cs)} clusters", fontsize=10)
ax.set_xticks([])
ax.set_yticks([])
axes[r][0].set_ylabel(f"{e['score']} mismatches\nzoom @ ({X0},{Y0})",
fontsize=9)
plt.tight_layout()
plt.show()
return fig