The attitude reference was captured at the first sensor read, on the ground, where a ship built to fly typically lies over. That made every tilt reading relative to a meaningless datum, and worse: the hull's natural righting swing after liftoff was read both as tilt and as rate response, contaminating the very measurement the test exists to make. - Attitude reference is now taken in the air, after an explicit righting phase that waits for every axis rate to steady (5 s calm, 120 s cap). Until then maxTilt() reads 0, so nothing can trip on ground attitude. - The settled airborne attitude is recorded and reported per axis, with a warning if the hull settles far from level even under balanced lift. - Wizard now says outright that a ship lying over on the ground is fine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1095 lines
50 KiB
Lua
1095 lines
50 KiB
Lua
-- main.lua -- loop + tilstandsmaskin for v3 observer-autopiloten.
|
|
-- All UI-tekst er ENGELSK; kommentarer er norske (husstil).
|
|
-- Modus (argument eller meny):
|
|
-- boot oppstart: setup-wizard-prompt hvis profil mangler, ellers standby
|
|
-- setup foerstegangs-wizard: wiring + sign test i ett (alt skip-spesifikt)
|
|
-- standby lytter etter ENGAGE fra pilot-konsollen (default etter boot)
|
|
-- monitor Lag 1: les sensorer+estimator, logg+HUD, styrer INGENTING
|
|
-- signtest §13.2: selvgaaende -- loefter, maaler pitch-fortegn, lander igjen
|
|
-- manual Lag 2: faste nivaaer (piltaster), finn hover
|
|
-- cal Lag 3: full kalibrering (selv-laerende)
|
|
-- observe Lag 4: observer ÅPEN SLØYFE -- valider at v_hat sporer v_meas
|
|
-- fly Lag 5-8: full lukket sløyfe (IDLE->CALIBRATING->FLYING->FAULT)
|
|
|
|
local progDir = fs.getDir(shell.getRunningProgram())
|
|
if package and package.path and not string.find(package.path, progDir, 1, true) then
|
|
package.path = progDir .. "/?.lua;" .. package.path
|
|
end
|
|
|
|
local cfg = require("config")
|
|
local utils = require("utils")
|
|
local sensors = require("sensors")
|
|
local Estimator = require("estimator")
|
|
local Logger = require("logger")
|
|
local mixer = require("mixer")
|
|
local calibration = require("calibration")
|
|
local Observer = require("observer")
|
|
local altitude = require("altitude")
|
|
local pitch = require("pitch")
|
|
local safety = require("safety")
|
|
local link = require("link")
|
|
|
|
local clamp, round = utils.clamp, utils.round
|
|
local function now() return os.epoch("utc") / 1000 end
|
|
|
|
-- ============================ AKTUATOR (sigma-delta dither) ============================
|
|
local foreAcc, aftAcc = 0, 0
|
|
local function ditherSide(cmd, acc)
|
|
if cfg.dither then
|
|
acc = acc + cmd
|
|
local out = math.floor(acc + 0.5)
|
|
if out < 0 then out = 0 elseif out > cfg.maxSignal then out = cfg.maxSignal end
|
|
acc = clamp(acc - out, -1, 1)
|
|
return out, acc
|
|
else
|
|
return clamp(round(cmd), 0, cfg.maxSignal), 0
|
|
end
|
|
end
|
|
local function driveValves(fore, aft)
|
|
local of; of, foreAcc = ditherSide(fore, foreAcc)
|
|
redstone.setAnalogOutput(cfg.foreSide, of)
|
|
local oa = 0
|
|
if cfg.aftSide then oa, aftAcc = ditherSide(aft, aftAcc); redstone.setAnalogOutput(cfg.aftSide, oa) end
|
|
return of, oa
|
|
end
|
|
local function allOff()
|
|
foreAcc, aftAcc = 0, 0
|
|
redstone.setAnalogOutput(cfg.foreSide, 0)
|
|
if cfg.aftSide then redstone.setAnalogOutput(cfg.aftSide, 0) end
|
|
end
|
|
|
|
-- ============================ PERSISTENS ============================
|
|
local CALIB_FILE, TARGET_FILE = "calib.txt", "target.txt"
|
|
local function loadCalib()
|
|
local c = {}
|
|
for k, v in pairs(cfg.calib) do c[k] = v end
|
|
if fs.exists(CALIB_FILE) then
|
|
local f = fs.open(CALIB_FILE, "r"); local s = f.readAll(); f.close()
|
|
local g = textutils.unserialise(s or "")
|
|
if type(g) == "table" then for k, v in pairs(g) do c[k] = v end end
|
|
end
|
|
return c
|
|
end
|
|
local function saveCalib(c)
|
|
local f = fs.open(CALIB_FILE, "w"); f.write(textutils.serialise(c)); f.close()
|
|
end
|
|
local function loadTarget()
|
|
if not fs.exists(TARGET_FILE) then return nil end
|
|
local f = fs.open(TARGET_FILE, "r"); local s = f.readAll(); f.close(); return tonumber(s)
|
|
end
|
|
local function saveTarget(h) local f = fs.open(TARGET_FILE, "w"); f.write(tostring(h)); f.close() end
|
|
|
|
-- wiring (front/aft redstone-sider) -- persistert per skip, overstyrer config-defaults
|
|
local SIDES_FILE = "sides.txt"
|
|
local SIDES = { "top", "bottom", "left", "right", "front", "back" }
|
|
local function validSide(s) for _, x in ipairs(SIDES) do if x == s then return true end end return false end
|
|
local function loadSides()
|
|
if not fs.exists(SIDES_FILE) then return nil end
|
|
local f = fs.open(SIDES_FILE, "r"); local s = f.readAll(); f.close()
|
|
local t = textutils.unserialise(s or ""); return (type(t) == "table" and t.fore) and t or nil
|
|
end
|
|
local function saveSides(t) local f = fs.open(SIDES_FILE, "w"); f.write(textutils.serialise(t)); f.close() end
|
|
|
|
-- komplett skip-profil = wiring valgt + pitch-fortegn faktisk MAALT paa DETTE skipet
|
|
-- (signMeasured settes av sign test / full kalibrering -- aldri av en shipped default)
|
|
local function profileComplete()
|
|
if not (fs.exists(SIDES_FILE) and fs.exists(CALIB_FILE)) then return false end
|
|
return loadCalib().signMeasured == true
|
|
end
|
|
|
|
-- live brukerinnstillinger (justeres fra pilot-konsollen), persistert. Syntetiske noekler -> cfg.
|
|
local SETTINGS_FILE = "settings.txt"
|
|
local function saveSettings(t) local f = fs.open(SETTINGS_FILE, "w"); f.write(textutils.serialise(t)); f.close() end
|
|
local function loadSettings()
|
|
if not fs.exists(SETTINGS_FILE) then return nil end
|
|
local f = fs.open(SETTINGS_FILE, "r"); local s = f.readAll(); f.close()
|
|
local t = textutils.unserialise(s or ""); return (type(t) == "table") and t or nil
|
|
end
|
|
-- bruk en innstilling live paa cfg (clamp for sikkerhet). persist=true lagrer til disk.
|
|
local function applySetting(key, value, persist)
|
|
value = tonumber(value); if not value then return end
|
|
if key == "climbSpeed" then value = utils.clamp(value, 0.2, 2.0); cfg.vMaxUp = value; cfg.vMaxDown = value
|
|
elseif key == "holdBand" then value = utils.clamp(value, 1, 10); cfg.altDeadband = value
|
|
elseif key == "maxPower" then value = utils.clamp(math.floor(value + 0.5), 3, 15); cfg.maxSignal = value
|
|
else return end
|
|
if persist then saveSettings({ climbSpeed = cfg.vMaxUp, holdBand = cfg.altDeadband, maxPower = cfg.maxSignal }) end
|
|
end
|
|
|
|
-- bundet proporsjonal hover-hold (universal, brukt av observe + fault-tilstand)
|
|
local function velHoldCommon(calib, vDes, v)
|
|
local hover = (calib.uHoverFront + calib.uHoverRear) / 2
|
|
return clamp(hover + clamp(1.2 * (vDes - v), -1.5, 1.5), cfg.absFloor, cfg.maxSignal)
|
|
end
|
|
|
|
-- ============================ MONITOR (Lag 1) ============================
|
|
local function runMonitor()
|
|
local est = Estimator.new(cfg)
|
|
local log = Logger.new("monitor.log", "# monitor: t, height, pitch, vVel, pitchRate, valid")
|
|
print("MONITOR -> monitor.log (controls nothing). CTRL+T to stop."); sleep(1.0)
|
|
local t0 = now()
|
|
while true do
|
|
local t, h, p = now(), sensors.readAltitude(), sensors.readPitch()
|
|
term.clear(); term.setCursorPos(1, 1); print("==== MONITOR (Layer 1) ====")
|
|
if h == nil or p == nil then print("SENSOR ERROR (nil) -- check sensors.")
|
|
else
|
|
est:update(t, h, p)
|
|
log:row({ t - t0, est.altitude, est.pitch, est.verticalVelocity, est.pitchRate, est.valid and 1 or 0 })
|
|
local vtag = est.verticalVelocity > 0.15 and "(rising)" or est.verticalVelocity < -0.15 and "(sinking)" or "(still)"
|
|
print(string.format("Height %8.2f", est.altitude))
|
|
print(string.format("Pitch %8.2f deg", est.pitch))
|
|
print(string.format("vVel %8.2f b/s %s", est.verticalVelocity, vtag))
|
|
print(string.format("pitchRate %8.2f deg/s", est.pitchRate))
|
|
print(est.valid and "estimate: OK" or "estimate: filling window...")
|
|
end
|
|
sleep(cfg.dt)
|
|
end
|
|
end
|
|
|
|
-- ============================ SIGN TEST (§13.2, v3 -- GYNGE-PROBE) ============================
|
|
-- GRUNNPREMISS: et SKJEVT skip er nettopp skipet denne testen finnes for. Skjevhet med balansert
|
|
-- loft er MAALEDATA (det er ubalansen vi skal laere aa trimme bort), ikke en feiltilstand. Derfor
|
|
-- finnes ingen "for skjevt"-abort -- testen maaler seg gjennom skjevheten og retter den til slutt.
|
|
--
|
|
-- Fortegnet trenger INGEN balansert baseline: sign = sign(rate(+dytt) - rate(-dytt)). Differansen
|
|
-- mellom de to dyttene kansellerer enhver konstant skjev-drift eksakt. Og fordi vi VEKSLER mellom
|
|
-- dyttene gynger skipet rundt utgangspunktet i stedet for aa akkumulere tilt -- selvbegrensende.
|
|
--
|
|
-- BAKKE-ATTITYDEN BRUKES ALDRI: skipet er bygget for aa FLY, ikke for aa staa, saa paa bakken
|
|
-- ligger det gjerne paa siden. Vi venter til oppdriften har reist det opp OG ratene har roet seg
|
|
-- foer attityde-referansen settes -- ellers ville skipets egen oppretting blitt lest som respons.
|
|
--
|
|
-- 1/5 trapper seg luftbaaren fra bunnen (laerer ~hover paa kjoepet, ingen fast liftLevel)
|
|
-- 2/5 klatrer til maalehoyde, lar skipet RETTE SEG OPP, setter attityde-referanse i lufta
|
|
-- 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 function runSigntest()
|
|
local s, est = cfg.signtest, Estimator.new(cfg)
|
|
local log = Logger.new("signtest.log", "# signtest: t, phase, fore, aft, height, pitch, vVel, pitchRate")
|
|
signtestAirborne = false
|
|
local h0 = sensors.readAltitude()
|
|
if not h0 then print("No altitude sensor."); return false end
|
|
|
|
-- ett-ballong-skip: ingen differensiell pitch-autoritet -> testen er meningsloes
|
|
if not cfg.aftSide then
|
|
print("Single-balloon ship (no AFT side wired).")
|
|
print("Pitch can't be steered differentially, so")
|
|
print("the sign test does not apply. Marking done.")
|
|
local c = loadCalib(); c.pitchSign = 1; c.signMeasured = true; saveCalib(c)
|
|
print(""); print("[Enter]"); read()
|
|
return true
|
|
end
|
|
|
|
local t0 = now()
|
|
local hover = nil -- laeres i trappa (1/4), trimmes videre av fart-holdet
|
|
local levelDiff = 0 -- treg nivellering under nedstigning (naar fortegnet er kjent)
|
|
|
|
-- MULTI-AKSE maaling: vi VET ikke hvilken gimbal-vinkel som er pitch paa DETTE skipet
|
|
-- (avhenger av byggeretning!). Derfor maales rate-responsen paa ALLE aksene, og aksen
|
|
-- som faktisk svarer paa dyttet blir pitchIndex. Orientering blir dermed likegyldig.
|
|
local NAX = 0 -- antall gimbal-akser (settes ved foerste lesing, maks 3)
|
|
local axRings = {} -- per-akse (t, vinkel)-ring -> rate via least-squares slope
|
|
local a0, lastAx = nil, nil -- vinkler ved start (tilt-referanse) + siste lesing
|
|
|
|
local function readAx()
|
|
local a = sensors.readAngles()
|
|
if type(a) ~= "table" or type(a[1]) ~= "number" then return nil end
|
|
if NAX == 0 then
|
|
NAX = math.min(3, #a)
|
|
for i = 1, NAX do axRings[i] = utils.newRing(1.0) end
|
|
end
|
|
return a
|
|
end
|
|
|
|
-- Attityde-referansen settes AaLDRI paa bakken: et skip bygget for aa FLY staar ikke stoett,
|
|
-- det ligger gjerne paa siden. Den attityden er meningsloes -- og oppdriftens egen oppretting
|
|
-- etter avgang ville blitt lest som baade "tilt" og "rate-respons". Vi setter referansen
|
|
-- FOERST naar skipet har rettet seg opp i lufta og ratene har roet seg.
|
|
local function setAttitudeRef()
|
|
if not lastAx then return end
|
|
a0 = {}
|
|
for i = 1, NAX do a0[i] = lastAx[i] end
|
|
end
|
|
|
|
local function axRates()
|
|
local r = {}
|
|
for i = 1, NAX do r[i] = axRings[i]:slope() end
|
|
return r
|
|
end
|
|
|
|
-- stoerste vinkelavvik fra start-attityden paa NOEN akse (fortegns-/akse-uavhengig tilt-vakt)
|
|
local function maxTilt()
|
|
if not (lastAx and a0) then return 0 end
|
|
local m = 0
|
|
for i = 1, NAX do
|
|
local v = lastAx[i]
|
|
if type(v) == "number" then
|
|
local d = math.abs(v - a0[i])
|
|
if d > m then m = d end
|
|
end
|
|
end
|
|
return m
|
|
end
|
|
|
|
local function tick(fore, aft, phase)
|
|
local f, oa = driveValves(fore, aft)
|
|
local h, t = sensors.readAltitude(), now()
|
|
local ax = readAx()
|
|
if ax then
|
|
lastAx = ax
|
|
for i = 1, NAX do
|
|
local v = ax[i]
|
|
if type(v) == "number" and v == v then axRings[i]:push(t, v) end
|
|
end
|
|
end
|
|
local p = ax and ax[cfg.pitchIndex or 1] or nil
|
|
if h and p then est:update(t, h, p) end
|
|
log:row({ t - t0, phase, f, oa, est.altitude, est.pitch, est.verticalVelocity, est.pitchRate })
|
|
sleep(cfg.dt)
|
|
end
|
|
|
|
local function hud(step, title, lines)
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== SIGN TEST " .. step .. " ====")
|
|
print(title)
|
|
for _, l in ipairs(lines or {}) do print(l) end
|
|
print("")
|
|
print(string.format("height +%.1f pitch %+.1f vVel %+.2f",
|
|
est.altitude - h0, est.pitch, est.verticalVelocity))
|
|
print("CTRL+T = abort (holds lift, no freefall)")
|
|
end
|
|
|
|
-- fart-P-hold rundt laert hover (+ hover-trim), med valgfri differanse (fore - aft).
|
|
-- Trim-rate 0.12 = samme verdi som Ki_climb (verifisert stabil for skipets ~5 s lag);
|
|
-- 0.05 var for tregt -- skipet rakk aa drive ut av maalebaandet foer trimmen tok igjen.
|
|
local function velTick(vDes, diff, phase)
|
|
hover = clamp(hover + 0.12 * (vDes - est.verticalVelocity) * cfg.dt, cfg.absFloor, cfg.maxSignal - 0.5)
|
|
local u = hover + clamp(1.2 * (vDes - est.verticalVelocity), -1.5, 1.5)
|
|
local f, a = mixer.preserveDifferenceClamp(u + diff, u - diff, cfg.absFloor, cfg.maxSignal)
|
|
tick(f, a, phase)
|
|
end
|
|
|
|
-- kontrollert nedstigning + hover-hold -- den ENESTE maaten vi forlater lufta paa
|
|
local function descendAndHold(pSign)
|
|
local tD = now()
|
|
while est.altitude > h0 + 3 and now() - tD < 120 do
|
|
if pSign ~= 0 then
|
|
levelDiff = clamp(levelDiff - pSign * 0.2 * est.pitch * cfg.dt, -1.5, 1.5)
|
|
end
|
|
velTick(-0.4, levelDiff, "descend")
|
|
hud("5/5", "Coming back down...", {
|
|
string.format("descending to +3 hover ~%.1f", hover),
|
|
string.format("trim %+.2f holding it level", levelDiff),
|
|
})
|
|
end
|
|
local tS = now()
|
|
while now() - tS < 3.0 do velTick(0, levelDiff, "settle") end
|
|
driveValves(hover, hover) -- bli staaende paa laert hover (aldri null = stup)
|
|
end
|
|
|
|
-- felles avbrudd: luftbaaren -> kontrollert ned foerst; paa bakken -> bare av
|
|
local function bail(msg)
|
|
if signtestAirborne then descendAndHold(0) else allOff() end
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== SIGN TEST: STOPPED ====")
|
|
print(msg)
|
|
if signtestAirborne then print(""); print("Ship is holding hover near the ground.") end
|
|
print(""); print("[Enter]"); read()
|
|
return false
|
|
end
|
|
|
|
local function abortMsg(code)
|
|
if code == "time" then
|
|
return "Ran out of time -- could not hold a\nsteady measuring height. Check burner\nfuel / ship weight and try again."
|
|
end
|
|
return "Aborted."
|
|
end
|
|
|
|
-- ---- 1/4: trappe seg luftbaaren (ingen fast liftLevel -- vi SOEKER effekten) ----
|
|
local cmd, stepT, riseHold, tLift = cfg.absFloor, now(), 0, now()
|
|
while true do
|
|
tick(cmd, cmd, "lift")
|
|
hud("1/5", "Lifting off (auto power search)...", {
|
|
string.format("power %.1f / %d", cmd, cfg.maxSignal),
|
|
})
|
|
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 now() - stepT >= 4.0 then
|
|
if cmd >= cfg.maxSignal then
|
|
return bail("No lift even at max power.\nCheck wiring (menu: Wiring) and that the\nburners have fuel.")
|
|
end
|
|
cmd = math.min(cfg.maxSignal, cmd + 0.5); stepT = now()
|
|
end
|
|
if now() - tLift > 120 then return bail("Timed out lifting off.") end
|
|
end
|
|
hover = math.max(cfg.absFloor, cmd - 0.25) -- kryssingen er ~her: foerste hover-estimat
|
|
signtestAirborne = true
|
|
|
|
-- ---- 2/4: klatre til maalehoyde og SETTLE (hover-trimmen laerer ekte hover) ----
|
|
-- SUPERVISOR-prinsipp: hoyden overvaakes gjennom hele maalefasen. Driver skipet ut av
|
|
-- baandet -> fly tilbake til maalehoyden og proev maalingen igjen (selv-reparerende).
|
|
-- Bare EN total-deadline kan felle testen -- ikke haartrigger-aborter paa drift.
|
|
local BAND_LO, BAND_HI, BAND_TARGET = 4, 25, 12
|
|
local deadline = now() + 420 -- hele testen ferdig innen 7 min, ellers ryddig bail
|
|
|
|
local function recenter(step, reason)
|
|
while now() < deadline do
|
|
local dh = est.altitude - (h0 + BAND_TARGET)
|
|
if math.abs(dh) < 1.5 then return true end
|
|
velTick(dh > 0 and -0.4 or 0.4, levelDiff, "recenter")
|
|
hud(step, "Flying to measuring height (" .. reason .. ")", {
|
|
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()),
|
|
})
|
|
end
|
|
return false, "time"
|
|
end
|
|
|
|
local okC, cErr = recenter("2/5", "initial climb")
|
|
if not okC then return bail(abortMsg(cErr)) end
|
|
|
|
-- La skipet RETTE SEG OPP i lufta foer vi maaler noe som helst. Et flybygget skrog ligger
|
|
-- gjerne paa siden paa bakken; oppdriften reiser det opp av seg selv naar det kommer i lufta.
|
|
-- Vi venter til ALLE akse-ratene har roet seg -- og setter FOERST DA attityde-referansen.
|
|
local tR, calmR, worst = now(), 0, 0
|
|
while now() - tR < 120 and now() < deadline do
|
|
velTick(0, 0, "righting")
|
|
worst = 0
|
|
for i = 1, NAX do worst = math.max(worst, math.abs(axRings[i]:slope())) end
|
|
if worst < 0.5 then calmR = calmR + cfg.dt else calmR = 0 end
|
|
hud("2/5", "Letting the ship right itself...", {
|
|
"(a flight-built hull lies over on the ground;",
|
|
" buoyancy levels it once airborne)",
|
|
string.format("worst rate %.2f deg/s steady %.1f/5.0 s", worst, calmR),
|
|
string.format("hover ~%.2f", hover),
|
|
})
|
|
if calmR >= 5.0 then break end
|
|
local rel = est.altitude - h0
|
|
if rel < BAND_LO or rel > BAND_HI then
|
|
local okD, dErr = recenter("2/5", "altitude drift")
|
|
if not okD then return bail(abortMsg(dErr)) end
|
|
end
|
|
end
|
|
setAttitudeRef() -- NAA er referansen meningsfull: skipets faktiske flyattityde
|
|
local settled = {}
|
|
for i = 1, NAX do settled[i] = (lastAx and lastAx[i]) or 0 end
|
|
|
|
-- rate-stabil maaling PER AKSE: hold hoyden (vDes=0) med gitt differanse til ratene roer
|
|
-- seg. Returnerer tabell {avgRate per akse}. Lag-bevisst: minHold >= loft-etterslepet.
|
|
local function measureRate(diff, step, title, minHold, maxWait)
|
|
local tS, win = now(), {}
|
|
local avg = nil
|
|
while now() - tS < maxWait do
|
|
velTick(0, diff, title)
|
|
win[#win + 1] = { t = now(), r = axRates() }
|
|
while #win > 1 and (now() - win[1].t) > 1.5 do table.remove(win, 1) end
|
|
avg = {}
|
|
local spanMax = 0
|
|
for i = 1, NAX do
|
|
local sum, lo, hi = 0, math.huge, -math.huge
|
|
for _, e in ipairs(win) do
|
|
local v = e.r[i]
|
|
sum = sum + v; lo = math.min(lo, v); hi = math.max(hi, v)
|
|
end
|
|
avg[i] = sum / #win
|
|
local sp = (#win > 1) and (hi - lo) or 99
|
|
if sp > spanMax then spanMax = sp end
|
|
end
|
|
local rline = ""
|
|
for i = 1, NAX do rline = rline .. string.format(" a%d %+.2f", i, avg[i]) end
|
|
hud(step, title, {
|
|
string.format("diff %+.0f rates:%s", diff, rline),
|
|
string.format("calm when spread < %.1f (now %.2f)", s.rateCalm, spanMax),
|
|
})
|
|
-- INGEN tilt-abort: skjevhet er maalingens formaal. Har raten allerede svart tydelig,
|
|
-- slutter vi likevel aa dytte tidlig -- vi har det vi kom for, unoedig aa tippe mer.
|
|
if maxTilt() > s.maxTiltAbort and #win >= 5 then return avg end
|
|
local rel = est.altitude - h0
|
|
if rel < BAND_LO or rel > BAND_HI then return nil, "band" end
|
|
if now() > deadline then return nil, "time" end
|
|
if now() - tS >= minHold and #win >= 5 and spanMax < s.rateCalm then return avg end
|
|
end
|
|
return avg -- timeout: retningen er som regel riktig selv om roen aldri kom
|
|
end
|
|
|
|
-- selv-reparerende maaling: hoyde-drift -> re-sentrer og proev IGJEN i stedet for abort
|
|
local function measureSafe(diff, title, minHold, maxWait)
|
|
while true do
|
|
local r, code = measureRate(diff, "3/5", title, minHold, maxWait)
|
|
if r then return r end
|
|
if code ~= "band" then return nil, code end
|
|
local okR, rErr = recenter("3/5", "altitude drift")
|
|
if not okR then return nil, rErr end
|
|
end
|
|
end
|
|
|
|
-- ---- 3/5: GYNGE-PROBE -- front-dytt <-> bak-dytt; aksen som SVARER blir pitchIndex ----
|
|
-- d = rate(+dytt) - rate(-dytt) trenger INGEN baseline: en konstant skjev-drift finnes i
|
|
-- begge leddene og kanselleres eksakt. To sykluser som er ENIGE om fortegnet = verifisert.
|
|
local nudge = s.nudge
|
|
local maxNudge = math.max(s.nudge, math.floor(cfg.maxSignal / 2) + 1)
|
|
local pick = nil
|
|
for attempt = 1, 3 do
|
|
local ds = {}
|
|
for c = 1, 2 do
|
|
local tag = string.format(" (cycle %d/2, push %d)", c, nudge)
|
|
local rF, eF = measureSafe(nudge, "Rocking: push FRONT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
|
|
if not rF then return bail(abortMsg(eF)) end
|
|
local rA, eA = measureSafe(-nudge, "Rocking: push AFT" .. tag, s.nudgeMinHold, s.nudgeMaxWait)
|
|
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
|
|
local d1, d2 = ds[1][i], ds[2][i]
|
|
local sg = utils.sign(d1)
|
|
local mag = (math.abs(d1) + math.abs(d2)) / 2
|
|
if sg ~= 0 and utils.sign(d2) == sg and mag >= s.drMin then
|
|
if not pick or mag > pick.mag then pick = { axis = i, sign = sg, mag = mag } end
|
|
end
|
|
end
|
|
if pick then break end
|
|
nudge = math.min(maxNudge, nudge + 1) -- auto: proev sterkere dytt
|
|
end
|
|
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 spaced along the hull.")
|
|
end
|
|
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) ----
|
|
local c = loadCalib()
|
|
c.pitchSign, c.pitchGain, c.alphaPitch = sign, 1.0, 1.0
|
|
c.pitchIndex = axis -- MAALT: aksen som faktisk svarte paa dyttet
|
|
c.pitchTrim = 2 * levelTrim -- diff -> D_des-enheter (mixer: front = u + D/2)
|
|
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.signMeasured = true -- profilen er komplett (wizard/standby sjekker denne)
|
|
saveCalib(c)
|
|
|
|
-- ---- 5/5: kontrollert ned + hold hover (nivellert med den nylaerte trimmen) ----
|
|
descendAndHold(sign)
|
|
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== SIGN TEST: SUCCESS ====")
|
|
print(string.format("pitch = gimbal axis a%d, sign %+d", axis, sign))
|
|
local sline = ""
|
|
for i = 1, NAX do sline = sline .. string.format(" a%d %+.0f", i, settled[i]) end
|
|
print("airborne attitude:" .. sline)
|
|
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
|
|
if math.abs(settled[axis]) > 30 then
|
|
print("")
|
|
print("WARNING: it settled far from level even")
|
|
print("with balanced lift -- the hull may not be")
|
|
print("righting fully. Check balloon spacing.")
|
|
end
|
|
print(string.format("hover ~%.1f (saved as baseline)", hover))
|
|
print("")
|
|
print("Ship is holding hover. Ready to FLY.")
|
|
print(""); print("[Enter]"); read()
|
|
return true
|
|
end
|
|
|
|
-- ============================ WIRING SETUP ============================
|
|
-- Probe tilkoblede ting + la brukeren identifisere hvilken redstone-side som driver FRONT- og
|
|
-- BAK-ballongen. Redstone-links er IKKE peripheraler (CC kan ikke "se" dem), saa vi pulser hver
|
|
-- ledige side og spoer hvilken brenner som taentes. Lagres til sides.txt (overstyrer config).
|
|
local function allSidesOff() for _, s in ipairs(SIDES) do redstone.setAnalogOutput(s, 0) end end
|
|
|
|
local function runWiring()
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== WIRING SETUP ====")
|
|
print("(best on the ground -- it pulses the burners)")
|
|
print("")
|
|
print("Connected per side:")
|
|
local linkSides = {}
|
|
for _, side in ipairs(SIDES) do
|
|
if peripheral.isPresent(side) then
|
|
print(string.format(" %-7s peripheral: %s", side, tostring(peripheral.getType(side))))
|
|
else
|
|
print(string.format(" %-7s free (possible redstone link)", side))
|
|
linkSides[#linkSides + 1] = side
|
|
end
|
|
end
|
|
print("")
|
|
print("Redstone links aren't detectable, so I PULSE each free side --")
|
|
print("watch which balloon's burner lights up.")
|
|
print("[Enter] = pulse-walk | type 'm' = assign manually")
|
|
local choice = read()
|
|
|
|
local fore, aft = nil, nil
|
|
if choice == "m" or choice == "M" then
|
|
repeat write("FRONT balloon side: "); fore = read() until validSide(fore)
|
|
write("AFT balloon side (Enter = none): "); local a = read()
|
|
aft = (a ~= "" and validSide(a)) and a or nil
|
|
else
|
|
local level = math.min(cfg.maxSignal, 8)
|
|
for _, side in ipairs(linkSides) do
|
|
while true do -- [r] pulser samme side igjen -- lett aa gaa glipp av et blaff
|
|
term.clear(); term.setCursorPos(1, 1); print("==== WIRING SETUP: pulsing ====")
|
|
print(string.format("Pulsing '%s' at %d -- WATCH THE BURNERS...", side, level))
|
|
redstone.setAnalogOutput(side, level); sleep(3.0); redstone.setAnalogOutput(side, 0)
|
|
write(string.format("'%s' fired: [f]ront / [a]ft / [n]one / [r]=again: ", side))
|
|
local a = read():lower()
|
|
if a == "f" then fore = side; break
|
|
elseif a == "a" then aft = side; break
|
|
elseif a ~= "r" then break end
|
|
end
|
|
end
|
|
end
|
|
allSidesOff()
|
|
|
|
term.clear(); term.setCursorPos(1, 1); print("==== WIRING RESULT ====")
|
|
print("FRONT side: " .. tostring(fore))
|
|
print("AFT side: " .. tostring(aft or "none (single balloon)"))
|
|
if not fore then
|
|
print("")
|
|
print("No FRONT side identified -- NOT saving. Re-run.")
|
|
else
|
|
saveSides({ fore = fore, aft = aft })
|
|
cfg.foreSide, cfg.aftSide = fore, aft -- bruk umiddelbart denne oekten
|
|
print(""); print(">>> Saved to sides.txt and applied.")
|
|
end
|
|
print(""); print("[Enter]"); read()
|
|
return fore ~= nil
|
|
end
|
|
|
|
-- ============================ MANUAL (Lag 2) ============================
|
|
local function runManual(foreArg, aftArg)
|
|
local est, c = Estimator.new(cfg), loadCalib()
|
|
local fore = clamp(round(tonumber(foreArg) or c.uHoverFront), 0, cfg.maxSignal)
|
|
local aft = clamp(round(tonumber(aftArg) or c.uHoverRear), 0, cfg.maxSignal)
|
|
local log = Logger.new("manual.log", "# manual: t, fore, aft, height, pitch, vVel, pitchRate")
|
|
local t0 = now()
|
|
local function render()
|
|
local h, p = sensors.readAltitude(), sensors.readPitch()
|
|
if h and p then est:update(now(), h, p) end
|
|
driveValves(fore, aft)
|
|
log:row({ now() - t0, fore, aft, est.altitude, est.pitch, est.verticalVelocity, est.pitchRate })
|
|
term.clear(); term.setCursorPos(1, 1); print("==== MANUAL (Layer 2) ====")
|
|
print(string.format("fore %d aft %d (max %d)", fore, aft, cfg.maxSignal))
|
|
print(string.format("Height %8.2f vVel %6.2f b/s", est.altitude, est.verticalVelocity))
|
|
print(string.format("Pitch %8.2f pRate %6.2f deg/s", est.pitch, est.pitchRate))
|
|
print(""); print("UP/DOWN both LEFT/RIGHT front W/S rear SPACE off Q quit")
|
|
end
|
|
render()
|
|
local timer = os.startTimer(cfg.dt)
|
|
while true do
|
|
local ev = { os.pullEvent() }
|
|
if ev[1] == "timer" and ev[2] == timer then render(); timer = os.startTimer(cfg.dt)
|
|
elseif ev[1] == "key" then
|
|
local k = ev[2]
|
|
if k == keys.up then fore = fore + 1; aft = aft + 1
|
|
elseif k == keys.down then fore = fore - 1; aft = aft - 1
|
|
elseif k == keys.right then fore = fore + 1 elseif k == keys.left then fore = fore - 1
|
|
elseif k == keys.w then aft = aft + 1 elseif k == keys.s then aft = aft - 1
|
|
elseif k == keys.space then fore = 0; aft = 0 elseif k == keys.q then break end
|
|
fore = clamp(fore, 0, cfg.maxSignal); aft = clamp(aft, 0, cfg.maxSignal); render()
|
|
end
|
|
end
|
|
allOff(); print("Manual ended. Valves off.")
|
|
end
|
|
|
|
-- ============================ CALIBRATE (Lag 3) ============================
|
|
local function doCalibrate()
|
|
print("CALIBRATION. Open sky above. It lifts off and learns by itself.")
|
|
print("CTRL+T aborts (holds last command). Starting in 2s..."); sleep(2.0)
|
|
local calib, msg = calibration.run(cfg, sensors, driveValves)
|
|
if calib then
|
|
saveCalib(calib)
|
|
driveValves(calib.uHoverFront, calib.uHoverRear)
|
|
end
|
|
return calib, msg
|
|
end
|
|
local function runCal()
|
|
local calib, msg = doCalibrate()
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
if calib then
|
|
print("==== CALIBRATION DONE (saved) ====")
|
|
print(string.format("uHoverFront %.2f uHoverRear %.2f", calib.uHoverFront, calib.uHoverRear))
|
|
print(string.format("lambda %.3f g %.3f", calib.lambda, calib.g))
|
|
print(string.format("pitchSign %d pitchGain %.3f", calib.pitchSign, calib.pitchGain))
|
|
if calib.pitchResidual and math.abs(calib.pitchResidual) > 5 then
|
|
print(string.format("!! residual pitch %.1f deg -- limited diff authority", calib.pitchResidual))
|
|
end
|
|
print("Holding hover (valves stay set).")
|
|
else
|
|
print("==== CALIBRATION ABORTED ====\nReason: " .. tostring(msg))
|
|
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear)
|
|
end
|
|
print(""); print("[Enter]"); read()
|
|
end
|
|
|
|
-- ============================ OBSERVE (Lag 4, ÅPEN SLØYFE) ============================
|
|
-- Driver et bundet opp/ned-profil (observeren styrer IKKE) og logger v_hat mot v_meas + L_hat.
|
|
-- Slik validerer/tuner vi observeren: leder estimatet, henger det etter, eller svinger det?
|
|
local function runObserve()
|
|
if not fs.exists(CALIB_FILE) then print("No calibration. Run 'cal' first."); print("[Enter]"); read(); return end
|
|
local calib = loadCalib()
|
|
local est, obs = Estimator.new(cfg), Observer.new(cfg, calib)
|
|
local log = Logger.new("observe.log", "# observe: t, cmd, height, v_meas, v_hat, L_hat, bias, e_v")
|
|
print("OBSERVE (open-loop). Observer watches, does NOT steer. CTRL+T to stop."); sleep(1.0)
|
|
local t0, lastT, phaseT, phase = now(), now(), now(), 0
|
|
while true do
|
|
local t = now(); local dt = t - lastT; lastT = t
|
|
if dt <= 0 or dt > 1 then dt = cfg.dt end
|
|
if now() - phaseT > 12 then phase = (phase + 1) % 2; phaseT = now() end
|
|
local vDes = (phase == 0) and 0.4 or -0.4
|
|
local cmd = velHoldCommon(calib, vDes, est.verticalVelocity)
|
|
driveValves(cmd, cmd)
|
|
local h, p = sensors.readAltitude(), sensors.readPitch()
|
|
if h and p then est:update(t, h, p) end
|
|
local e_v = obs:step(cmd, cmd, est.verticalVelocity, dt, false)
|
|
local Lh = obs:L_hat()
|
|
log:row({ t - t0, cmd, est.altitude, est.verticalVelocity, obs.v_hat, Lh, obs.bias, e_v })
|
|
term.clear(); term.setCursorPos(1, 1); print("==== OBSERVE (Layer 4, open-loop) ====")
|
|
print(string.format("cmd %.2f (vDes %+.1f)", cmd, vDes))
|
|
print(string.format("v_meas %6.2f v_hat %6.2f e_v %+.2f", est.verticalVelocity, obs.v_hat, e_v))
|
|
print(string.format("L_hat %6.3f bias %+.3f", Lh, obs.bias))
|
|
print(string.format("height %.1f", est.altitude))
|
|
print("")
|
|
print("Good: v_hat tracks v_meas (leads slightly, no lag/oscillation).")
|
|
print("If v_hat lags -> raise Kf/lambda. If it oscillates -> lower Kf.")
|
|
sleep(cfg.dt)
|
|
end
|
|
end
|
|
|
|
-- ============================ FLY (Lag 5-8, LUKKET SLØYFE) ============================
|
|
local function runFly()
|
|
local calib = loadCalib()
|
|
-- v3 trenger IKKE den skjore staircase-kalibreringen lenger (hoyde er g-fri, pitch self-learning).
|
|
-- Det eneste profilen maa ha er pitchSign (fra Sign test) + riktig wiring. Mangler den -> veiled.
|
|
if not fs.exists(CALIB_FILE) then
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("No profile for this ship yet.")
|
|
print("")
|
|
print("Run the setup wizard first:")
|
|
print(" ap setup (or menu choice 1)")
|
|
print("")
|
|
print("It maps the wiring and measures the pitch")
|
|
print("sign in one short guided auto-flight.")
|
|
print("Pitch & altitude self-learn from there.")
|
|
print(""); print("[Enter]"); read(); return
|
|
end
|
|
|
|
local launchAlt = sensors.readAltitude() or 0
|
|
local target = loadTarget() or (launchAlt + cfg.takeoffOffset)
|
|
local est = Estimator.new(cfg)
|
|
local obs = Observer.new(cfg, calib)
|
|
local div = safety.newDivergence(cfg.evDivergeAlpha)
|
|
local log = Logger.new("fly.log",
|
|
"# fly: t, state, height, target, pitch, v_meas, v_hat, L_front, L_rear, L_hat, u_sum, bias, fore, aft, e_v, iTrim, hTrim")
|
|
print(string.format("FLY -> target %.1f (sign %d). CTRL+T to stop.", target, calib.pitchSign)); sleep(1.0)
|
|
|
|
local pctl = pitch.new(cfg, calib) -- self-learning pitch-kontroller (integral-trim)
|
|
local actl = altitude.new(cfg, calib) -- self-learning g-fri hoyde-kontroller
|
|
local state, reason, lastT, t0 = "FLYING", "", now(), now()
|
|
local prevSat, lastTrimSave = false, now()
|
|
|
|
-- pilot-lenke: kringkast status + ta imot enkle kommandoer fra pilot-konsollen (om modem finnes)
|
|
if link.open() then print("Pilot link: ON (rednet)") else print("Pilot link: none (solo)") end
|
|
local stopReq, mode = false, "fly" -- mode = siste pilot-intensjon: fly / hold / land
|
|
local function handleCmd(msg)
|
|
if type(msg) ~= "table" then return end
|
|
if msg.cmd == "target" and tonumber(msg.value) then target = tonumber(msg.value); saveTarget(target); mode = "fly"
|
|
elseif msg.cmd == "adjust" and tonumber(msg.delta) then target = target + tonumber(msg.delta); saveTarget(target); mode = "fly"
|
|
elseif msg.cmd == "hold" then target = est.altitude; saveTarget(target); mode = "hold"
|
|
elseif msg.cmd == "land" then target = launchAlt + 3; saveTarget(target); mode = "land"
|
|
elseif msg.cmd == "set" then applySetting(msg.key, msg.value, true)
|
|
elseif msg.cmd == "stop" then stopReq = true end
|
|
end
|
|
-- vent ~dt sekunder MEN behandle innkommende kommandoer underveis (ikke-blokkerende styring)
|
|
local function serve(secs)
|
|
local timer = os.startTimer(secs)
|
|
while true do
|
|
local e1, e2, e3, e4 = os.pullEvent()
|
|
if e1 == "timer" and e2 == timer then return
|
|
elseif e1 == "rednet_message" and e4 == link.CMD then handleCmd(e3) end
|
|
end
|
|
end
|
|
|
|
while true do
|
|
local t = now(); local dt = t - lastT; lastT = t
|
|
if dt <= 0 or dt > 1 then dt = cfg.dt end
|
|
local h, p = sensors.readAltitude(), sensors.readPitch()
|
|
local valid = safety.sensorOk(h, p)
|
|
if valid then est:update(t, h, p) end
|
|
|
|
-- pitch runaway-vakt: aldri la skipet tippe ukontrollert (siste skanse, fortegns-uavhengig)
|
|
if valid and state == "FLYING" and math.abs(est.pitch) > cfg.pitchFaultDeg then
|
|
state = "FAULT"; reason = "pitch out of range"
|
|
end
|
|
|
|
local frontHeat, rearHeat, sat, diag, u_diff = nil, nil, true, {}, 0
|
|
if state == "FLYING" and valid then
|
|
local lowAlt = (est.altitude - launchAlt) < cfg.lowAltCaution
|
|
local uFc, uRc, ad = actl:compute(est.altitude, target, est.verticalVelocity, dt, prevSat, lowAlt)
|
|
u_diff = pctl:compute(est.pitch, est.pitchRate, dt, prevSat)
|
|
if not cfg.aftSide then u_diff = 0 end -- ett-ballong-skip: ingen differensiell autoritet
|
|
local floor
|
|
frontHeat, rearHeat, floor = mixer.mix(cfg, calib, uFc, uRc, u_diff)
|
|
sat = (frontHeat <= floor + 0.01 or frontHeat >= cfg.maxSignal - 0.01
|
|
or rearHeat <= floor + 0.01 or rearHeat >= cfg.maxSignal - 0.01)
|
|
diag = ad
|
|
else
|
|
-- FAULT eller ugyldig sensor: HOLD hover-baseline (aldri null = stup)
|
|
frontHeat, rearHeat = safety.holdOutputs(calib)
|
|
end
|
|
|
|
local of, oa = driveValves(frontHeat, rearHeat)
|
|
|
|
-- observer oppdateres med FAKTISK kommandert (float) loft + maalt fart
|
|
local e_v = 0
|
|
if valid then
|
|
e_v = obs:step(frontHeat, rearHeat, est.verticalVelocity, dt, sat)
|
|
local evAvg = div:update(e_v)
|
|
if evAvg > cfg.evDivergeFault then state = "FAULT"; reason = "observer divergence"
|
|
elseif state == "FAULT" and evAvg < cfg.evDivergeFault * 0.5
|
|
and math.abs(est.pitch) < cfg.pitchFaultDeg * 0.6 and reason ~= "" then state = "FLYING" end
|
|
else
|
|
state = "FAULT"; reason = "sensor invalid"
|
|
end
|
|
|
|
log:row({ t - t0, state, est.altitude, target, est.pitch, est.verticalVelocity, obs.v_hat,
|
|
obs.L_front, obs.L_rear, obs:L_hat(), diag.u_sum or 0, obs.bias, of, oa, e_v, pctl.iTrim, actl.uTrim })
|
|
|
|
-- persister laerte trims som warm-start (self-updating; taaler restart + last-endring)
|
|
if now() - lastTrimSave > 10 then
|
|
calib.pitchTrim = pctl.iTrim; calib.hoverTrim = actl.uTrim; saveCalib(calib); lastTrimSave = now()
|
|
end
|
|
prevSat = sat
|
|
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
if state == "FAULT" then
|
|
print("!!!! FAULT: " .. reason .. " !!!!")
|
|
print("Holding hover baseline (NOT zero). Take manual control / recalibrate.")
|
|
else
|
|
print(diag.hold and "==== FLY: HOLDING (band) ====" or "==== FLY: moving to band ====")
|
|
end
|
|
print(string.format("Height %7.1f / %.0f (e %+.1f)", est.altitude, target, target - est.altitude))
|
|
print(string.format("Pitch %7.1f pRate %+.1f trim %+.2f", est.pitch, est.pitchRate, pctl.iTrim))
|
|
print(string.format("v_meas %6.2f v_hat %6.2f (e_v %+.2f)", est.verticalVelocity, obs.v_hat, e_v))
|
|
print(string.format("u_sum %.1f hTrim %+.2f vDes %+.2f", diag.u_sum or 0, actl.uTrim, diag.v_des or 0))
|
|
print(string.format("fore %2d aft %2d", of, oa))
|
|
|
|
-- kringkast status til pilot-konsollen
|
|
link.publishStatus({
|
|
state = state, reason = reason, height = est.altitude, target = target,
|
|
pitch = est.pitch, vVel = est.verticalVelocity, pitchRate = est.pitchRate,
|
|
uSum = diag.u_sum or 0, hold = diag.hold or false, mode = mode,
|
|
iTrim = pctl.iTrim, hTrim = actl.uTrim, fore = of, aft = oa,
|
|
climbSpeed = cfg.vMaxUp, holdBand = cfg.altDeadband, maxPower = cfg.maxSignal,
|
|
})
|
|
|
|
serve(cfg.dt) -- vent dt + behandle pilot-kommandoer (erstatter sleep)
|
|
if stopReq then print("Pilot STOP -- holding hover, back to standby."); break end
|
|
end
|
|
end
|
|
|
|
-- ============================ SETUP-WIZARD (foerstegang / nytt skip) ============================
|
|
-- ETT stoppested for alt skip-spesifikt: wiring (hvilken side driver hvilken ballong) + sign test
|
|
-- (kort selvgaaende flytur som maaler pitch-fortegn og ~hover). Etterpaa er skipet klart til FLY.
|
|
local function runSetup()
|
|
signtestAirborne = false -- nullstill foer wizarden (ellers kan cleanup arve forrige kjoering)
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== SHIP SETUP WIZARD ====")
|
|
print("")
|
|
print("Two steps, fully guided:")
|
|
print(" 1) Wiring -- map front/aft burner sides")
|
|
print(" 2) Sign test -- short auto-flight that")
|
|
print(" measures pitch response + balance")
|
|
print("")
|
|
print("Just needs open sky above. It's FINE if the")
|
|
print("ship lies over on the ground -- it rights")
|
|
print("itself airborne, and nothing is measured")
|
|
print("until it has.")
|
|
print("")
|
|
write("[Enter] = start, q = cancel: ")
|
|
if read():lower() == "q" then return end
|
|
|
|
if not runWiring() then
|
|
print("Wiring was not saved -- wizard stopped."); print("[Enter]"); read(); return
|
|
end
|
|
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== STEP 2: SIGN TEST ====")
|
|
print("")
|
|
print("The ship lifts off BY ITSELF, waits until")
|
|
print("it has righted and steadied in the air,")
|
|
print("rocks front/aft to find the pitch axis,")
|
|
print("learns the trim that levels it, then comes")
|
|
print("back down to hover. Takes 2-4 minutes.")
|
|
print("")
|
|
write("[Enter] = lift off, q = cancel: ")
|
|
if read():lower() == "q" then return end
|
|
runSigntest()
|
|
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
if profileComplete() then
|
|
print("==== SETUP COMPLETE ====")
|
|
print("")
|
|
print("Profile saved. The ship is ready:")
|
|
print(" - FLY from the menu, or")
|
|
print(" - standby + ENGAGE from the cockpit")
|
|
else
|
|
print("==== SETUP INCOMPLETE ====")
|
|
print("")
|
|
print("Sign test did not finish -- run the")
|
|
print("wizard again (menu choice 1).")
|
|
end
|
|
print(""); print("[Enter]"); read()
|
|
end
|
|
|
|
-- ============================ STANDBY (pilot-fjernstart) ============================
|
|
-- Lytter etter ENGAGE fra pilot-konsollen og starter fly. Kringkaster IDLE-heartbeat slik at
|
|
-- konsollen vet at maskinrommet er online og klart. Default etter boot; [S] aapner setup-wizarden.
|
|
local function awaitCmd(secs)
|
|
local timer = os.startTimer(secs)
|
|
while true do
|
|
local e1, e2, e3, e4 = os.pullEvent()
|
|
if e1 == "timer" and e2 == timer then return nil
|
|
elseif e1 == "char" and (e2 == "s" or e2 == "S") then return "setup"
|
|
elseif e1 == "rednet_message" and e4 == link.CMD and type(e3) == "table" then
|
|
if e3.cmd == "engage" or e3.cmd == "stop" then return e3.cmd end
|
|
if e3.cmd == "set" then applySetting(e3.key, e3.value, true) end -- juster innstillinger i standby
|
|
-- target/hold/land ignoreres mens vi staar i standby
|
|
end
|
|
end
|
|
end
|
|
|
|
local function runStandby()
|
|
while true do
|
|
-- re-proev modem hver runde: fanger et modem som plugges til mens vi staar i standby
|
|
local hasModem = link.open()
|
|
local h = sensors.readAltitude() or 0
|
|
local p = sensors.readPitch() or 0
|
|
local flyable = fs.exists(CALIB_FILE) -- kan fly (evt. eldre profil uten maalt-flagg)
|
|
local complete = profileComplete() -- wiring + faktisk MAALT pitch-fortegn
|
|
link.publishStatus({ state = "IDLE", height = h, target = loadTarget() or h, pitch = p,
|
|
vVel = 0, hold = false, ready = flyable, fore = 0, aft = 0, uSum = 0, iTrim = 0, hTrim = 0,
|
|
climbSpeed = cfg.vMaxUp, holdBand = cfg.altDeadband, maxPower = cfg.maxSignal })
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== STANDBY (pilot remote) ====")
|
|
if complete then print("Profile: READY")
|
|
elseif flyable then print("Profile: LEGACY ([S] = re-run setup)")
|
|
else print("Profile: NOT SET UP -> press [S]") end
|
|
if hasModem then print("Link: modem OK (broadcasting to cockpit)")
|
|
else print("Link: NO MODEM -- cockpit shows OFFLINE!")
|
|
print(" attach a modem to this PC") end
|
|
print(string.format("Height %.1f Pitch %+.1f", h, p))
|
|
print("Waiting for ENGAGE from the pilot console...")
|
|
print("[S] setup wizard CTRL+T then 'ap' = menu")
|
|
local cmd = awaitCmd(0.5)
|
|
if cmd == "setup" then
|
|
runSetup()
|
|
elseif cmd == "engage" then
|
|
if flyable then
|
|
runFly() -- blokkerer til pilot STOP / FAULT / CTRL+T
|
|
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear) -- hold hover etter stop
|
|
else
|
|
link.publishStatus({ state = "SETUP NEEDED", reason = "press S on autopilot PC", ready = false })
|
|
sleep(2.0)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ============================ BOOT (startup-inngang) ============================
|
|
-- Foerste boot uten komplett profil -> tilby wizarden. Timeout -> standby, slik at en ubemannet
|
|
-- reboot (chunk-reload) aldri blir staaende og vente paa tastetrykk.
|
|
local function runBoot()
|
|
if not profileComplete() then
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("==== AIRSHIP AUTOPILOT ====")
|
|
print("")
|
|
print(fs.exists(CALIB_FILE)
|
|
and "Ship profile: LEGACY (sign not measured)."
|
|
or "No ship profile on this computer yet.")
|
|
print("")
|
|
print("[Enter] = first-time setup wizard")
|
|
print("(any other key, or 10 s -> standby)")
|
|
local timer = os.startTimer(10)
|
|
while true do
|
|
local e = { os.pullEvent() }
|
|
if e[1] == "timer" and e[2] == timer then break
|
|
elseif e[1] == "key" then
|
|
if e[2] == keys.enter then runSetup() end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
runStandby()
|
|
end
|
|
|
|
-- ============================ PROFIL / MENY ============================
|
|
local function showProfile()
|
|
local c = loadCalib()
|
|
print("==== PROFILE ====")
|
|
print(string.format("uHoverFront %.2f uHoverRear %.2f", c.uHoverFront, c.uHoverRear))
|
|
print(string.format("lambda %.3f g %.3f", c.lambda, c.g))
|
|
print(string.format("pitchSign %d (%s) axis a%d", c.pitchSign,
|
|
c.signMeasured and "measured" or "DEFAULT -- run setup", c.pitchIndex or cfg.pitchIndex))
|
|
print(string.format("wiring: fore=%s aft=%s%s", cfg.foreSide, tostring(cfg.aftSide),
|
|
fs.exists(SIDES_FILE) and "" or " (config default)"))
|
|
print("calib.txt: " .. (fs.exists(CALIB_FILE) and "saved" or "none (defaults)"))
|
|
local tH = loadTarget()
|
|
print("Target: " .. (tH and string.format("%.1f", tH) or "auto (start + offset)"))
|
|
end
|
|
|
|
local function menu()
|
|
while true do
|
|
term.clear(); term.setCursorPos(1, 1)
|
|
print("===== AIRSHIP AUTOPILOT v3 =====")
|
|
print(" Setup")
|
|
print(" 1) Setup wizard (wiring + sign test)")
|
|
print(" 2) Wiring only 3) Sign test only")
|
|
print(" Fly")
|
|
print(" 4) FLY / hold 5) Standby (pilot)")
|
|
print(" 6) Set target altitude")
|
|
print(" Tools")
|
|
print(" 7) Monitor 8) Manual 9) Full calibrate")
|
|
print(" o) Observe p) Profile/reset q) Quit")
|
|
print("")
|
|
local prof = profileComplete() and "Profile: OK"
|
|
or (fs.exists(CALIB_FILE) and "Profile: LEGACY (1 = re-setup)" or "Profile: NOT SET UP -> 1")
|
|
print((sensors.present() and "Sensors: OK " or "SENSORS MISSING! ") .. prof)
|
|
print(string.format("Wiring: fore=%s aft=%s", cfg.foreSide, tostring(cfg.aftSide)))
|
|
write("Choice: ")
|
|
local k = read():lower()
|
|
if k == "1" then return "setup"
|
|
elseif k == "2" then return "wiring"
|
|
elseif k == "3" then return "signtest"
|
|
elseif k == "4" then return "fly"
|
|
elseif k == "5" then return "standby"
|
|
elseif k == "6" then
|
|
write("Target (empty = auto): "); local s = read()
|
|
if s == "" then if fs.exists(TARGET_FILE) then fs.delete(TARGET_FILE) end; print("Target cleared.")
|
|
elseif tonumber(s) then saveTarget(tonumber(s)); print("Target = " .. s) else print("Invalid number.") end
|
|
print("[Enter]"); read()
|
|
elseif k == "7" then return "monitor"
|
|
elseif k == "8" then return "manual"
|
|
elseif k == "9" then return "cal"
|
|
elseif k == "o" then return "observe"
|
|
elseif k == "p" then
|
|
term.clear(); term.setCursorPos(1, 1); showProfile()
|
|
print("\nR = reset SHIP PROFILE (new ship), Enter = back"); local a = read():lower()
|
|
if a == "r" then
|
|
for _, f in ipairs({ CALIB_FILE, SIDES_FILE, TARGET_FILE, SETTINGS_FILE }) do
|
|
if fs.exists(f) then fs.delete(f) end
|
|
end
|
|
print("Ship profile reset -- setup wizard on next boot."); print("[Enter]"); read()
|
|
end
|
|
elseif k == "q" then return "exit" end
|
|
end
|
|
end
|
|
|
|
-- ============================ DISPATCH ============================
|
|
sensors.init(cfg)
|
|
local args = { ... }
|
|
-- bruk persistert wiring (front/aft-sider) hvis satt -- overstyrer config-defaults per skip
|
|
local sv = loadSides()
|
|
if sv then cfg.foreSide = sv.fore; cfg.aftSide = sv.aft end
|
|
-- bruk MAALT gimbal-akse for pitch hvis sign-testen har funnet den (bygg-orientering varierer)
|
|
local cal0 = loadCalib()
|
|
if cal0.pitchIndex then cfg.pitchIndex = cal0.pitchIndex end
|
|
-- bruk persisterte live-innstillinger (klatre-fart / hold-baand / maks-effekt)
|
|
local sset = loadSettings()
|
|
if sset then
|
|
applySetting("climbSpeed", sset.climbSpeed, false)
|
|
applySetting("holdBand", sset.holdBand, false)
|
|
applySetting("maxPower", sset.maxPower, false)
|
|
end
|
|
if not sensors.present() then print("Sensors missing (altitude_sensor + gimbal_sensor)."); return end
|
|
|
|
-- kjor EN modus + opprydding. Returnerer (ok, err) saa menyloopen kan avslutte ved CTRL+T.
|
|
local function runMode(mode, a2, a3)
|
|
local ok, err = pcall(function()
|
|
if mode == "boot" then runBoot()
|
|
elseif mode == "setup" then runSetup()
|
|
elseif mode == "standby" then runStandby()
|
|
elseif mode == "monitor" then runMonitor()
|
|
elseif mode == "signtest" then runSigntest()
|
|
elseif mode == "wiring" then runWiring()
|
|
elseif mode == "manual" then runManual(a2, a3)
|
|
elseif mode == "cal" then runCal()
|
|
elseif mode == "observe" then runObserve()
|
|
elseif mode == "fly" then runFly()
|
|
else print("Unknown mode: " .. tostring(mode))
|
|
print("Valid: setup wiring signtest fly standby monitor manual cal observe boot") end
|
|
end)
|
|
-- opprydding: aktive driv-modus -> hold hover (aldri etterlat drivende uten beskjed);
|
|
-- monitor styrer ingenting (la utgangene staa).
|
|
if mode == "manual" or mode == "wiring" then
|
|
allOff()
|
|
elseif mode == "signtest" or mode == "setup" then
|
|
-- Ctrl+T midt i sign-testen: luftbaaren -> hold hover (ALDRI frittfall); paa bakken -> alt av
|
|
if signtestAirborne then
|
|
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear)
|
|
else
|
|
allOff()
|
|
end
|
|
elseif mode == "cal" or mode == "fly" or mode == "observe" or mode == "standby" or mode == "boot" then
|
|
if fs.exists(CALIB_FILE) then
|
|
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear) -- hold, ikke null (aldri stup)
|
|
else
|
|
allOff() -- aldri floeyet under vaar kontroll -> skipet staar paa bakken; ikke varm brennere
|
|
end
|
|
end
|
|
if not ok and err ~= "Terminated" then print("ERROR: " .. tostring(err)); print("[Enter]"); read() end
|
|
return ok, err
|
|
end
|
|
|
|
if args[1] then
|
|
-- CLI-arg: kjor en gang og avslutt
|
|
runMode(args[1], args[2], args[3])
|
|
else
|
|
-- meny-drevet: tilbake til hovedskjermen etter hver modus (CTRL+T avslutter appen)
|
|
while true do
|
|
local m = menu()
|
|
if m == "exit" then break end
|
|
local ok, err = runMode(m)
|
|
if not ok and err == "Terminated" then break end
|
|
end
|
|
end
|