re(flight): turn rates confirm AV_* are rate caps and _Min/_Max mean at min/max speed

turn_law.py pins the speed regime with a throttle, holds a stick axis and
differentiates the craft's own forward vector over 1-second windows.

  slow + nose down  ~87 deg/s   (AV_PitchMinus_Min 75)
  fast + nose down  ~54         (AV_PitchMinus_Max 40)
  slow + nose up   ~175         (AV_PitchPlus_Min 150)
  fast + nose up   ~136         (AV_PitchPlus_Max 70)

So agility falls with speed (_Min/_Max are at minimum/maximum speed, not rate
bounds) and pitching up is ~2x pitching down, exactly as the field pairs say.

Control mapping measured: LX is roll (forward vector barely moves, 3-5 deg/s), LY is
pitch (+1 = nose down per vgamepad's LY: -1 = up), and the right stick does not steer
at all.

The ~1.2x overshoot seen in the speed law appears again here (1.16-1.35x), and a
unit scale cannot explain both m/s and deg/s — a TIME BASE can: if the guest's
simulated second is shorter than the wall-clock second the probe measures against,
every rate reads high by the same factor. So the definition numbers are
self-consistent and these measurements confirm the shape of the law, not a scale.

Recorded 🟡: no yaw input found (AV_Yaw_* exists but neither stick yaws), which with
roll on LX and MaximumBank_Normal points at a bank-to-turn model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
This commit is contained in:
2026-08-13 15:30:00 +00:00
parent 1efc3f567d
commit 86fcfcc8b0
3 changed files with 606 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Measure the craft's TURN rates, and whether they depend on speed.
The unit definition carries `AA_Yaw_{Max,Min}`, `AA_PitchPlus_{Max,Min}`,
`AA_Roll_{Max,Min}` — pairs whose obvious reading is "rate at maximum speed" and
"rate at minimum speed", i.e. agility falling as the craft goes faster (for the
player: yaw 65°/s vs 120°/s, roll 475°/s vs 750°/s). That is a prediction, so
measure it: hold a throttle to pin the speed regime, hold a stick axis, and
differentiate the craft's own forward vector.
Same discipline as speed_law.py: lock onto the player object once, sample at
20 Hz, and differentiate over 1-second windows (the guest updates slower than the
sample rate).
Usage: turn_law.py <out.csv> [hold_s]
"""
import json, math, os, struct, subprocess, sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gworld, entities2, speed_law
def fwd_at(fd, off, cfg):
"""The craft's forward row out of its rotation matrix (entities2 `self` bound
rot_delta / rot_stride / fwd_row and validated the row against motion)."""
at = off + cfg["rot_delta"] + cfg["fwd_row"] * cfg["rot_stride"]
v = struct.unpack(">3f", os.pread(fd, 12, at))
n = math.sqrt(sum(c * c for c in v)) or 1.0
s = cfg.get("fwd_sign", 1)
return tuple(s * c / n for c in v)
def pad(*a):
subprocess.run(["vgamepad", *a], capture_output=True)
def rate(seq, lo, hi):
"""Degrees per second between the first sample at/after `lo` and the last at/before `hi`."""
a = [s for s in seq if s[0] >= lo][0]
b = [s for s in seq if s[0] <= hi][-1]
dot = max(-1.0, min(1.0, sum(a[1 + i] * b[1 + i] for i in range(3))))
return math.degrees(math.acos(dot)) / (b[0] - a[0])
def main():
out_csv = sys.argv[1]
hold = float(sys.argv[2]) if len(sys.argv) > 2 else 6.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 after retries")
print(f"# locked on {nm}")
rows = []
# (label, throttle, axis, value) — throttle pins the speed regime, so the
# Max/Min pair in the definition can be told apart.
# LX is ROLL (the forward vector barely moves under it) and the right stick
# does not steer at all — measured. LY is pitch, and `vgamepad` documents
# `LY: -1 = up`, so LY=+1 is nose DOWN = the `PitchMinus` family.
phases = [
("slow_down", ("LT", 1.0), ("LY", 1.0)), # AV_PitchMinus_Min 75 deg/s
("fast_down", ("RT", 1.0), ("LY", 1.0)), # AV_PitchMinus_Max 40
("slow_up", ("LT", 1.0), ("LY", -1.0)), # AV_PitchPlus_Min 150
("fast_up", ("RT", 1.0), ("LY", -1.0)), # AV_PitchPlus_Max 70
]
for label, (trig, tv), (axis, av) in phases:
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0")
pad("axis", "LX", "0.0"); pad("axis", "LY", "0.0"); pad("axis", "RX", "0.0")
pad("trig", trig, str(tv))
time.sleep(1.5) # let the speed settle before turning
pad("axis", axis, str(av))
seq, t0 = [], time.time()
while time.time() - t0 < hold:
f = fwd_at(w.fd, off, cfg)
seq.append((round(time.time() - t0, 3), *f))
time.sleep(0.05)
pad("axis", axis, "0.0")
for s in seq:
rows.append((label, *s))
try:
r = [rate(seq, t, t + 1.0) for t in (1.0, 2.0, 3.0) if seq[-1][0] > t + 1.0]
print(f"# {label:<11} turn rate per 1 s window: " + " ".join(f"{x:6.1f}" for x in r) + " deg/s")
except Exception as e:
print(f"# {label:<11} rate failed: {e}")
pad("trig", "RT", "0.0"); pad("trig", "LT", "0.0"); pad("reset")
with open(out_csv, "w") as f:
f.write("phase,t,fx,fy,fz\n")
for r in rows:
f.write(",".join(str(x) for x in r) + "\n")
print(f"# wrote {out_csv} ({len(rows)} samples)")
if __name__ == "__main__":
main()