Sign test v3: a crooked ship is the measurement, not a failure

The tilt abort was circular logic -- it demanded a balanced ship before it
would learn how to balance one. Removed entirely, and the test restructured
so imbalance is handled structurally rather than guarded against:

- Rocking probe: alternate front/aft pushes; sign = rate(+) - rate(-).
  A constant lean appears in both terms and cancels exactly, so no balanced
  baseline is needed. Alternating also keeps the ship rocking around its
  start attitude instead of accumulating tilt. Two agreeing cycles verify.
- New BALANCE phase: with the sign known, an integral loop learns the
  differential trim that actually levels this ship and saves it as the
  pitchTrim warm-start. Not converging is a warning, not a failure.
- maxTiltAbort is now "measurement is good enough, stop pushing" -- it ends
  a measurement early once the window is full, never the test.
- Only three failures remain: no lift, liftoff timeout, no response on any
  gimbal axis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 23:56:20 +02:00
parent d012246d0c
commit 81e558d04e
3 changed files with 101 additions and 57 deletions

View File

@@ -29,10 +29,13 @@ side drives which balloon, pitch sign, hover level) is measured on the ship by
the **setup wizard**, which the install flow takes you straight into: the **setup wizard**, which the install flow takes you straight into:
1. **Wiring** — the wizard pulses each free side; you say which burner fired. 1. **Wiring** — the wizard pulses each free side; you say which burner fired.
2. **Sign test** — the ship lifts off by itself, finds its own lift power, 2. **Sign test** — the ship lifts off by itself, finds its own lift power, then
pushes the front balloon, verifies the response in both directions *rocks* between front and aft pushes. The sign comes from
(auto-escalating the push if the response is weak), then descends and holds `rate(+push) rate(push)`, which cancels any constant lean exactly, so a
hover. No config editing, no freefall. Takes 13 minutes. ship hanging crooked measures fine — that lean **is** the thing being
measured. It then learns the differential trim that levels the ship, saves
it as a warm-start, and descends to hover. No config editing, no freefall.
Takes 24 minutes.
After that the ship is ready: `FLY` from the menu or `ENGAGE` from the cockpit After that the ship is ready: `FLY` from the menu or `ENGAGE` from the cockpit
console. Pitch and altitude keep self-learning in flight. The wizard can be console. Pitch and altitude keep self-learning in flight. The wizard can be

View File

@@ -124,6 +124,9 @@ return {
nudgeMinHold = 6.0, -- s min under nudge (>= ~5 s loft-etterslep) foer godtatt nudgeMinHold = 6.0, -- s min under nudge (>= ~5 s loft-etterslep) foer godtatt
nudgeMaxWait = 14.0, -- s timeout nudge nudgeMaxWait = 14.0, -- s timeout nudge
drMin = 0.3, -- deg/s min rate-endring for aa godta/lagre fortegn drMin = 0.3, -- deg/s min rate-endring for aa godta/lagre fortegn
maxTiltAbort = 25.0, -- deg: stopp open-loop maaling om skipet roterer for langt -- IKKE en abort: et skjevt skip er nettopp det testen finnes for. Naar tiltet passerer
-- dette OG maalevinduet er fullt, avslutter vi bare maalingen tidlig (vi har svaret --
-- unoedig aa tippe videre). Balansefasen retter skjevheten etterpaa.
maxTiltAbort = 25.0, -- deg fra start-attityde: "godt nok maalt, slutt aa dytte"
}, },
} }

View File

@@ -145,14 +145,21 @@ local function runMonitor()
end end
end end
-- ============================ SIGN TEST (§13.2, v2 -- SELVGAAENDE) ============================ -- ============================ SIGN TEST (§13.2, v3 -- GYNGE-PROBE) ============================
-- Maaler pitch-responsen paa en differanse-nudge for aa finne pitchSign. Bruker pitch-RATEN, ikke -- GRUNNPREMISS: et SKJEVT skip er nettopp skipet denne testen finnes for. Skjevhet med balansert
-- vinkelen: open-loop under et vedvarende moment er skipet en naer-integrator -> VINKELEN settler -- loft er MAALEDATA (det er ubalansen vi skal laere aa trimme bort), ikke en feiltilstand. Derfor
-- aldri, men RATEN gjor det. Hele testen er selvgaaende -- INGEN config-redigering: -- finnes ingen "for skjevt"-abort -- testen maaler seg gjennom skjevheten og retter den til slutt.
-- 1/4 trapper seg luftbaaren fra bunnen (laerer ~hover paa kjoepet, ingen fast liftLevel) --
-- 2/4 klatrer kontrollert til trygg maalehoyde (fart-P-hold rundt laert hover) -- Fortegnet trenger INGEN balansert baseline: sign = sign(rate(+dytt) - rate(-dytt)). Differansen
-- 3/4 baseline -> nudge front -> MOTSATT nudge (verifisering); svak respons -> auto sterkere dytt -- mellom de to dyttene kansellerer enhver konstant skjev-drift eksakt. Og fordi vi VEKSLER mellom
-- 4/4 lagrer, senker skipet kontrollert tilbake og HOLDER hover -- ALDRI frittfall -- dyttene gynger skipet rundt utgangspunktet i stedet for aa akkumulere tilt -- selvbegrensende.
--
-- 1/5 trapper seg luftbaaren fra bunnen (laerer ~hover paa kjoepet, ingen fast liftLevel)
-- 2/5 klatrer kontrollert til trygg maalehoyde (fart-P-hold rundt laert hover)
-- 3/5 GYNGE-PROBE: front-dytt <-> bak-dytt, 2 sykluser; aksen som svarer blir pitchIndex.
-- Svak respons -> auto sterkere dytt. Maaler ALLE gimbal-akser (byggeretning likegyldig)
-- 4/5 BALANSE: med fortegnet kjent laeres differanse-trimmen som holder skipet vannrett
-- 5/5 lagrer, senker skipet kontrollert tilbake og HOLDER hover -- ALDRI frittfall
local signtestAirborne = false -- for runMode-cleanup: hold hover hvis luftbaaren, ellers alt av local signtestAirborne = false -- for runMode-cleanup: hold hover hvis luftbaaren, ellers alt av
@@ -261,8 +268,9 @@ local function runSigntest()
levelDiff = clamp(levelDiff - pSign * 0.2 * est.pitch * cfg.dt, -1.5, 1.5) levelDiff = clamp(levelDiff - pSign * 0.2 * est.pitch * cfg.dt, -1.5, 1.5)
end end
velTick(-0.4, levelDiff, "descend") velTick(-0.4, levelDiff, "descend")
hud("4/4", "Coming back down...", { hud("5/5", "Coming back down...", {
string.format("descending to +3 hover ~%.1f", hover), string.format("descending to +3 hover ~%.1f", hover),
string.format("trim %+.2f holding it level", levelDiff),
}) })
end end
local tS = now() local tS = now()
@@ -282,9 +290,6 @@ local function runSigntest()
end end
local function abortMsg(code) local function abortMsg(code)
if code == "tilt" then
return string.format("Tilted past %.0f deg with BALANCED lift --\nship is out of balance. Aborted.", s.maxTiltAbort)
end
if code == "time" then if code == "time" then
return "Ran out of time -- could not hold a\nsteady measuring height. Check burner\nfuel / ship weight and try again." return "Ran out of time -- could not hold a\nsteady measuring height. Check burner\nfuel / ship weight and try again."
end end
@@ -295,10 +300,9 @@ local function runSigntest()
local cmd, stepT, riseHold, tLift = cfg.absFloor, now(), 0, now() local cmd, stepT, riseHold, tLift = cfg.absFloor, now(), 0, now()
while true do while true do
tick(cmd, cmd, "lift") tick(cmd, cmd, "lift")
hud("1/4", "Lifting off (auto power search)...", { hud("1/5", "Lifting off (auto power search)...", {
string.format("power %.1f / %d", cmd, cfg.maxSignal), string.format("power %.1f / %d", cmd, cfg.maxSignal),
}) })
if maxTilt() > s.maxTiltAbort then return bail(abortMsg("tilt")) end
if est.verticalVelocity > 0.25 then riseHold = riseHold + cfg.dt else riseHold = 0 end if est.verticalVelocity > 0.25 then riseHold = riseHold + cfg.dt else riseHold = 0 end
if riseHold >= 1.0 and est.altitude - h0 > 1.5 then break end if riseHold >= 1.0 and est.altitude - h0 > 1.5 then break end
if now() - stepT >= 4.0 then if now() - stepT >= 4.0 then
@@ -323,21 +327,21 @@ local function runSigntest()
while now() < deadline do while now() < deadline do
local dh = est.altitude - (h0 + BAND_TARGET) local dh = est.altitude - (h0 + BAND_TARGET)
if math.abs(dh) < 1.5 then return true end if math.abs(dh) < 1.5 then return true end
velTick(dh > 0 and -0.4 or 0.4, 0, "recenter") velTick(dh > 0 and -0.4 or 0.4, levelDiff, "recenter")
hud(step, "Flying to measuring height (" .. reason .. ")", { hud(step, "Flying to measuring height (" .. reason .. ")", {
string.format("target +%d now %+.1f hover ~%.1f", BAND_TARGET, est.altitude - h0, hover), string.format("target +%d now %+.1f hover ~%.1f", BAND_TARGET, est.altitude - h0, hover),
string.format("tilt %+.0f deg (fine -- that's what we fix)", maxTilt()),
}) })
if maxTilt() > s.maxTiltAbort then return false, "tilt" end
end end
return false, "time" return false, "time"
end end
local okC, cErr = recenter("2/4", "initial climb") local okC, cErr = recenter("2/5", "initial climb")
if not okC then return bail(abortMsg(cErr)) end if not okC then return bail(abortMsg(cErr)) end
local tSettle = now() local tSettle = now()
while now() - tSettle < 8 do while now() - tSettle < 8 do
velTick(0, 0, "settle0") velTick(0, 0, "settle0")
hud("2/4", "Settling (learning true hover)...", { hud("2/5", "Settling (learning true hover)...", {
string.format("hover ~%.2f %.0fs left", hover, math.max(0, 8 - (now() - tSettle))), string.format("hover ~%.2f %.0fs left", hover, math.max(0, 8 - (now() - tSettle))),
}) })
end end
@@ -369,12 +373,9 @@ local function runSigntest()
string.format("diff %+.0f rates:%s", diff, rline), string.format("diff %+.0f rates:%s", diff, rline),
string.format("calm when spread < %.1f (now %.2f)", s.rateCalm, spanMax), string.format("calm when spread < %.1f (now %.2f)", s.rateCalm, spanMax),
}) })
if maxTilt() > s.maxTiltAbort then -- INGEN tilt-abort: skjevhet er maalingens formaal. Har raten allerede svart tydelig,
-- kraftig tilt UNDER EN NUDGE er i seg selv et sterkt, entydig signal: bruk -- slutter vi likevel aa dytte tidlig -- vi har det vi kom for, unoedig aa tippe mer.
-- maalingen og slutt aa dytte. Tilt med balansert loft = ubalansert skip -> abort. if maxTilt() > s.maxTiltAbort and #win >= 5 then return avg end
if diff ~= 0 then return avg end
return nil, "tilt"
end
local rel = est.altitude - h0 local rel = est.altitude - h0
if rel < BAND_LO or rel > BAND_HI then return nil, "band" end if rel < BAND_LO or rel > BAND_HI then return nil, "band" end
if now() > deadline then return nil, "time" end if now() > deadline then return nil, "time" end
@@ -386,65 +387,102 @@ local function runSigntest()
-- selv-reparerende maaling: hoyde-drift -> re-sentrer og proev IGJEN i stedet for abort -- selv-reparerende maaling: hoyde-drift -> re-sentrer og proev IGJEN i stedet for abort
local function measureSafe(diff, title, minHold, maxWait) local function measureSafe(diff, title, minHold, maxWait)
while true do while true do
local r, code = measureRate(diff, "3/4", title, minHold, maxWait) local r, code = measureRate(diff, "3/5", title, minHold, maxWait)
if r then return r end if r then return r end
if code ~= "band" then return nil, code end if code ~= "band" then return nil, code end
local okR, rErr = recenter("3/4", "altitude drift") local okR, rErr = recenter("3/5", "altitude drift")
if not okR then return nil, rErr end if not okR then return nil, rErr end
end end
end end
-- ---- 3/4: baseline -> nudge -> MOTSATT nudge; aksen som SVARER blir pitchIndex ---- -- ---- 3/5: GYNGE-PROBE -- front-dytt <-> bak-dytt; aksen som SVARER blir pitchIndex ----
-- Verifisert respons (motsatt dytt gir motsatt rate) godtas ved HALV terskel: to -- d = rate(+dytt) - rate(-dytt) trenger INGEN baseline: en konstant skjev-drift finnes i
-- uavhengige, konsistente maalinger er sterkere bevis enn en stor enkeltmaaling. -- begge leddene og kanselleres eksakt. To sykluser som er ENIGE om fortegnet = verifisert.
local nudge = s.nudge local nudge = s.nudge
local maxNudge = math.max(s.nudge, math.floor(cfg.maxSignal / 2) + 1) local maxNudge = math.max(s.nudge, math.floor(cfg.maxSignal / 2) + 1)
local bestVer, bestAny = nil, nil local pick = nil
for attempt = 1, 3 do for attempt = 1, 3 do
local tag = string.format(" (try %d, push %d)", attempt, nudge) local ds = {}
local r0, e0 = measureSafe(0, "Baseline (balanced)" .. tag, s.baseMinHold, s.baseMaxWait) for c = 1, 2 do
if not r0 then return bail(abortMsg(e0)) end local tag = string.format(" (cycle %d/2, push %d)", c, nudge)
local r1, e1 = measureSafe(nudge, "Push: more FRONT" .. tag, s.nudgeMinHold, s.nudgeMaxWait) local rF, eF = measureSafe(nudge, "Rocking: push FRONT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
if not r1 then return bail(abortMsg(e1)) end if not rF then return bail(abortMsg(eF)) end
local r2, e2 = measureSafe(-nudge, "Verify: more AFT" .. tag, s.nudgeMinHold, s.nudgeMaxWait) local rA, eA = measureSafe(-nudge, "Rocking: push AFT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
if not r2 then return bail(abortMsg(e2)) end if not rA then return bail(abortMsg(eA)) end
local d = {}
for i = 1, NAX do d[i] = rF[i] - rA[i] end
ds[c] = d
end
-- aksen med sterkest ENIG respons vinner (begge sykluser samme retning)
for i = 1, NAX do for i = 1, NAX do
local dF, dR = r1[i] - r0[i], r2[i] - r0[i] -- rate-endring: mer front / mer bak local d1, d2 = ds[1][i], ds[2][i]
local cand = { axis = i, sign = utils.sign(dF), mag = math.abs(dF) } local sg = utils.sign(d1)
local consistent = utils.sign(dR) == -utils.sign(dF) and math.abs(dR) >= s.drMin * 0.25 local mag = (math.abs(d1) + math.abs(d2)) / 2
if consistent and cand.mag >= s.drMin * 0.5 then if sg ~= 0 and utils.sign(d2) == sg and mag >= s.drMin then
if not bestVer or cand.mag > bestVer.mag then bestVer = cand end if not pick or mag > pick.mag then pick = { axis = i, sign = sg, mag = mag } end
elseif cand.mag >= s.drMin then
if not bestAny or cand.mag > bestAny.mag then bestAny = cand end
end end
end end
if bestVer then break end if pick then break end
nudge = math.min(maxNudge, nudge + 1) -- auto: proev sterkere dytt nudge = math.min(maxNudge, nudge + 1) -- auto: proev sterkere dytt
end end
local pick = bestVer or bestAny
if not pick then if not pick then
return bail("No pitch response on ANY gimbal axis,\neven with a stronger push. Likely causes:\n- links cross-wired: did BOTH burners fire\n on one pulse in Wiring? Re-run Wiring.\n- balloons not on the fore/aft line.") return bail("No pitch response on ANY gimbal axis,\neven with a stronger push. Likely causes:\n- links cross-wired: did BOTH burners fire\n on one pulse in Wiring? Re-run Wiring.\n- balloons not spaced along the hull.")
end end
local sign, axis, verified = pick.sign, pick.axis, (bestVer ~= nil) local sign, axis = pick.sign, pick.axis
cfg.pitchIndex = axis -- gjeld umiddelbart (estimator, balansefase, nedstigning)
-- ---- 4/5: BALANSE -- laer differanse-trimmen som holder DETTE skipet vannrett ----
-- Dette er hele poenget: skipet henger skjevt, og vi maaler nettopp trimmen som retter det.
-- Integralet laerer den varige trimmen; P/D bare demper transienten og forsvinner ved likevekt.
-- Konvergerer den ikke: vi lagrer likevel beste trim + advarer (begrenset pitch-autoritet).
local Kp_lvl, Kd_lvl, Ki_lvl = 0.15, 0.08, 0.03
local trimMax = cfg.maxSignal / 2
local levelTrim, calm, tBal = 0, 0, now()
local pitchNow, rateNow = 0, 0
while now() - tBal < 90 and now() < deadline do
pitchNow = (lastAx and lastAx[axis]) or 0
rateNow = axRings[axis]:slope()
levelTrim = clamp(levelTrim + Ki_lvl * sign * (-pitchNow) * cfg.dt, -trimMax, trimMax)
levelDiff = clamp(levelTrim + sign * (-Kp_lvl * pitchNow - Kd_lvl * rateNow), -trimMax, trimMax)
velTick(0, levelDiff, "balance")
hud("4/5", "Learning the balance trim...", {
string.format("tilt %+.1f deg rate %+.2f", pitchNow, rateNow),
string.format("trim %+.2f level for %.1f/4.0 s", levelTrim, calm),
})
if math.abs(pitchNow) < 2.0 and math.abs(rateNow) < 0.3 then calm = calm + cfg.dt else calm = 0 end
if calm >= 4.0 then break end
local rel = est.altitude - h0
if rel < BAND_LO or rel > BAND_HI then
local okR, rErr = recenter("4/5", "altitude drift")
if not okR then return bail(abortMsg(rErr)) end
end
end
local balanced = calm >= 4.0
levelDiff = levelTrim -- nedstigningen fortsetter fra den laerte trimmen
-- ---- lagre FOER nedstigningen (Ctrl+T etterpaa mister ingenting) ---- -- ---- lagre FOER nedstigningen (Ctrl+T etterpaa mister ingenting) ----
local c = loadCalib() local c = loadCalib()
c.pitchSign, c.pitchGain, c.alphaPitch = sign, 1.0, 1.0 c.pitchSign, c.pitchGain, c.alphaPitch = sign, 1.0, 1.0
c.pitchIndex = axis -- MAALT: aksen som faktisk svarte paa dyttet c.pitchIndex = axis -- MAALT: aksen som faktisk svarte paa dyttet
cfg.pitchIndex = axis -- gjeld umiddelbart (estimator + nedstigning) c.pitchTrim = 2 * levelTrim -- diff -> D_des-enheter (mixer: front = u + D/2)
c.pitchTrim = 0 -- nullstill laert trim ved ny fortegns-maaling
c.uHoverFront, c.uHoverRear = hover, hover -- grov men EKTE hover-maaling ('cal' kan forfine) c.uHoverFront, c.uHoverRear = hover, hover -- grov men EKTE hover-maaling ('cal' kan forfine)
c.hoverTrim = 0 -- ny baseline -> gammel loft-trim er ugyldig c.hoverTrim = 0 -- ny baseline -> gammel loft-trim er ugyldig
c.signMeasured = true -- profilen er komplett (wizard/standby sjekker denne) c.signMeasured = true -- profilen er komplett (wizard/standby sjekker denne)
saveCalib(c) saveCalib(c)
-- ---- 4/4: kontrollert ned + hold hover (nivellerer med nymaalt akse + fortegn) ---- -- ---- 5/5: kontrollert ned + hold hover (nivellert med den nylaerte trimmen) ----
descendAndHold(sign) descendAndHold(sign)
term.clear(); term.setCursorPos(1, 1) term.clear(); term.setCursorPos(1, 1)
print("==== SIGN TEST: SUCCESS ====") print("==== SIGN TEST: SUCCESS ====")
print(string.format("pitch = gimbal axis a%d, sign %+d", axis, sign)) print(string.format("pitch = gimbal axis a%d, sign %+d", axis, sign))
print(verified and "(verified in both directions)" or "(UNVERIFIED -- reverse push unclear)") print(string.format("balance trim %+.2f (saved warm-start)", 2 * levelTrim))
if balanced then
print("Ship levelled out under this trim.")
else
print(string.format("NOTE: still %+.0f deg off level -- limited", pitchNow))
print("pitch authority. FLY keeps learning it.")
end
print(string.format("hover ~%.1f (saved as baseline)", hover)) print(string.format("hover ~%.1f (saved as baseline)", hover))
print("") print("")
print("Ship is holding hover. Ready to FLY.") print("Ship is holding hover. Ready to FLY.")