#!/usr/bin/env python3 """Angular rate at a KNOWN, SETTLED speed — short bursts instead of one sweep. Why not a sweep: turning bleeds speed hard (1 193 -> 589 in 8 s at full throttle), so a long hold averages a rate over a moving speed and lands between two different caps. A sweep *driven* by that bleed also cannot dwell at either extreme, which is exactly where a speed-dependent law is most testable. So: settle the throttle, measure the settled speed, then pitch for only ~1 second. The speed barely moves inside a burst, so each burst is one honest `(speed, rate)` point. Three throttle settings give three clean points at known speeds. Row pinning is done ONCE while the craft is still level — the pin decides which matrix ROW is up and which is right, which is a property of the layout, not of the current attitude, so it stays valid after the craft has been thrown around. Accumulation is windowed over the whole burst (never per-read): polling is faster than the guest updates these fields, so a per-read delta is either zero or a whole frame divided by a fraction of one — that aliasing once manufactured an entire rate-vs-speed curve. Usage: burst_probe.py [burst_s] [repeats] """ import json import math import os import struct 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}} THROTTLES = [("min", {"lt": 255}), ("cruise", {}), ("max", {"rt": 255})] def pos_at(fd, off): return struct.unpack(">3f", os.pread(fd, 12, off)) def measure(w, off, cfg, axis, u_i, w_i, f_i, hold, secs): """Accumulate path length and swept angle over one interval.""" prev_r, prev_p = rows_at(w.fd, off, cfg), pos_at(w.fd, off) t0 = time.time() path = swept = 0.0 while time.time() - t0 < secs: time.sleep(0.02) cur_r, cur_p = rows_at(w.fd, off, cfg), pos_at(w.fd, off) 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])) swept += abs(math.degrees(d)) prev_r, prev_p = cur_r, cur_p span = time.time() - t0 return path / span, swept / span def main(): axis, out_csv = sys.argv[1], sys.argv[2] burst = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0 reps = int(sys.argv[4]) if len(sys.argv) > 4 else 3 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) # once, while level print(f"# locked on {nm}, {axis} in {burst}s bursts x{reps}") # ⚠️ Gap in the first run of this probe: it did NOT bracket the HUD clock, so # the absolute deg/GAME-second could not be computed and only the (clock-free) # min:max ratio was usable. Screenshot the HUD at the start and end. import subprocess subprocess.run(["screenshot", "/sylph-home/re/shots/burst_clock_a.png"], capture_output=True) rows = [] for tname, tstate in THROTTLES: for rep in range(reps): pad_state(**tstate) time.sleep(6.0) # settle the throttle, wings level-ish settled, _ = measure(w, off, cfg, axis, u_i, w_i, f_i, None, 1.0) pad_state(**tstate, **DRIVER[axis]) bspeed, rate = measure(w, off, cfg, axis, u_i, w_i, f_i, None, burst) pad_state(**tstate) rows.append((tname, rep, round(settled, 1), round(bspeed, 1), round(rate, 2))) print(f"# {tname:<6} rep{rep} settled {settled:7.1f} " f"during {bspeed:7.1f} rate {rate:6.1f} deg/wall-s") if not alive(w, off, cfg): print("# CRAFT STOPPED MOVING — aborting") pad_state() break else: continue break pad_state() subprocess.run(["screenshot", "/sylph-home/re/shots/burst_clock_b.png"], capture_output=True) with open(out_csv, "w") as f: f.write("throttle,rep,settled_speed,burst_speed,rate_deg_per_wall_s\n") for r in rows: f.write(",".join(str(x) for x in r) + "\n") print(f"# wrote {out_csv}") if __name__ == "__main__": raise SystemExit(main())