tools+docs: the pitch stick sign was inverted - the pilot fires now
Measured, not argued: command a 45-degree-off-the-nose error and watch whether it
shrinks, both axes, both sides, two pulse widths, with the opposite sign as a
control. Yaw's sign is correct (45 -> 11.3/32.7 at 0.6 s, 41.6/33.8 at 1.2 s).
Pitch's is inverted - the pilot's own sign GREW the error every time
(48.3/55.6/70.7/91.7) and the opposite shrank it every time (30.7/41.5/15.7/9.6).
A method artefact is recorded because it gave the opposite answer first: a 3 s
full-deflection pulse overshoots a 45-degree error so far that BOTH signs look
wrong (45 -> 164 and 45 -> 178). A long pulse cannot answer a sign question.
Verified against the game rather than by inspection. Before: fire=1 in 0 of 13521
samples, |aim yaw| pinned at 90.0, target 36-43 km away. After: 43 of 1732, aim
down to 2.3 degrees, range median 6.3 km, and the HUD's own ammunition counters
moving - NOSE BM 06000 -> 05723, MAIN MPM 00300 -> 00298.
Also fixed a leftover of the same FIFO era: pilot.py called pad.f.write("tap A
90") for the target-select double tap, which raised AttributeError once Pad
stopped having an `f`. Pad gained tap()/dpad(); ctrl_probe.py and target_probe.py
still use pad.f and now say so in place.
Still open: YOU KILLED is 0000 after 250 s of firing and REMAINING OB is still
012. The craft shoots, closes and selects; whether it destroys anything is next.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PMRJjbxLqZtsb5Vb7KunPE
This commit is contained in:
BIN
docs/re/captures/stage02-pilot-firing-after-sign-fix.png
Normal file
BIN
docs/re/captures/stage02-pilot-firing-after-sign-fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -122,7 +122,58 @@ Same probe, on runs confirmed animating at both ends of every phase
|
|||||||
suspicion that the binding was wrong: `pos-0x70` is live and tracks the ship. It
|
suspicion that the binding was wrong: `pos-0x70` is live and tracks the ship. It
|
||||||
only looked dead because nothing was turning the ship.
|
only looked dead because nothing was turning the ship.
|
||||||
|
|
||||||
## 🟡 Still open — the pilot does not converge
|
## ✅ AND THE SECOND HALF: the PITCH stick sign was inverted
|
||||||
|
|
||||||
|
(The section below is what was open before this was measured; it is kept because
|
||||||
|
the sequence matters.)
|
||||||
|
|
||||||
|
With the pad reaching the game, the sign question became testable and was
|
||||||
|
settled by commanding a **45°-off-the-nose error** and watching whether it
|
||||||
|
shrank — both axes, both sides, two pulse widths, plus the opposite sign as a
|
||||||
|
control:
|
||||||
|
|
||||||
|
| pulse | axis | pilot's own sign | opposite sign |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0.6 s | yaw | 45° → **11.3** / **32.7** ✅ reduces | — |
|
||||||
|
| 1.2 s | yaw | 45° → **41.6** / **33.8** ✅ reduces | — |
|
||||||
|
| 0.6 s | pitch | 45° → 48.3 / 55.6 🔴 grows | 45° → **30.7** / **41.5** reduces |
|
||||||
|
| 1.2 s | pitch | 45° → 70.7 / 91.7 🔴 grows | 45° → **15.7** / **9.6** reduces |
|
||||||
|
|
||||||
|
So `sticks()`'s `sy = -(KP*pitch - KD*…)` had the **wrong sign**; yaw's is right.
|
||||||
|
|
||||||
|
🔴 **A method artefact worth recording, because it gave the opposite answer
|
||||||
|
first.** The first attempt used a **3 s** full-deflection pulse and reported
|
||||||
|
pitch growing under *both* signs (45° → 164° and 45° → 178°). That is
|
||||||
|
overshoot: three seconds of full pitch swings the ship far past a 45° error, so
|
||||||
|
the frozen world-direction ends up behind it whichever way it turns. Pulse
|
||||||
|
short, or measure the initial rotation direction — a long pulse cannot answer a
|
||||||
|
sign question.
|
||||||
|
|
||||||
|
### Verified: the pilot fires
|
||||||
|
|
||||||
|
Same mission, same binding, sign flipped:
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `fire=1` samples | **0** of 13 521 | **43** of 1 732 |
|
||||||
|
| `\|aim yaw\|` | median 90.0°, min 90.0° | min **2.3°**, 7 of 14 inside 25° in the first pass |
|
||||||
|
| range to target | median 43 km, min 36 km | median **6.3 km**, min 5.3 km |
|
||||||
|
| ammunition | untouched | `NOSE BM 06000 → 05723`, `MAIN MPM 00300 → 00298` |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
One more leftover of the same FIFO era was fixed on the way: `pilot.py` called
|
||||||
|
`self.pad.f.write("tap A 90")` for the target-select double tap, which raised
|
||||||
|
`AttributeError` the moment the class stopped having an `f`. `Pad` gained
|
||||||
|
`tap()`/`dpad()`; `ctrl_probe.py` and `target_probe.py` still use `pad.f` and now
|
||||||
|
say so in place.
|
||||||
|
|
||||||
|
🟡 **Still open even so:** `YOU KILLED WARSHIPS/WARPLANES` is still `0000` after
|
||||||
|
250 s of firing, and `REMAINING OB` is still `012`. The craft shoots, closes and
|
||||||
|
selects; whether it actually *destroys* anything is the next thing to measure,
|
||||||
|
and it is what the objective-counter work is waiting on.
|
||||||
|
|
||||||
|
## 🟡 The state before that measurement — the pilot does not converge
|
||||||
|
|
||||||
With the pad fixed, a 150 s `pilot.py` run still fired **0** times: over its 86
|
With the pad fixed, a 150 s `pilot.py` run still fired **0** times: over its 86
|
||||||
targeted samples `|aim yaw|` is *still* exactly 90.0° and the range is
|
targeted samples `|aim yaw|` is *still* exactly 90.0° and the range is
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ import math
|
|||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# 🔴 DEAD FIFO HANDLE: this file uses `pad.f.write(...)`, the old vgamepad
|
||||||
|
# server protocol. flight_probe.Pad no longer has an `f` — it writes Canary's
|
||||||
|
# --hid=file pad file instead — so these calls now raise AttributeError. Use
|
||||||
|
# pad.tap()/pad.dpad(). Not converted here: neither tool has been re-run since.
|
||||||
|
# See docs/re/pilot-never-fires.md.
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|||||||
@@ -99,6 +99,28 @@ class Pad:
|
|||||||
self.buttons.clear()
|
self.buttons.clear()
|
||||||
self._write()
|
self._write()
|
||||||
|
|
||||||
|
def tap(self, b, secs=0.09):
|
||||||
|
"""Press and release, blocking for `secs`.
|
||||||
|
|
||||||
|
The old FIFO server slept on the *server* side, so callers wrote
|
||||||
|
`tap A 90` and carried on; `pilot.py` still did that through `pad.f`,
|
||||||
|
which this class no longer has. Here the caller pays the 90 ms. That is
|
||||||
|
acceptable only because the one caller — the double-tap that selects a
|
||||||
|
target — is rate-limited; do not put this in a per-tick path.
|
||||||
|
"""
|
||||||
|
self.press(b)
|
||||||
|
time.sleep(secs)
|
||||||
|
self.release(b)
|
||||||
|
|
||||||
|
def dpad(self, direction, secs=0.06):
|
||||||
|
"""One d-pad step; `center` releases all four."""
|
||||||
|
for d in ("UP", "DOWN", "LEFT", "RIGHT"):
|
||||||
|
self.buttons.discard(d)
|
||||||
|
if direction.lower() == "center":
|
||||||
|
self._write()
|
||||||
|
return
|
||||||
|
self.tap(direction.upper(), secs)
|
||||||
|
|
||||||
def vector(self):
|
def vector(self):
|
||||||
return [self.state[k] for k in ("LX", "LY", "RX", "RY", "LT", "RT")]
|
return [self.state[k] for k in ("LX", "LY", "RX", "RY", "LT", "RT")]
|
||||||
|
|
||||||
|
|||||||
@@ -283,8 +283,9 @@ class Pilot:
|
|||||||
"""A, twice: make the GAME target what we are already pointing at."""
|
"""A, twice: make the GAME target what we are already pointing at."""
|
||||||
if self.dry or t - self.select_t < self.SELECT_PERIOD:
|
if self.dry or t - self.select_t < self.SELECT_PERIOD:
|
||||||
return
|
return
|
||||||
self.pad.f.write("tap A 90\n")
|
# Target select is (A) pressed TWICE — see autopilot-memory-driven.md.
|
||||||
self.pad.f.write("tap A 90\n")
|
self.pad.tap("A", 0.09)
|
||||||
|
self.pad.tap("A", 0.09)
|
||||||
self.select_t = t
|
self.select_t = t
|
||||||
self.selects += 1
|
self.selects += 1
|
||||||
|
|
||||||
@@ -404,7 +405,17 @@ class Pilot:
|
|||||||
if ez < 0: # target behind: commit to a full turn
|
if ez < 0: # target behind: commit to a full turn
|
||||||
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
yaw = math.copysign(math.pi / 2, ex if ex else 1.0)
|
||||||
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
sx = max(-1.0, min(1.0, self.KP * yaw - self.KD * float(np.dot(w, up))))
|
||||||
sy = max(-1.0, min(1.0, -(self.KP * pitch - self.KD * float(np.dot(w, right)))))
|
# 🔴 The leading minus was WRONG and is measured out (2026-08-23). With
|
||||||
|
# the pad finally reaching the game, a 45°-off-the-nose error was
|
||||||
|
# commanded with this convention and with its opposite, at 0.6 s and
|
||||||
|
# 1.2 s pulses, both sides: the pilot's own sign GREW the error every
|
||||||
|
# time (45° -> 48/56/71/92) and the opposite sign SHRANK it every time
|
||||||
|
# (45° -> 31/41/16/10). Yaw's sign, tested the same way, is correct.
|
||||||
|
#
|
||||||
|
# Method note, because the first attempt got the opposite answer: a 3 s
|
||||||
|
# full-deflection pulse OVERSHOOTS a 45° error so far that both signs
|
||||||
|
# look wrong. Pulse short, or measure the initial rotation direction.
|
||||||
|
sy = max(-1.0, min(1.0, self.KP * pitch - self.KD * float(np.dot(w, right))))
|
||||||
return sx, sy, yaw, pitch
|
return sx, sy, yaw, pitch
|
||||||
|
|
||||||
# ---------------------------------------------------------------- step
|
# ---------------------------------------------------------------- step
|
||||||
|
|||||||
112
tools/re-capture/stick_sign.py
Executable file
112
tools/re-capture/stick_sign.py
Executable file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Does `pilot.py`'s stick sign REDUCE its own aim error, or grow it?
|
||||||
|
|
||||||
|
With the pad finally reaching the game (`pilot-never-fires.md`), the pilot still
|
||||||
|
fires 0 times in 150 s and `|aim yaw|` stays pinned at exactly 90° — the `ez < 0`
|
||||||
|
branch, i.e. the target is astern and stays astern. A controller whose stick sign
|
||||||
|
is inverted looks exactly like that: it turns away from the error it is nulling,
|
||||||
|
so the target never comes round the front.
|
||||||
|
|
||||||
|
The test is direct and needs no target. Pick a direction 45° off the nose in the
|
||||||
|
ship's own frame, freeze it in WORLD coordinates, command the deflection
|
||||||
|
`sticks()` would command for that error, and see whether the error falls or
|
||||||
|
rises. Both signs are tested, because one of them agreeing by luck would prove
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
`sticks()`'s convention, replicated exactly:
|
||||||
|
|
||||||
|
fwd = M[fwd_row] * fwd_sign
|
||||||
|
right = M[(fwd_row + 1) % 3]
|
||||||
|
up = cross(fwd, right)
|
||||||
|
yaw = atan2(dot(want, right), dot(want, fwd))
|
||||||
|
sx = KP*yaw - KD*(body rate about up)
|
||||||
|
sy = -(KP*pitch - KD*(body rate about right))
|
||||||
|
|
||||||
|
The KD terms are dropped here: the P term saturates the stick anyway, and a
|
||||||
|
damping term cannot flip a sign.
|
||||||
|
|
||||||
|
Usage: stick_sign.py <nav-live.json> [seconds_per_phase]
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import frozen # noqa: E402
|
||||||
|
import navigator # noqa: E402
|
||||||
|
from flight_probe import Pad # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = json.load(open(sys.argv[1]))
|
||||||
|
secs = float(sys.argv[2]) if len(sys.argv) > 2 else 3.0
|
||||||
|
W = navigator.World(cfg)
|
||||||
|
W.scan()
|
||||||
|
pad = Pad()
|
||||||
|
|
||||||
|
def frame():
|
||||||
|
ents = W.sample(time.time())
|
||||||
|
me = next((e for e in ents if "Player" in e[1]), None)
|
||||||
|
if me is None:
|
||||||
|
return None
|
||||||
|
M = W.rot(me[0])
|
||||||
|
if M is None:
|
||||||
|
return None
|
||||||
|
fwd = M[W.fwd_row] * W.fwd_sign
|
||||||
|
right = M[(W.fwd_row + 1) % 3]
|
||||||
|
return fwd, right, np.cross(fwd, right)
|
||||||
|
|
||||||
|
def err(want, f):
|
||||||
|
fwd, right, up = f
|
||||||
|
yaw = math.atan2(float(np.dot(want, right)), float(np.dot(want, fwd)))
|
||||||
|
pitch = math.atan2(float(np.dot(want, up)), float(np.dot(want, fwd)))
|
||||||
|
return math.degrees(yaw), math.degrees(pitch)
|
||||||
|
|
||||||
|
def trial(axis, side):
|
||||||
|
f0 = frame()
|
||||||
|
if f0 is None:
|
||||||
|
print(f"{axis}{side:+d}: no player/orientation")
|
||||||
|
return
|
||||||
|
fwd, right, up = f0
|
||||||
|
base = right if axis == "yaw" else up
|
||||||
|
want = fwd * math.cos(math.radians(45)) + base * side * math.sin(math.radians(45))
|
||||||
|
want = want / np.linalg.norm(want)
|
||||||
|
y0, p0 = err(want, f0)
|
||||||
|
# what sticks() would command for this error
|
||||||
|
if axis == "yaw":
|
||||||
|
pad.axis("LX", math.copysign(1.0, y0))
|
||||||
|
cmd = f"LX={math.copysign(1.0, y0):+.0f}"
|
||||||
|
else:
|
||||||
|
pad.axis("LY", -math.copysign(1.0, p0))
|
||||||
|
cmd = f"LY={-math.copysign(1.0, p0):+.0f}"
|
||||||
|
time.sleep(secs)
|
||||||
|
pad.reset()
|
||||||
|
f1 = frame()
|
||||||
|
froze = frozen.frozen(3.0)[0]
|
||||||
|
if f1 is None:
|
||||||
|
print(f"{axis}{side:+d}: lost the player")
|
||||||
|
return
|
||||||
|
y1, p1 = err(want, f1)
|
||||||
|
e0, e1 = (abs(y0), abs(y1)) if axis == "yaw" else (abs(p0), abs(p1))
|
||||||
|
verdict = "REDUCES (sign correct)" if e1 < e0 - 1.0 else (
|
||||||
|
"GROWS (sign INVERTED)" if e1 > e0 + 1.0 else "no change")
|
||||||
|
print(f"{axis} want{side:+d} {cmd}: |err| {e0:6.2f} -> {e1:6.2f} deg "
|
||||||
|
f"{verdict}{' *** FROZE - DISCARD ***' if froze else ''}")
|
||||||
|
|
||||||
|
if frozen.frozen(3.0)[0]:
|
||||||
|
print("# GUEST IS ALREADY FROZEN — nothing to measure")
|
||||||
|
return 3
|
||||||
|
for axis in ("yaw", "pitch"):
|
||||||
|
for side in (+1, -1):
|
||||||
|
trial(axis, side)
|
||||||
|
time.sleep(1.0)
|
||||||
|
pad.reset()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -28,6 +28,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# 🔴 DEAD FIFO HANDLE: this file uses `pad.f.write(...)`, the old vgamepad
|
||||||
|
# server protocol. flight_probe.Pad no longer has an `f` — it writes Canary's
|
||||||
|
# --hid=file pad file instead — so these calls now raise AttributeError. Use
|
||||||
|
# pad.tap()/pad.dpad(). Not converted here: neither tool has been re-run since.
|
||||||
|
# See docs/re/pilot-never-fires.md.
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|||||||
Reference in New Issue
Block a user