re(flight): polling faster than the guest updates manufactures a clean curve

Fitting rate against instantaneous speed produced a tidy "rate rises with speed"
relationship, with speeds up to 4795 when the craft's maximum is 1200. It is
entirely an artefact: 20 Hz polling is faster than the guest updates these fields,
so a per-read delta is either exactly zero (no update yet) or a whole frame's
worth divided by a fraction of a frame. 111 of 352 reads were zero on BOTH
channels -- position and attitude update on the same frame, so the two are
perfectly correlated, and dividing each by the short wall dt produced the
correlation out of nothing.

Fix: aggregate over windows spanning many frames (0.5 s). A sum of |delta| over
such a window is right however the updates fall inside it.

This does NOT affect the swept-total probes (roll_axis.py, rate_probe.py) -- they
already summed over the whole dwell, immune for the same reason. Only per-sample
instantaneous rates were ever wrong, so no earlier number moves.

The windowed re-run is NOT yet claimed as a result. It gives plausible magnitudes
but still shows rate rising with speed, against the definition's PitchPlus_Min 150
> _Max 70, and it has two disqualifiers: it ran on an instance where the craft was
already tumbling from the previous sweep, so pinning reported "WEAK -- craft may
not be level", and the sweep started mid-range rather than at maximum. A clean
answer needs a fresh flight with pinning CONFIDENT and nothing before it. Since
what is in doubt is precisely what _Min/_Max mean, a measurement through a
doubtful instrument cannot settle it.

Both datasets kept, the bad one labelled, because the aliased curve is a good
example of what a manufactured correlation looks like.
This commit is contained in:
2026-08-13 23:00:59 +00:00
parent 6c7025851e
commit 0a84c1358e
4 changed files with 550 additions and 0 deletions

117
tools/re-capture/rate_curve.py Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Angular rate as a FUNCTION of speed — fitted, not sampled at two endpoints.
The two-point probe (`rate_probe.py`) assumed the craft held the speed its throttle
selects. It does not: full pitch bleeds 1 193 -> 589 in eight seconds *with the
throttle still at maximum*, so an 8-second average is taken over a moving speed and
lands between two different caps. That is how a measured pitch rate came out 33 %
ABOVE its own cap.
Turn the bug into the instrument. Because the speed bleeds on its own, one long hold
sweeps the whole range, so sampling position *and* attitude together gives
`(speed, rate)` pairs across it — the entire curve from a single phase, with no
assumption about what speed the craft is at.
Speed comes from the position deltas (no HUD OCR needed). Both speed and rate are
per wall-second here; the run's clock ratio converts both to game units afterwards
and cancels out of the SHAPE of the curve.
⚠️ SAMPLE IN WINDOWS, NOT PER READ. Polling at 20 Hz is faster than the guest
updates these fields, so a per-read delta is either exactly zero (no update yet) or
a whole frame's worth divided by a fraction of a frame. In a first run 111 of 352
reads were zero on BOTH channels — position and attitude update on the same frame,
so the two are perfectly correlated, and dividing each by the short wall dt
manufactured a clean "rate rises with speed" curve out of pure aliasing. Summing
|delta| over a window that spans many frames is immune: the total is right however
the updates fall inside it. (This is also why the swept-total probes were never
affected — only per-sample instantaneous rates were.)
Usage: rate_curve.py <roll|pitch> <out.csv> [dwell_s]
"""
import json
import math
import os
import struct
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import speed_law # noqa: E402
from axis_probe import alive, pad_state, pin_rows, rows_at, dot # noqa: E402
DRIVER = {"roll": {"lx": 32767}, "pitch": {"ly": 32767}}
def shot(n):
subprocess.run(["screenshot", f"/sylph-home/re/shots/{n}.png"], capture_output=True)
def pos_at(fd, off):
return struct.unpack(">3f", os.pread(fd, 12, off))
def main():
axis, out_csv = sys.argv[1], sys.argv[2]
dwell = float(sys.argv[3]) if len(sys.argv) > 3 else 16.0
cfg = json.load(open("/tmp/nav-live.json"))
w, off, nm = speed_law.find_player()
if not w:
sys.exit("player entity not found")
f_i = cfg["fwd_row"]
if not alive(w, off, cfg):
sys.exit("craft is not moving")
u_i, w_i = pin_rows(w, off, cfg)
print(f"# locked on {nm}, sweeping {axis}")
# Start at maximum speed and let the turn bleed it: one hold, whole range.
pad_state(rt=255)
time.sleep(6.0)
shot(f"curve_{axis}_a")
prev_r, prev_p, prev_t = rows_at(w.fd, off, cfg), pos_at(w.fd, off), time.time()
pad_state(rt=255, **DRIVER[axis])
t0 = time.time()
rows = []
WIN = 0.5 # seconds per emitted point: many guest frames
win_t0, win_path, win_swept = time.time(), 0.0, 0.0
while time.time() - t0 < dwell:
time.sleep(0.02)
now = time.time()
cur_r, cur_p = rows_at(w.fd, off, cfg), pos_at(w.fd, off)
win_path += math.dist(cur_p, prev_p)
if axis == "roll":
d = math.atan2(dot(cur_r[u_i], prev_r[w_i]), dot(cur_r[u_i], prev_r[u_i]))
else:
d = math.atan2(dot(cur_r[f_i], prev_r[u_i]), dot(cur_r[f_i], prev_r[f_i]))
win_swept += abs(math.degrees(d))
prev_r, prev_p = cur_r, cur_p
if now - win_t0 >= WIN:
span = now - win_t0
rows.append((round(now - t0, 2), round(win_path / span, 1),
round(win_swept / span, 2)))
win_t0, win_path, win_swept = now, 0.0, 0.0
pad_state()
shot(f"curve_{axis}_b")
with open(out_csv, "w") as f:
f.write("t,speed_units_per_wall_s,rate_deg_per_wall_s\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
# Bin by speed so the shape is visible without any curve-fitting assumption.
print(f"# {len(rows)} windows of {WIN}s; rate binned by speed (per WALL second):")
lo = min(r[1] for r in rows)
hi = max(r[1] for r in rows)
print(f"# speed swept {lo:.0f} -> {hi:.0f} units/wall-s")
nb = 6
for b in range(nb):
a = lo + (hi - lo) * b / nb
z = lo + (hi - lo) * (b + 1) / nb
sel = [r[2] for r in rows if a <= r[1] < z]
if sel:
print(f"# speed {a:7.0f}-{z:7.0f} n={len(sel):3d} rate {sum(sel)/len(sel):6.1f}")
print(f"# wrote {out_csv}; read HUD TIME off curve_{axis}_[ab] for the clock")
if __name__ == "__main__":
raise SystemExit(main())