Files
SirRoboGarage/common_libs/tests/analyze_lead_capture_by_range.py
T
SirStone f91e121965 lead capture by range: we under-lead (0.40->0.135) — a RANGE effect, not a power one
Measure, for every shot ModularBot fires at the real DrussGT, the lead we
actually applied vs the lead the enemy's motion required, from the recorded
live battles (/tmp/tfil_ab2, 70 battles / 490 rounds / 54926 shots, plus a
35-battle powtest replication of a different binary).

- requiredLead from an AIM-INDEPENDENT interception solve (bullet speed vs
  enemy truth), appliedLead from the server-recorded bullet bearing.
- capture = applied/required, guarded at 2px lateral lead (1.6% excluded);
  headline metric is the robust proportional slope.
- validation: hits 11.6px mean miss / 80.8% inside 18px, misses 134px,
  11.6x separation; 496/496 death + 70/70 owner attributions correct.

Direct answer: capture falls with RANGE (capSlp 0.401 -> 0.135, and
|err|/tolerance 1.27 -> 7.54) but is FLAT across fired POWER within a band
(450+, enemy alive: 0.154 / 0.127 / 0.127). The sub-0.5 long-range shots
(1.18% hit) are finishKill endgame shots at a near-dead DrussGT, not a
lead-capture failure. A naive linear predictor captures 0.29-0.60; we reach
46-67% of that, so the under-lead is real but capture=1.0 is unattainable
against a dodger (oracle required lead).
2026-09-24 22:47:18 +02:00

584 lines
25 KiB
Python

#!/usr/bin/env python3
"""MEASURE LEAD CAPTURE BY RANGE: how much of the required lead do we apply?
Reads the recent LIVE recorded battles vs the real DrussGT
(``/tmp/tfil_ab2/out/<ARM>/run<N>.jsonl`` per-tick worldstate +
``.events.jsonl`` fire/hit sidecar + ``.jsonl.rounds.json`` tick index,
produced by ``tools/robocode_shim/run_bridge_battle.sh`` / ``tools/ab/ab_run.sh``)
and, for every shot WE fire, compares the lead we actually put into the bullet
against the lead the enemy's motion required -- split by RANGE band and then by
fired POWER inside each band.
WHICH SIDE IS US
----------------
``TrBattleCapture`` writes the movement subject (the bot whose name matches
``--subject``, here DrussGT) as ``e*`` and the adversary (ModularBot, our bot)
as ``s*``. So our worldstate track is ``sx,sy,se`` and DrussGT's is
``ex,ey,ee``. Owner ids in the event sidecar are NOT stable across runs, so
every run is attributed independently: a fire event's (x,y) is the firing
tank's centre and its energy drops by exactly the fired power one tick later.
Attribution is cross-checked against the server ``death`` events (496/496).
THE DECOMPOSITION (all angles in deg, all relative to the LOS at the FIRE tick)
------------------------------------------------------------------------------
O = the firing tank's centre at the fire tick (the bullet line origin)
v = 20 - 3p px/tick
t* = the AIM-INDEPENDENT interception tick: the first tick k with
|E(t0+k) - O| <= v*k. It depends only on the enemy's recorded
truth and the bullet speed, never on our aim.
required = bearing(O -> E(t*)) - LOS (the lead a perfect gun would need)
applied = bullet bearing (server-recorded, perfect truth) - LOS
leadErr = applied - required (wrapped)
capture = applied / required (guarded: only where |required| >= 2px at range)
miss_px = |E(t*) - (O + v*t* * u)| (bullet line vs enemy at t*)
CAPTURE < 1 IS NOT BY ITSELF A BUG
----------------------------------
``required`` uses the enemy's ACTUAL future dodge (perfect information), which
no real gun can know. So the script also computes a NAIVE LINEAR PREDICTOR
control -- the same bullet speed, aimed at the intercept of a straight-line
continuation of the enemy's last LIN_M ticks of velocity. That control is the
ceiling a trivial predictive gun reaches against this dodger; our capture is
read against it, not only against 1.0.
MEASURED = every number printed. INFERRED = the causal reading in the doc.
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import os
import statistics
US_SIDE = "s" # adversary = ModularBot = us
BOT_RADIUS = 18.0
SPEED_A, SPEED_B = 20.0, 3.0 # bullet speed = 20 - 3*p px/tick
MAX_FLIGHT = 220
MIN_REQ_PX = 2.0 # capture defined only above this lateral lead
LIN_M = 4 # ticks of velocity for the naive-predictor control
BANDS = [(0, 100), (100, 200), (200, 300), (300, 450), (450, 1e9)]
BAND_LABELS = ["0-100", "100-200", "200-300", "300-450", "450+"]
PWR_EDGES = [0.0, 0.5, 0.75, 1.0, 1.5, 100.0]
PWR_LABELS = ["<0.50", "0.50-0.75", "0.75-1.00", "1.00-1.50", ">=1.50"]
def wrap180(x):
"""Signed angular difference in (-180, 180]."""
x = (x + 180.0) % 360.0 - 180.0
return x + 360.0 if x <= -180.0 else x
def band_of(r):
for (lo, hi), lab in zip(BANDS, BAND_LABELS):
if lo <= r < hi:
return lab
return BAND_LABELS[-1]
def pbin(p):
for i in range(len(PWR_EDGES) - 1):
if PWR_EDGES[i] <= p < PWR_EDGES[i + 1]:
return i
return None
def mean(xs):
return statistics.fmean(xs) if xs else float("nan")
def median(xs):
return statistics.median(xs) if xs else float("nan")
def stdev(xs):
return statistics.pstdev(xs) if len(xs) > 1 else float("nan")
def _rows(path):
out = []
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
o = json.loads(line)
if "tick" in o:
out.append(o)
return out
class Run:
"""One live battle: capture rows, event sidecar, per-round tick index."""
def __init__(self, cap_path, events_path, rounds_path):
self.cap_path = cap_path
self.rows = _rows(cap_path)
self.by_tick = {r["tick"]: r for r in self.rows}
self.events = [json.loads(l) for l in open(events_path) if l.strip()]
self.rounds = json.load(open(rounds_path))["rounds"]
self.start = {r["round"]: r["startTick"] for r in self.rounds}
self.count = {r["round"]: r["count"] for r in self.rounds}
self.owner_side = self._resolve_owner_side()
self.resolution = {}
for ev in self.events:
if ev.get("type") in ("hit", "hitwall", "hitbullet"):
self.resolution[(ev["round"], ev["owner"], ev["bullet"])] = ev["type"]
# -- attribution ------------------------------------------------------
def _match(self, t, side, ev):
r, r1 = self.by_tick.get(t), self.by_tick.get(t + 1)
if r is None or r1 is None:
return False
if abs(r[side + "x"] - ev["x"]) > 0.02 or abs(r[side + "y"] - ev["y"]) > 0.02:
return False
ef = "ee" if side == "e" else "se"
return abs((r[ef] - r1[ef]) - ev["power"]) < 0.02
def _resolve_owner_side(self):
votes = collections.defaultdict(collections.Counter)
for ev in self.events:
if ev.get("type") != "fire":
continue
guess = self.start.get(ev["round"], 0) + ev["tick"]
for t in range(guess - 8, guess + 9):
for side in ("e", "s"):
if self._match(t, side, ev):
votes[ev["owner"]][side] += 1
return {o: c.most_common(1)[0][0] for o, c in votes.items() if c}
def align(self, ev):
guess = self.start.get(ev["round"], 0) + ev["tick"]
side = self.owner_side.get(ev["owner"])
best = None
for t in range(guess - 8, guess + 9):
for sd in (("e", "s") if side is None else (side,)):
if self._match(t, sd, ev):
d = abs(t - guess)
if best is None or d < best[0]:
best = (d, t, sd)
return None if best is None else best[1]
def n_position_matches(self, ev):
n = 0
guess = self.start.get(ev["round"], 0) + ev["tick"]
for t in range(guess - 8, guess + 9):
for sd in ("e", "s"):
r = self.by_tick.get(t)
if r is None:
continue
if abs(r[sd + "x"] - ev["x"]) <= 0.02 and abs(r[sd + "y"] - ev["y"]) <= 0.02:
n += 1
return n
def death_side_ok(self):
"""Every death must land on a side whose energy has collapsed to 0."""
out = []
for ev in self.events:
if ev.get("type") != "death":
continue
side = self.owner_side.get(ev["victim"])
g = self.start.get(ev["round"], 0) + ev["tick"]
ekey = "ee" if side == "e" else "se"
ok = False
for t in range(g - 8, g + 10):
r = self.by_tick.get(t)
if r is not None and r[ekey] <= 1e-6:
ok = True
break
out.append(ok)
return out
# -- per-shot extraction ---------------------------------------------
def shots(self):
for ev in self.events:
if ev.get("type") != "fire":
continue
if self.owner_side.get(ev["owner"]) != US_SIDE:
continue
t0 = self.align(ev)
if t0 is None:
continue
s = self._shot(ev, t0)
if s is not None:
yield s
def _shot(self, ev, t0):
r0 = self.by_tick[t0]
p = ev["power"]
v = SPEED_A - SPEED_B * p
th = math.radians(ev["dir"])
ux, uy = math.cos(th), math.sin(th)
x0, y0 = ev["x"], ev["y"] # tank centre == bullet line origin
rnd = ev["round"]
end = self.start[rnd] + self.count[rnd]
rng = math.hypot(r0["ex"] - x0, r0["ey"] - y0)
# aim-independent interception tick (oracle)
tstar = None
for k in range(1, MAX_FLIGHT + 1):
if t0 + k >= end:
break
r = self.by_tick.get(t0 + k)
if r is None:
break
if math.hypot(r["ex"] - x0, r["ey"] - y0) <= v * k:
tstar = (k, r)
break
if tstar is None:
return None
kstar, ea = tstar
# miss distance: perpendicular of the enemy at the tick our (actual) bullet
# reaches its along-track plane, relative to our bullet's real line.
# Same definition as common_libs/tests/analyze_drussgt_dodge_vs_power.py.
perp_arr = None
for k in range(1, MAX_FLIGHT + 1):
if t0 + k >= end:
break
r = self.by_tick.get(t0 + k)
if r is None:
break
dx, dy = r["ex"] - x0, r["ey"] - y0
al = dx * ux + dy * uy
if al > 0 and v * k >= al:
perp_arr = dx * uy - dy * ux
break
miss_px = abs(perp_arr) if perp_arr is not None else float("nan")
los = math.degrees(math.atan2(r0["ey"] - y0, r0["ex"] - x0))
required = wrap180(math.degrees(math.atan2(ea["ey"] - y0, ea["ex"] - x0)) - los)
applied = wrap180(ev["dir"] - los)
leaderr = wrap180(applied - required)
# naive linear-predictor control: continue the last LIN_M ticks of velocity
lin = None
rp = self.by_tick.get(t0 - LIN_M)
if rp is not None:
vx = (r0["ex"] - rp["ex"]) / LIN_M
vy = (r0["ey"] - rp["ey"]) / LIN_M
kk = rng / v
for _ in range(40):
kk = math.hypot(r0["ex"] + vx * kk - x0, r0["ey"] + vy * kk - y0) / v
lin = wrap180(math.degrees(math.atan2(r0["ey"] + vy * kk - y0,
r0["ex"] + vx * kk - x0)) - los)
req_px = abs(math.radians(required)) * rng
capture = (applied / required) if (req_px >= MIN_REQ_PX and abs(required) > 1e-9) else None
capture_lin = (lin / required) if (lin is not None and req_px >= MIN_REQ_PX
and abs(required) > 1e-9) else None
kind = self.resolution.get((rnd, ev["owner"], ev["bullet"]))
return dict(
run=self.cap_path, rnd=rnd, tick=t0, power=p, speed=v,
range=rng, band=band_of(rng), pbin=pbin(p), flight=kstar,
required=required, applied=applied, leaderr=leaderr, lin=lin,
capture=capture, capture_lin=capture_lin, req_px=req_px,
miss_px=miss_px,
tolerance_deg=math.degrees(math.atan(BOT_RADIUS / rng)),
hit=1.0 if kind == "hit" else 0.0,
resolution=kind, npos=self.n_position_matches(ev),
our_energy=r0["se"], enemy_energy=r0["ee"], rel_tick=t0 - self.start[rnd],
)
def discover_tfil(root):
runs = []
for sub in sorted(os.listdir(root)):
d = os.path.join(root, sub)
if not os.path.isdir(d):
continue
for fn in sorted(os.listdir(d)):
if not fn.endswith(".jsonl") or fn.endswith(".events.jsonl"):
continue
cap = os.path.join(d, fn)
ev, rj = cap[:-6] + ".events.jsonl", cap + ".rounds.json"
if os.path.exists(ev) and os.path.exists(rj):
runs.append(Run(cap, ev, rj))
return runs
def discover_powtest(root):
"""powtest layout: cap_<arm>_r<N>.jsonl + events_<arm>_r<N>.json + .rounds.json."""
runs = []
if not os.path.isdir(root):
return runs
for fn in sorted(os.listdir(root)):
if not (fn.startswith("cap_") and fn.endswith(".jsonl")):
continue
cap = os.path.join(root, fn)
stem = fn[4:].replace(".jsonl", ".json")
ev = os.path.join(root, "events_" + stem)
rj = cap + ".rounds.json"
if os.path.exists(ev) and os.path.exists(rj):
runs.append(Run(cap, ev, rj))
return runs
# --------------------------------------------------------------- statistics
def slope(xs, ys):
d = sum(x * x for x in xs)
return sum(x * y for x, y in zip(xs, ys)) / d if d > 0 else float("nan")
def cell(shots):
caps = [s["capture"] for s in shots if s["capture"] is not None]
lins = [s["capture_lin"] for s in shots if s["capture_lin"] is not None]
req = [s["required"] for s in shots]
resolved = [s for s in shots if s["resolution"] in ("hit", "hitwall", "hitbullet")]
return dict(
n=len(shots), nc=len(caps), excl=len(shots) - len(caps),
unresolved=len(shots) - len(resolved),
range=mean([s["range"] for s in shots]),
req=mean(req),
app=mean([s["applied"] for s in shots]),
cap=mean(caps), cap_med=median(caps), cap_slope=slope(req, [s["applied"] for s in shots]),
cap_lin=mean(lins), cap_lin_slope=slope(req, [s["lin"] for s in shots if s["lin"] is not None]),
ae=mean([abs(s["leaderr"]) for s in shots]),
ae_sd=stdev([abs(s["leaderr"]) for s in shots]),
ae_px=mean([abs(math.radians(s["leaderr"])) * s["range"] for s in shots]),
miss=mean([s["miss_px"] for s in shots]),
med_miss=median([s["miss_px"] for s in shots]),
tol=mean([s["tolerance_deg"] for s in shots]),
hit=mean([s["hit"] for s in resolved]),
enE=mean([s["enemy_energy"] for s in shots]),
flight=mean([s["flight"] for s in shots]),
)
def table(cells, title):
print(f"\n{title}")
hdr = (f"{'cell':<24} {'n':>6} {'excl':>5} {'range':>5} {'flt':>4} {'reqL':>7} {'appL':>7} "
f"{'capt':>6} {'capSlp':>7} {'capLin':>7} {'|err|':>6} {'+/-':>5} {'|err|px':>7} "
f"{'tol':>5} {'hit%':>6}")
print(hdr)
print("-" * len(hdr))
for name, sh in cells:
if not sh:
continue
c = cell(sh)
print(f"{name:<24} {c['n']:>6} {c['excl']:>5} {c['range']:>5.0f} {c['flight']:>4.1f} "
f"{c['req']:>7.2f} {c['app']:>7.2f} {c['cap']:>6.2f} {c['cap_slope']:>7.3f} "
f"{c['cap_lin_slope']:>7.3f} {c['ae']:>6.2f} {c['ae_sd']:>5.2f} {c['ae_px']:>7.1f} "
f"{c['tol']:>5.2f} {100*c['hit']:>5.2f}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tfil", default="/tmp/tfil_ab2/out")
ap.add_argument("--powtest", default="/tmp/powtest")
ap.add_argument("--json", default=None)
args = ap.parse_args()
runs = discover_tfil(args.tfil)
print("=" * 132)
print("LEAD CAPTURE BY RANGE -- live ModularBot vs real DrussGT")
print("=" * 132)
print(f"corpus : {args.tfil}")
print(f"battles (runs) : {len(runs)}")
if not runs:
raise SystemExit("no runs found")
amb = 0
death_ok = death_n = 0
pow_all = collections.defaultdict(collections.Counter)
sideowners = collections.Counter()
allshots = []
for rn in runs:
for o, sd in rn.owner_side.items():
sideowners[(sd, o)] += 1
amb += sum(1 for ev in rn.events if ev.get("type") == "fire"
and rn.n_position_matches(ev) > 1)
for ev in rn.events:
if ev.get("type") == "fire":
pow_all[rn.owner_side.get(ev["owner"], "?")][round(ev["power"], 2)] += 1
ds = rn.death_side_ok()
death_n += len(ds)
death_ok += sum(ds)
allshots.extend(rn.shots())
sides = collections.Counter(tuple(sorted(rn.owner_side.values())) for rn in runs)
print(f"per-run owner maps: {dict(sides)} (expect all ('e','s'))")
print(f"runs with a broken owner map : {sum(1 for rn in runs if sorted(rn.owner_side.values()) != ['e','s'])}")
print(f"fire events w/ ambiguous position : {amb}")
print(f"death events side-validated : {death_ok}/{death_n}")
for sd in ("s", "e"):
tot = sum(pow_all[sd].values())
top = pow_all[sd].most_common(6)
print(f" side {sd} = {'US (ModularBot)' if sd == US_SIDE else 'DrussGT'} "
f"n={tot:>6} top powers: {top}")
hits = [s for s in allshots if s["hit"] == 1.0]
misses = [s for s in allshots if s["resolution"] in ("hitwall", "hitbullet")
and s["hit"] == 0.0]
unresolved = [s for s in allshots if s["resolution"] is None]
print("\n" + "=" * 132)
print("SAMPLE")
print("=" * 132)
print(f"our shots (aligned, with an interception) : {len(allshots)}")
print(f" server hit / hitwall / hitbullet / other : "
f"{sum(1 for s in allshots if s['resolution']=='hit')} / "
f"{sum(1 for s in allshots if s['resolution']=='hitwall')} / "
f"{sum(1 for s in allshots if s['resolution']=='hitbullet')} / "
f"{len(unresolved)}")
print(f"capture excluded (|required| < {MIN_REQ_PX} px lateral) : "
f"{sum(1 for s in allshots if s['capture'] is None)}")
print("\n" + "=" * 132)
print("GEOMETRY VALIDATION (hits must separate from misses)")
print("=" * 132)
hdr = f"{'':<16}{'n':>7} {'|leadErr|deg':>13} {'|leadErr|px':>12} {'misPx mean':>11} {'misPx med':>10} {'<18px':>7}"
print(hdr)
for lab, grp in (("HITS", hits), ("MISSES", misses)):
if not grp:
continue
ae = [abs(s["leaderr"]) for s in grp]
apx = [abs(math.radians(s["leaderr"])) * s["range"] for s in grp]
mp = [s["miss_px"] for s in grp]
print(f"{lab:<16}{len(grp):>7} {mean(ae):>13.3f} {mean(apx):>12.1f} "
f"{mean(mp):>11.1f} {median(mp):>10.1f} {100*sum(1 for x in mp if x<18)/len(mp):>6.1f}%")
if hits and misses:
sep = mean([s["miss_px"] for s in misses]) / max(1e-9, mean([s["miss_px"] for s in hits]))
print(f"\nmiss separation ratio (misses/hits) = {sep:.2f}x "
f"-> {'OK (bearing recovery validated)' if sep > 2 else 'WEAK - bearing recovery suspect'}")
print(f"overall measured hit rate = {100*mean([s['hit'] for s in allshots]):.2f}%")
cells = [(lab, [s for s in allshots if s["band"] == lab]) for lab in BAND_LABELS]
print("\n" + "=" * 132)
print("MAIN TABLE -- all our shots, by RANGE band")
print("=" * 132)
print("reqL/appL = mean signed lead vs LOS (deg). capt = mean ratio, capSlp = robust proportional")
print("capture, capLin = the same slope for a naive linear predictor (control).")
table(cells, "by range band")
print("\n" + "=" * 132)
print("POWER WITHIN RANGE BAND -- this is what separates 'range' from 'power'")
print("=" * 132)
for lab in BAND_LABELS:
sub = [s for s in allshots if s["band"] == lab]
if not sub:
continue
pcs = [(f"{lab} {PWR_LABELS[i]}", [s for s in sub if s["pbin"] == i])
for i in range(len(PWR_LABELS))]
table(pcs, f"range {lab}")
# ---- endgame confound and its removal --------------------------------
print("\n" + "=" * 132)
print("CONFOUND CHECK -- the sub-0.5-power shots at long range are the ENDGAME finish")
print("=" * 132)
print("hit% of our 450+ shots, by the ENEMY's remaining energy at the fire tick:")
sub = [s for s in allshots if s["band"] == "450+"]
for lo, hi in ((0, 2), (2, 5), (5, 10), (10, 20), (20, 40), (40, 150)):
g = [s for s in sub if lo <= s["enemy_energy"] < hi]
if g:
c = cell(g)
print(f" enemy energy [{lo:>3},{hi:>3}) n={c['n']:>6} meanP={mean([x['power'] for x in g]):>5.2f} "
f"capSlp={c['cap_slope']:>6.3f} hit%={100*c['hit']:>5.2f}")
print("\nsame table, EXCLUDING shots fired while the enemy still has >= 5 energy")
print("(this removes the near-dead-target endgame and leaves the clean power comparison):")
for lab in BAND_LABELS:
sub = [s for s in allshots if s["band"] == lab and s["enemy_energy"] >= 5.0]
if not sub:
continue
pcs = [(f"{lab} {PWR_LABELS[i]}", [s for s in sub if s["pbin"] == i])
for i in range(len(PWR_LABELS))]
table(pcs, f"range {lab} (enemy energy >= 5)")
# ---- response curve ---------------------------------------------------
print("\n" + "=" * 132)
print("RESPONSE CURVE -- do we move the aim in proportion to the required lead?")
print("=" * 132)
for lab in ("200-300", "300-450", "450+"):
g = sorted([s for s in allshots if s["band"] == lab], key=lambda s: s["required"])
if len(g) < 100:
continue
nb = 8
print(f"\nrange {lab} (deciles of required lead -> mean applied lead):")
for i in range(nb):
q = g[i * len(g) // nb:(i + 1) * len(g) // nb]
c = cell(q)
print(f" required [{q[0]['required']:>7.2f},{q[-1]['required']:>7.2f}] "
f"meanReq={c['req']:>7.2f} meanApp={c['app']:>7.2f} "
f"hit%={100*c['hit']:>5.2f} n={c['n']:>5}")
print("\n" + "=" * 132)
print("DIRECT ANSWER -- does capture degrade with range / power?")
print("=" * 132)
print(f"{'range band':<12} {'n':>6} {'capSlp':>7} {'capLin':>7} {'us/lin':>7} "
f"{'medcap':>7} {'|req|':>6} {'|app|':>6} {'|err|px':>8} {'tol':>5} {'|err|/tol':>9} {'hit%':>6}")
for lab, sh in cells:
if not sh:
continue
c = cell(sh)
ratio = c["ae_px"] / (c["tol"] * math.pi / 180.0 * c["range"])
areq = mean([abs(s["required"]) for s in sh])
aapp = mean([abs(s["applied"]) for s in sh])
print(f"{lab:<12} {c['n']:>6} {c['cap_slope']:>7.3f} {c['cap_lin_slope']:>7.3f} "
f"{c['cap_slope']/c['cap_lin_slope'] if c['cap_lin_slope'] else float('nan'):>7.2f} "
f"{c['cap_med']:>7.3f} {areq:>6.2f} {aapp:>6.2f} {c['ae_px']:>8.1f} {c['tol']:>5.2f} "
f"{ratio:>9.2f} {100*c['hit']:>5.2f}")
print("\n(capSlp = our proportional lead capture; capLin = naive linear predictor control;")
print(" us/lin = our capture / the trivial-predictor ceiling; |req|/|app| = mean ABSOLUTE")
print(" lead magnitude in deg; |err|/tol = mean lead error px / 18px)")
print("\nPOWER WITHIN 450+ BAND, enemy energy >= 5 (removes the endgame confound):")
sub = [s for s in allshots if s["band"] == "450+" and s["enemy_energy"] >= 5.0]
for i in range(len(PWR_LABELS)):
g = [s for s in sub if s["pbin"] == i]
if not g:
continue
c = cell(g)
print(f" power {PWR_LABELS[i]:<11} n={c['n']:>6} meanP={mean([s['power'] for s in g]):>5.2f} "
f"meanRange={c['range']:>4.0f} capSlp={c['cap_slope']:>6.3f} "
f"capLin={c['cap_lin_slope']:>6.3f} |err|px={c['ae_px']:>6.1f} hit%={100*c['hit']:>5.2f}")
if args.powtest:
pruns = discover_powtest(args.powtest)
if pruns:
pshots = [s for rn in pruns for s in rn.shots()]
print("\n" + "=" * 132)
print(f"ROBUSTNESS CROSS-CHECK -- powtest corpus ({len(pruns)} runs, "
f"{len(pshots)} shots, DIFFERENT binary)")
print("=" * 132)
pc = [(lab, [s for s in pshots if s["band"] == lab]) for lab in BAND_LABELS]
table(pc, "by range band (powtest)")
if args.json:
blob = dict(
corpus=args.tfil, runs=len(runs), shots=len(allshots),
hits=len(hits), misses=len(misses),
excl=sum(1 for s in allshots if s["capture"] is None),
death_ok=death_ok, death_n=death_n, amb=amb,
overall_hit=mean([s["hit"] for s in allshots]),
hit_ae=mean([abs(s["leaderr"]) for s in hits]),
miss_ae=mean([abs(s["leaderr"]) for s in misses]),
hit_miss_px=mean([s["miss_px"] for s in hits]),
miss_miss_px=mean([s["miss_px"] for s in misses]),
pwr_hist={sd: dict(pow_all[sd]) for sd in pow_all},
bands={lab: cell([s for s in allshots if s["band"] == lab]) for lab in BAND_LABELS},
bands_enemy_alive={lab: cell([s for s in allshots if s["band"] == lab
and s["enemy_energy"] >= 5.0])
for lab in BAND_LABELS},
band_power_alive={lab: {PWR_LABELS[i]: cell([s for s in allshots
if s["band"] == lab
and s["pbin"] == i
and s["enemy_energy"] >= 5.0])
for i in range(len(PWR_LABELS))}
for lab in BAND_LABELS},
hit_by_enemy_energy={f"{lo}-{hi}": cell([s for s in allshots
if s["band"] == "450+"
and lo <= s["enemy_energy"] < hi])
for lo, hi in ((0, 2), (2, 5), (5, 10), (10, 20), (20, 40), (40, 150))},
band_power={lab: {PWR_LABELS[i]: cell([s for s in allshots
if s["band"] == lab and s["pbin"] == i])
for i in range(len(PWR_LABELS))} for lab in BAND_LABELS},
)
with open(args.json, "w") as f:
json.dump(blob, f, indent=2)
print(f"\n[json] wrote {args.json}")
if __name__ == "__main__":
main()