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).
This commit is contained in:
2026-09-24 22:47:18 +02:00
parent 795a0e59fe
commit f91e121965
4 changed files with 2795 additions and 0 deletions
@@ -0,0 +1,583 @@
#!/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()
@@ -0,0 +1,213 @@
====================================================================================================================================
LEAD CAPTURE BY RANGE -- live ModularBot vs real DrussGT
====================================================================================================================================
corpus : /tmp/tfil_ab2/out
battles (runs) : 70
per-run owner maps: {('e', 's'): 70} (expect all ('e','s'))
runs with a broken owner map : 0
fire events w/ ambiguous position : 12112
death events side-validated : 496/496
side s = US (ModularBot) n= 56242 top powers: [(1.0, 32044), (0.5, 14556), (0.1, 807), (0.51, 225), (0.55, 208), (0.53, 199)]
side e = DrussGT n= 67065 top powers: [(0.15, 27209), (0.95, 22789), (0.45, 5274), (0.25, 3071), (0.65, 2862), (0.35, 2340)]
====================================================================================================================================
SAMPLE
====================================================================================================================================
our shots (aligned, with an interception) : 54926
server hit / hitwall / hitbullet / other : 5480 / 47101 / 1203 / 1142
capture excluded (|required| < 2.0 px lateral) : 887
====================================================================================================================================
GEOMETRY VALIDATION (hits must separate from misses)
====================================================================================================================================
n |leadErr|deg |leadErr|px misPx mean misPx med <18px
HITS 5480 1.478 11.4 11.6 10.8 80.8%
MISSES 48304 16.724 141.3 134.1 123.7 0.8%
miss separation ratio (misses/hits) = 11.59x -> OK (bearing recovery validated)
overall measured hit rate = 9.98%
====================================================================================================================================
MAIN TABLE -- all our shots, by RANGE band
====================================================================================================================================
reqL/appL = mean signed lead vs LOS (deg). capt = mean ratio, capSlp = robust proportional
capture, capLin = the same slope for a naive linear predictor (control).
by range band
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 27 1 81 10.3 1.51 -0.23 0.72 0.401 0.597 16.76 11.13 23.2 13.01 44.00
100-200 274 14 162 14.7 -0.95 2.23 0.07 0.391 0.600 17.74 13.38 50.1 6.57 25.28
200-300 990 30 261 17.9 -1.67 0.37 0.04 0.246 0.428 16.65 11.97 75.8 3.99 16.04
300-450 17178 259 405 24.5 -0.56 0.40 0.01 0.198 0.404 15.98 11.27 112.9 2.56 11.79
450+ 36457 583 535 31.0 0.19 -0.44 0.15 0.135 0.291 14.71 10.36 137.0 1.95 9.14
====================================================================================================================================
POWER WITHIN RANGE BAND -- this is what separates 'range' from 'power'
====================================================================================================================================
range 0-100
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 0.50-0.75 6 1 83 6.5 -4.82 -1.09 0.41 0.448 0.669 12.35 3.75 17.7 12.61 66.67
0-100 1.00-1.50 2 0 77 7.5 -28.19 -7.90 0.27 0.292 0.730 20.29 10.29 27.8 13.17 50.00
0-100 >=1.50 19 0 80 11.8 6.63 0.85 0.84 0.405 0.574 17.78 12.32 24.4 13.12 35.29
range 100-200
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
100-200 <0.50 5 0 161 10.4 4.77 -1.52 -0.08 0.436 0.495 14.84 8.18 41.5 6.48 20.00
100-200 0.50-0.75 53 12 166 10.8 1.78 5.83 0.04 0.481 0.567 14.31 8.72 41.2 6.38 30.77
100-200 0.75-1.00 5 0 177 11.2 0.60 -4.71 -1.53 0.527 0.394 11.72 7.59 35.6 5.89 40.00
100-200 1.00-1.50 23 0 168 12.5 -0.21 6.11 -0.04 0.442 0.616 14.50 11.98 46.5 6.47 42.86
100-200 >=1.50 188 2 159 16.3 -2.00 1.02 0.13 0.375 0.606 19.34 14.50 53.7 6.65 21.43
range 200-300
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
200-300 <0.50 6 0 257 15.7 -0.40 -3.70 0.06 0.512 0.839 10.39 8.54 50.9 4.07 50.00
200-300 0.50-0.75 130 12 267 16.1 -1.54 1.25 -0.40 0.202 0.415 15.92 10.93 74.3 3.90 18.55
200-300 0.75-1.00 21 4 263 17.1 -3.60 3.14 0.34 0.135 -0.015 17.32 10.06 79.1 3.96 11.11
200-300 1.00-1.50 780 13 261 17.8 -1.29 0.29 0.08 0.287 0.462 16.04 11.33 73.0 3.99 15.92
200-300 >=1.50 53 1 248 25.3 -6.97 -1.28 0.29 0.063 0.291 27.83 17.66 122.1 4.20 8.70
range 300-450
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
300-450 <0.50 333 63 409 22.0 -0.93 0.92 -0.67 0.094 0.493 16.30 9.09 116.4 2.54 3.07
300-450 0.50-0.75 5268 82 411 23.4 0.12 0.75 0.25 0.216 0.431 15.16 10.68 108.9 2.52 11.65
300-450 0.75-1.00 880 12 408 24.7 -0.72 -0.36 -0.19 0.203 0.458 16.00 11.28 113.6 2.55 13.14
300-450 1.00-1.50 10696 102 402 25.0 -0.86 0.27 -0.07 0.191 0.386 16.38 11.59 114.8 2.59 12.02
300-450 >=1.50 1 0 312 22.0 -7.39 22.61 -3.06 -3.062 -3.217 30.00 nan 163.1 3.31 0.00
range 450+
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
450+ <0.50 950 120 537 28.2 0.80 0.67 0.02 0.106 0.302 14.61 8.72 136.8 1.94 1.18
450+ 0.50-0.75 12957 205 536 29.6 0.32 -0.12 0.13 0.151 0.314 14.04 9.96 131.1 1.94 10.20
450+ 0.75-1.00 2424 28 540 31.6 0.52 -0.41 0.01 0.132 0.284 14.92 10.42 140.6 1.93 9.50
450+ 1.00-1.50 20126 230 534 32.0 0.05 -0.71 0.18 0.126 0.279 15.11 10.66 140.4 1.95 8.80
====================================================================================================================================
CONFOUND CHECK -- the sub-0.5-power shots at long range are the ENDGAME finish
====================================================================================================================================
hit% of our 450+ shots, by the ENEMY's remaining energy at the fire tick:
enemy energy [ 0, 2) n= 1017 meanP= 0.22 capSlp= 0.111 hit%= 1.10
enemy energy [ 2, 5) n= 1076 meanP= 0.54 capSlp= 0.128 hit%= 8.55
enemy energy [ 5, 10) n= 2614 meanP= 0.59 capSlp= 0.135 hit%= 9.69
enemy energy [ 10, 20) n= 6758 meanP= 0.63 capSlp= 0.146 hit%= 9.85
enemy energy [ 20, 40) n= 10733 meanP= 0.83 capSlp= 0.147 hit%= 9.43
enemy energy [ 40,150) n= 14259 meanP= 0.97 capSlp= 0.122 hit%= 9.12
same table, EXCLUDING shots fired while the enemy still has >= 5 energy
(this removes the near-dead-target endgame and leaves the clean power comparison):
range 0-100 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 0.50-0.75 5 0 80 6.6 -5.78 -4.34 0.41 0.448 0.669 11.80 3.88 16.2 13.00 80.00
0-100 1.00-1.50 2 0 77 7.5 -28.19 -7.90 0.27 0.292 0.730 20.29 10.29 27.8 13.17 50.00
0-100 >=1.50 16 0 81 11.3 5.37 0.41 0.98 0.503 0.605 14.99 11.43 20.8 13.05 40.00
range 100-200 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
100-200 0.50-0.75 42 2 168 11.2 2.51 2.50 0.03 0.481 0.573 12.93 8.67 37.8 6.33 35.71
100-200 0.75-1.00 5 0 177 11.2 0.60 -4.71 -1.53 0.527 0.394 11.72 7.59 35.6 5.89 40.00
100-200 1.00-1.50 23 0 168 12.5 -0.21 6.11 -0.04 0.442 0.616 14.50 11.98 46.5 6.47 42.86
100-200 >=1.50 175 2 161 16.3 -2.60 1.10 0.05 0.384 0.590 19.19 14.48 54.1 6.57 22.35
range 200-300 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
200-300 <0.50 6 0 257 15.7 -0.40 -3.70 0.06 0.512 0.839 10.39 8.54 50.9 4.07 50.00
200-300 0.50-0.75 119 2 267 16.2 -1.88 -0.63 -0.41 0.192 0.412 15.49 11.23 72.5 3.89 20.35
200-300 0.75-1.00 17 0 265 17.5 -4.44 -0.78 0.34 0.135 -0.015 16.75 10.98 77.4 3.93 14.29
200-300 1.00-1.50 776 9 261 17.8 -1.30 0.17 0.08 0.287 0.462 16.00 11.34 72.8 3.98 16.01
200-300 >=1.50 40 0 249 25.5 -3.41 -0.27 0.03 0.061 0.290 26.76 18.77 118.8 4.19 11.11
range 300-450 (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
300-450 <0.50 4 0 381 21.5 2.94 -0.28 -1.41 1.202 1.280 17.26 10.28 109.0 2.76 25.00
300-450 0.50-0.75 4825 54 412 23.5 0.14 0.58 0.24 0.220 0.430 15.08 10.64 108.4 2.52 11.82
300-450 0.75-1.00 860 9 408 24.8 -0.72 -0.53 -0.19 0.205 0.453 15.94 11.27 113.3 2.54 13.32
300-450 1.00-1.50 10681 99 402 25.0 -0.87 0.26 -0.07 0.191 0.385 16.38 11.60 114.8 2.59 12.03
300-450 >=1.50 1 0 312 22.0 -7.39 22.61 -3.06 -3.062 -3.217 30.00 nan 163.1 3.31 0.00
range 450+ (enemy energy >= 5)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
450+ <0.50 4 0 519 26.8 -1.51 -11.72 -5.43 -3.021 -1.809 26.98 6.38 247.2 2.00 0.00
450+ 0.50-0.75 11882 137 535 29.5 0.31 -0.21 0.16 0.154 0.312 13.97 9.96 130.3 1.95 10.39
450+ 0.75-1.00 2377 28 540 31.6 0.47 -0.45 0.01 0.127 0.275 14.94 10.41 140.7 1.93 9.60
450+ 1.00-1.50 20101 230 534 32.0 0.04 -0.71 0.18 0.127 0.279 15.11 10.66 140.4 1.95 8.80
====================================================================================================================================
RESPONSE CURVE -- do we move the aim in proportion to the required lead?
====================================================================================================================================
range 200-300 (deciles of required lead -> mean applied lead):
required [ -46.11, -23.72] meanReq= -27.79 meanApp= -9.18 hit%=19.67 n= 123
required [ -23.71, -16.96] meanReq= -19.97 meanApp= -1.30 hit%=14.53 n= 124
required [ -16.83, -11.27] meanReq= -13.99 meanApp= -0.85 hit%=14.29 n= 124
required [ -11.23, -2.84] meanReq= -7.43 meanApp= 0.34 hit%=14.41 n= 124
required [ -2.76, 5.10] meanReq= 1.01 meanApp= 1.67 hit%= 9.24 n= 123
required [ 5.11, 13.22] meanReq= 9.12 meanApp= -3.36 hit%=14.88 n= 124
required [ 13.23, 23.92] meanReq= 18.32 meanApp= 3.04 hit%=11.76 n= 124
required [ 23.94, 45.18] meanReq= 27.20 meanApp= 12.55 hit%=29.41 n= 124
range 300-450 (deciles of required lead -> mean applied lead):
required [ -28.84, -19.87] meanReq= -24.21 meanApp= -6.04 hit%=13.39 n= 2147
required [ -19.87, -13.44] meanReq= -16.43 meanApp= -0.20 hit%= 9.09 n= 2147
required [ -13.44, -7.81] meanReq= -10.63 meanApp= -0.19 hit%=10.22 n= 2147
required [ -7.80, -1.44] meanReq= -4.66 meanApp= -0.12 hit%=11.82 n= 2148
required [ -1.44, 5.26] meanReq= 1.83 meanApp= 0.34 hit%= 9.96 n= 2147
required [ 5.26, 12.33] meanReq= 8.84 meanApp= 0.39 hit%= 9.00 n= 2147
required [ 12.33, 20.39] meanReq= 16.05 meanApp= 0.44 hit%= 9.60 n= 2147
required [ 20.39, 28.98] meanReq= 24.76 meanApp= 8.54 hit%=21.20 n= 2148
range 450+ (deciles of required lead -> mean applied lead):
required [ -28.60, -17.03] meanReq= -21.60 meanApp= -4.09 hit%= 9.27 n= 4557
required [ -17.03, -10.56] meanReq= -13.70 meanApp= -1.51 hit%= 7.92 n= 4557
required [ -10.56, -5.13] meanReq= -7.79 meanApp= -1.00 hit%=10.29 n= 4557
required [ -5.13, 0.00] meanReq= -2.51 meanApp= -0.55 hit%=10.08 n= 4557
required [ 0.00, 5.27] meanReq= 2.50 meanApp= 0.36 hit%= 9.95 n= 4557
required [ 5.27, 11.06] meanReq= 8.12 meanApp= -0.16 hit%= 8.75 n= 4557
required [ 11.06, 17.66] meanReq= 14.26 meanApp= 0.14 hit%= 7.22 n= 4557
required [ 17.66, 28.54] meanReq= 22.26 meanApp= 3.26 hit%= 9.69 n= 4558
====================================================================================================================================
DIRECT ANSWER -- does capture degrade with range / power?
====================================================================================================================================
range band n capSlp capLin us/lin medcap |req| |app| |err|px tol |err|/tol hit%
0-100 27 0.401 0.597 0.67 0.450 24.77 13.38 23.2 13.01 1.27 44.00
100-200 274 0.391 0.600 0.65 0.442 20.21 16.95 50.1 6.57 2.70 25.28
200-300 990 0.246 0.428 0.58 0.238 15.71 13.84 75.8 3.99 4.17 16.04
300-450 17178 0.198 0.404 0.49 0.174 13.46 13.09 112.9 2.56 6.23 11.79
450+ 36457 0.135 0.291 0.46 0.120 11.59 11.43 137.0 1.95 7.54 9.14
(capSlp = our proportional lead capture; capLin = naive linear predictor control;
us/lin = our capture / the trivial-predictor ceiling; |req|/|app| = mean ABSOLUTE
lead magnitude in deg; |err|/tol = mean lead error px / 18px)
POWER WITHIN 450+ BAND, enemy energy >= 5 (removes the endgame confound):
power <0.50 n= 4 meanP= 0.10 meanRange= 519 capSlp=-3.021 capLin=-1.809 |err|px= 247.2 hit%= 0.00
power 0.50-0.75 n= 11882 meanP= 0.53 meanRange= 535 capSlp= 0.154 capLin= 0.312 |err|px= 130.3 hit%=10.39
power 0.75-1.00 n= 2377 meanP= 0.87 meanRange= 540 capSlp= 0.127 capLin= 0.275 |err|px= 140.7 hit%= 9.60
power 1.00-1.50 n= 20101 meanP= 1.00 meanRange= 534 capSlp= 0.127 capLin= 0.279 |err|px= 140.4 hit%= 8.80
====================================================================================================================================
ROBUSTNESS CROSS-CHECK -- powtest corpus (35 runs, 24277 shots, DIFFERENT binary)
====================================================================================================================================
by range band (powtest)
cell n excl range flt reqL appL capt capSlp capLin |err| +/- |err|px tol hit%
-------------------------------------------------------------------------------------------------------------------------
0-100 6 0 91 11.2 -6.18 -0.00 0.60 0.529 0.496 14.10 10.10 20.8 11.38 60.00
100-200 83 0 170 15.8 -3.28 -2.27 0.89 0.354 0.470 19.49 14.92 58.1 6.16 23.46
200-300 442 3 260 17.6 -2.70 0.80 -0.02 0.283 0.380 16.47 11.92 74.2 4.01 16.55
300-450 9843 102 405 24.9 -1.13 0.09 0.05 0.219 0.417 16.12 11.52 114.1 2.56 11.90
450+ 13903 143 525 30.9 0.12 -0.88 0.18 0.136 0.301 15.14 10.80 138.6 1.98 9.24
[json] wrote common_libs/tests/fixtures/lead_capture_by_range_results.json
File diff suppressed because it is too large Load Diff