Airship autopilot: role-based wget installer for server

Mirrors the live CC autopilot (computer 0) and cockpit (computer 4) from the
Modda wii world. install.lua fetches each role's manifest over HTTP so the
system installs on any server regardless of computer id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:53:41 +02:00
commit 4cd2573f56
36 changed files with 2897 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
-- altitude.lua (Lag 6) -- G-FRI selvlaerende hoyde-loop.
-- Erstatter den g-avhengige modell-inversjonen (som svingte fordi g/hover var ukalibrert).
-- OUTER: hoyde -> onsket fart, som et MYKT BAaND med hysterese (prioritet: pitch > eksakt hoyde,
-- vi pumper aldri mot et eksakt tall). INNER: en treg fart-PI i REDSTONE-rommet. Integratoren
-- (uTrim) laerer loftet som faktisk gir onsket fart -- uavhengig av ekte g og hover. Speiler
-- pitchens integral-trim. Persisteres som warm-start (calib.hoverTrim).
-- Ki_climb=0.12 verifisert i offline-sim: naar target, ingen svingning for tau~3-6 s (skip ~5 s).
local utils = require("utils")
local M = {}
local Alt = {}
Alt.__index = Alt
-- calib: { uHoverFront, uHoverRear, hoverTrim? }
function M.new(cfg, calib)
return setmetatable({
cfg = cfg, calib = calib,
hold = false,
uTrim = calib.hoverTrim or 0, -- laert loft-korreksjon (redstone-enheter), warm-start
}, Alt)
end
-- vMeas: MAALT vertikal fart (b/s). dt: faktisk steg (s). saturated: mixeren klemte en utgang
-- (anti-windup -> frys integralet). Returnerer u_front_common, u_rear_common + diag.
function Alt:compute(altitude, target, vMeas, dt, saturated, lowAltTighten)
local cfg, calib = self.cfg, self.calib
local uHoverSum = calib.uHoverFront + calib.uHoverRear -- noeytral baseline; uTrim laerer resten
-- OUTER: hoyde -> onsket fart (mykt baand med hysterese)
local altError = target - altitude
local ae = math.abs(altError)
if self.hold then
if ae > cfg.altDeadband + cfg.altBandHyst then self.hold = false end -- forlat baandet foerst godt utenfor
else
if ae < cfg.altDeadband then self.hold = true end -- innenfor -> hold
end
local vMaxDown = cfg.vMaxDown
if lowAltTighten then vMaxDown = math.min(vMaxDown, 0.4) end -- ekstra forsiktig naer bakken
local v_des = self.hold and 0 or utils.clamp(cfg.Kp_alt * altError, -vMaxDown, cfg.vMaxUp)
-- INNER: g-FRI fart-PI. Integratoren laerer loftet som gir v_des. Anti-windup: frys ved metning.
local vErr = v_des - vMeas
if dt > 0 and not saturated then
self.uTrim = utils.clamp(self.uTrim + cfg.Ki_climb * vErr * dt, cfg.uTrimMin, cfg.uTrimMax)
end
local u_sum = utils.clamp(uHoverSum + self.uTrim + cfg.Kp_climb * vErr, 2 * cfg.absFloor, 2 * cfg.maxSignal)
-- fordel paa segmentene etter trim-forholdet (pitch-loopen legger differansen oppaa)
local u_front = u_sum * calib.uHoverFront / uHoverSum
local u_rear = u_sum * calib.uHoverRear / uHoverSum
return u_front, u_rear, { v_des = v_des, vErr = vErr, uTrim = self.uTrim, u_sum = u_sum, hold = self.hold }
end
return M

View File

@@ -0,0 +1,241 @@
-- calibration.lua (§6) -- SELV-LAERENDE sysID. Maaler alt skip-spesifikt og AVLEDER sin egen
-- regulator-gain fra den maalte planten -> universell uten hand-tuning.
--
-- Hvorfor avledet gain: en proporsjonal hastighetsregulator paa denne lav-drag/stor-etterslep
-- planten er stabil bare hvis Kp ~ lambda/g (skip-spesifikt). Fast Kp gir grensesyklus paa ett
-- skip og treghet paa et annet. Vi maaler lambda/g i trappas kryss-trinn og setter Kp = K*lambda/g.
--
-- Flyt: trappe-fra-bunn (finn hover + steg-respons -> lambda/g -> avled Kp) -> hover-hold ->
-- pitch-fortegn -> pitch-trim. Bundet proporsjonal styring + treg hoverEst-trim. Dither
-- i driveValves lar skipet hvile paa sann (desimal) hover.
local utils = require("utils")
local Estimator = require("estimator")
local Logger = require("logger")
local M = {}
local function now() return os.epoch("utc") / 1000 end
-- ---- trappe ----
local STAIR_STEP = 0.5
local STAIR_DWELL = 6.0 -- sek per UNDER-hover trinn (>= etterslep)
local STAIR_DETECT = 0.30 -- b/s sustained = crossing
local STAIR_DET_HOLD = 1.0
local STAIR_HARDV = 1.5 -- b/s -> bryt uansett
local STAIR_SINK = 0.40 -- b/s ned -> airborne & under hover: steg opp
local STAIR_SINK_HOLD = 0.6
local FIT_WINDOW = 9.0 -- sek hold paa kryss-kommando for lambda/g-fit (steg-respons)
-- ---- avledet hastighetsregulator (universell: Kp = K*lambda/g) ----
local K_GAIN_VEL = 1.5
-- KP_MAX lavt nok til at kpVel*v holder seg innenfor VEL_BAND for v opp til ~7 b/s
-- -> alltid LINEAER (ingen bang-bang) -> grensesyklus strukturelt utelukket. Lavt = trygt.
local KP_MIN, KP_MAX = 0.05, 0.20
local Ki_ff = 0.05 -- TREG hoverEst-trim (airborne hold)
local FF_BAND = 1.5
local VEL_BAND = 1.5 -- omslutter hover for enhver plant
local CLIMB_RATE = 0.4 -- lav -> lite momentum -> liten coast
local HOVER_VTOL = 0.40 -- loosere: nok til pitch-maaling, unngaa grensesyklus-jag
local HOVER_HOLD = 2.0
local HOLD_MAX_T = 90.0
local Kp_diff = 0.20 -- pitch-nivellering (best-effort)
local PITCH_WARN = 3.0
-- ---- vakter ----
local V_RUNAWAY, RUNAWAY_HOLD, GROUND_DROP = 4.0, 3.0, 2.0
-- v(t) = v0 + a_inf*(t - (1-e^{-lam t})/lam) ; 2-param LSQ for hver lam, min SSE
local function fitFirstOrder(samples)
if #samples < 8 then return nil, nil, false end
local bestSSE, bestL, bestA = math.huge, nil, nil
for gi = 0, 30 do
local lam = 0.1 + (2.0 - 0.1) * gi / 30
local s11, s1p, spp, s1v, spv = 0, 0, 0, 0, 0
for _, s in ipairs(samples) do
local t, v = s[1], s[2]
local phi = t - (1 - math.exp(-lam * t)) / lam
s11 = s11 + 1; s1p = s1p + phi; spp = spp + phi * phi
s1v = s1v + v; spv = spv + phi * v
end
local det = s11 * spp - s1p * s1p
if math.abs(det) > 1e-9 then
local c0 = (spp * s1v - s1p * spv) / det
local c1 = (s11 * spv - s1p * s1v) / det
local sse = 0
for _, s in ipairs(samples) do
local t, v = s[1], s[2]
local phi = t - (1 - math.exp(-lam * t)) / lam
sse = sse + (v - (c0 + c1 * phi)) ^ 2
end
if sse < bestSSE then bestSSE, bestL, bestA = sse, lam, c1 end
end
end
if not bestL then return nil, nil, false end
return bestL, bestA, (bestA ~= nil and bestA > 0.02 and bestL > 0.11 and bestL < 1.99)
end
function M.run(cfg, sensors, drive)
local log = Logger.new("cal.log", "# cal v3: phase, t, fore, aft, height, vVel, pitch, pitchRate")
local est = Estimator.new(cfg)
local s = cfg.signtest
local hoverGuess = (cfg.calib.uHoverFront + cfg.calib.uHoverRear) / 2
local calib = {}
for k, v in pairs(cfg.calib) do calib[k] = v end
local startAlt = sensors.readAltitude()
if not startAlt then return nil, "no altitude sensor" end
local hoverEst = hoverGuess
local ffBase = hoverGuess
local uDiff = 0
local kpVel = 0.10 -- foreloepig; AVLEDES fra lambda/g etter trappa
local runHold = 0
local function tick(fore, aft, phase)
local f, a = drive(fore, aft)
local h, p = sensors.readAltitude(), sensors.readPitch()
if h and p then est:update(now(), h, p) end
log:row({ phase, now(), f, a, est.altitude, est.verticalVelocity, est.pitch, est.pitchRate })
sleep(cfg.dt)
return est.altitude, est.verticalVelocity, est.pitch, est.pitchRate
end
local function hud(title, lines)
term.clear(); term.setCursorPos(1, 1); print("==== CALIBRATION ===="); print(title)
for _, l in ipairs(lines) do print(l) end
end
-- bundet proporsjonal hastighets-steg med AVLEDET kpVel (kan ikke runaway/grensesykle)
local function velCtl(vDes, levelPitch, trimFF, tag)
local v = est.verticalVelocity
if trimFF then
hoverEst = utils.clamp(hoverEst + Ki_ff * (vDes - v) * cfg.dt,
math.max(cfg.absFloor, ffBase - FF_BAND), math.min(cfg.maxSignal - 0.5, ffBase + FF_BAND))
end
local uCommon = utils.clamp(hoverEst + utils.clamp(kpVel * (vDes - v), -VEL_BAND, VEL_BAND),
cfg.absFloor, cfg.maxSignal)
if levelPitch then
uDiff = utils.clamp(uDiff - calib.pitchSign * Kp_diff * est.pitch * cfg.dt,
-cfg.maxSignal / 2, cfg.maxSignal / 2)
end
return tick(uCommon + uDiff, uCommon - uDiff, tag)
end
local function guard(v)
if est.altitude < startAlt - GROUND_DROP then return "sank below launch point (lift too low?)" end
if math.abs(v) > V_RUNAWAY then runHold = runHold + cfg.dt else runHold = 0 end
if runHold > RUNAWAY_HOLD then return "velocity runaway (unexpected)" end
return nil
end
local function holdHover(levelP, tag, title)
local t0, held = now(), 0
while now() - t0 < HOLD_MAX_T do
local _, v, p = velCtl(0, levelP, true, tag)
local err = guard(v); if err then return false, err end
if math.abs(v) < HOVER_VTOL then held = held + cfg.dt else held = 0 end
hud(title, {
string.format("hoverEst %.2f uDiff %+.2f kpVel %.3f", hoverEst, uDiff, kpVel),
string.format("vVel %.2f pitch %.2f", v, p),
string.format("settle %.1f / %.1f s", held, HOVER_HOLD),
})
if held >= HOVER_HOLD then return true end
end
return false, "hover-hold did not settle in time"
end
-- ---------- STAGE 1: TRAPPE fra bunnen -> hover + lambda/g-fit (kryss-trinnet) ----------
do
local cmd = cfg.absFloor
local crossing, fitSamples = nil, nil
while cmd <= cfg.maxSignal + 1e-9 and not crossing do
local tStep, det, sink, samples, crossed = now(), 0, 0, {}, false
while true do
local _, v = tick(cmd, cmd, "S1_stair")
samples[#samples + 1] = { now() - tStep, v }
if v > STAIR_DETECT then det = det + cfg.dt else det = 0 end
if v < -STAIR_SINK then sink = sink + cfg.dt else sink = 0 end
if not crossed and (det >= STAIR_DET_HOLD or v > STAIR_HARDV) then crossed = true end
hud("Stage 1: staircase hover-find", {
string.format("cmd %.2f vVel %.2f", cmd, v),
crossed and string.format("CROSSING -> fitting %.1f/%.1f s", now() - tStep, FIT_WINDOW)
or string.format("rise-detect %.1f / %.1f s", det, STAIR_DET_HOLD),
string.format("alt +%.1f", est.altitude - startAlt),
})
if crossed then
if now() - tStep >= FIT_WINDOW then break end -- nok steg-respons-data
else
if now() - tStep >= STAIR_DWELL or sink >= STAIR_SINK_HOLD then break end
end
end
if crossed then crossing, fitSamples = cmd, samples else cmd = cmd + STAIR_STEP end
end
if not crossing then return nil, "could not lift at any command up to maxSignal (too heavy?)" end
hoverEst = crossing - STAIR_STEP / 2
ffBase = hoverEst
-- lambda/g fra steg-responsen (excess = STAIR_STEP/2 over hoverEst)
local lam, ainf, ok = fitFirstOrder(fitSamples)
if ok then calib.lambda = lam else calib.lambda = 1.0 / cfg.responseDelayDefault end
if ok and ainf > 0 then
calib.g = ainf * hoverEst / (STAIR_STEP / 2) -- g = a_inf/(L-1), L-1 = (step/2)/hoverEst
end
-- AVLED regulator-gain fra maalt plant (universell)
kpVel = utils.clamp(K_GAIN_VEL * calib.lambda / math.max(0.1, calib.g), KP_MIN, KP_MAX)
log:raw(string.format("# S1: crossing=%.2f hoverEst=%.2f lambda=%.3f g=%.3f -> kpVel=%.3f",
crossing, hoverEst, calib.lambda, calib.g, kpVel))
end
-- ---------- STAGE 2: hover-hold (arrester klatringen, settle) ----------
do
local ok, err = holdHover(false, "S2_hold", "Stage 2: settling to hover")
if not ok then return nil, err end
end
-- ---------- STAGE 3: pitch-fortegn + autoritet (rolig ved hover) ----------
do
local function avgPitch(fore, aft, secs, phase)
local t0, sum, n = now(), 0, 0
while now() - t0 < secs do local _, _, p = tick(fore, aft, phase); sum = sum + p; n = n + 1 end
return (n > 0) and (sum / n) or 0
end
hud("Stage 3: measuring pitch sign", { "balanced..." })
local pBefore = avgPitch(hoverEst, hoverEst, s.settleTime, "S3_pre")
local prMax, t0 = 0, now()
while now() - t0 < s.nudgeTime do
local _, _, _, pr = tick(hoverEst + s.nudge, math.max(0, hoverEst - s.nudge), "S3_nudge")
if math.abs(pr) > math.abs(prMax) then prMax = pr end
hud("Stage 3: nudge (more front)", { string.format("pitchRate %.2f", pr) })
end
local pAfter = avgPitch(hoverEst + s.nudge, math.max(0, hoverEst - s.nudge), s.settleTime, "S3_post")
local dp = pAfter - pBefore
if math.abs(dp) < 1.0 then
log:raw(string.format("# S3: WEAK dp=%.2f -> keeping pitchSign=%d", dp, calib.pitchSign))
else
calib.pitchSign = utils.sign(dp)
calib.pitchGain = math.max(0.05, math.abs(dp) / (2 * s.nudge))
calib.alphaPitch = calib.pitchGain
log:raw(string.format("# S3: dp=%.2f -> pitchSign=%d pitchGain=%.3f", dp, calib.pitchSign, calib.pitchGain))
end
end
-- ---------- STAGE 4: hover + pitch-trim (best-effort) ----------
do
local ok, err = holdHover(true, "S4_level", "Stage 4: hover + level (patient)")
if not ok then return nil, err end
calib.uHoverFront = utils.clamp(hoverEst + uDiff, cfg.absFloor, cfg.maxSignal)
calib.uHoverRear = utils.clamp(hoverEst - uDiff, cfg.absFloor, cfg.maxSignal)
calib.pitchResidual = est.pitch
log:raw(string.format("# S4: uHoverFront=%.2f uHoverRear=%.2f residualPitch=%.2f",
calib.uHoverFront, calib.uHoverRear, est.pitch))
if math.abs(est.pitch) > PITCH_WARN then
log:raw(string.format("# S4: WARNING limited pitch authority (residual %.1f deg)", est.pitch))
end
end
drive(calib.uHoverFront, calib.uHoverRear) -- hold hover ved utgang
return calib
end
return M

View File

@@ -0,0 +1,130 @@
-- config.lua -- all tunables + defaults for the v3 observer-based autopilot.
-- Startpunkter, ikke fasit. lambda/g/uHover* settes av kalibrering; resten finnes via loggen.
-- Hold dette filet som eneste sted for skip-spesifikke valg.
return {
-- ============ HARDWARE (skip-spesifikt -- verifisert mot dette skipet) ============
foreSide = "right", -- redstone-side til FRONT-ventil (Create Redstone Link)
aftSide = "back", -- redstone-side til BAK-ventil; sett nil for ett-ballong-skip
pitchIndex = 1, -- hvilket tall fra gimbal_sensor.getAngles() er pitch (a1)
-- VIKTIG fysikk-caveat (fra forrige forsoks maalinger):
-- Loftet PLATAaeR rundt signal ~5 og er flatt/synkende over. v3-modellen antar
-- loft ~ lineaert i u og holder derfor KUN naer hover. Vi capper utgangen lavt slik at
-- regulatoren aldri kommanderer inn i den dode sonen (der modellen brister OG pitch-
-- autoriteten forsvinner). Senk til 5 hvis >5 gir problemer; hev forsiktig om noedvendig.
maxSignal = 6, -- maks redstone-utgang per ventil (clamp-tak i mixeren)
-- Sigma-delta-dithering: realiser DESIMAL-kommando (f.eks. 3.6) som tidssnitt av heltall.
-- Den trege loft-responsen (~3 s) lavpasser ditheret -> skipet ser snittet og kan hvile paa
-- sann (desimal) hover. Transparent for heltalls-kommandoer. Sett false for raa avrunding.
dither = true,
-- ============ LOOP ============
dt = 0.2, -- s (~5 Hz). Skipet er tregt; raskere forsterker bare stoy.
velocityWindow = 1.0, -- s -- regresjonsvindu for fart/pitch-rate
glitchH = 25, -- blokker: avvis hoyde-hopp storre enn dette per steg (sensor-glitch)
-- ============ OBSERVER (KJERNE -- Lag 4) ============
Kf = 0.10, -- TRUST-KNAPPEN: fart-residual -> fylling. Tunes via loggen.
Kv = 0.5, -- hvor raskt fart-estimatet snapper mot maaling
Kw_pitch = 0.5, -- hvor raskt pitch-rate-estimatet snapper mot maaling
Kfp = 0.08, -- pitch-residual -> differanse-fylling
Kbias = 0.005, -- SAKTE last/modellfeil-korreksjon (<< Kf; timeskala-separasjon)
biasMin = -0.5,
biasMax = 0.8,
Lmax = 2.0, -- maks fysisk lagret loft (vekt-normalisert) per estimat-clamp
eMaxV = 4.0, -- outlier-grense paa fart-residual (b/s)
eMaxP = 30.0, -- outlier-grense paa pitch-rate-residual (deg/s)
-- ============ YTRE HOYDE (Lag 6) ============
-- PRIORITET: stabil pitch > eksakt hoyde. Hoyde = MYKT BAaND, ikke setpunkt -> ikke pump.
-- Lave rater + bredt baand + hysterese demper opp/ned-grensesyklusen (lav drag + ~5 s etterslep
-- + ukalibrert g/lambda -> ellers oversving rundt maalet). Konservativt; finjuster mot fly.log.
Kp_alt = 0.12, -- (b/s) per blokk hoydefeil
vMaxUp = 0.8, -- b/s maks klatrerate (verifisert i sim med g-fri PI -> ingen oversving)
vMaxDown = 0.8, -- b/s maks synkerate
altDeadband = 3.0, -- blokker -- innenfor baandet: HOLD, ikke chase eksakt hoyde
altBandHyst = 2.0, -- ekstra margin foer vi forlater baandet (hysterese mot grensesyklus)
-- ============ HOYDE: G-FRI FART-PI (Lag 6, self-learning) ============
-- Erstatter den g-avhengige modell-inversjonen (svingte fordi g/hover var ukalibrert). uTrim er
-- en treg integrator i REDSTONE-rommet som laerer loftet som faktisk gir onsket fart -- uavhengig
-- av ekte g og hover. Persistert som warm-start (calib.hoverTrim). Speiler pitchens integral-trim.
-- Ki=0.12 verifisert i offline-sim: naar target, ingen svingning for tau~3-6 s (skipets ~5 s).
-- Senk Ki for mye treigere skip (lang lag -> windup-svingning).
Ki_climb = 0.12, -- redstone per (b/s * s) fartsfeil -- treg loft-laering
Kp_climb = 0.8, -- redstone per (b/s) fartsfeil -- responsivitet
uTrimMin = -4.0, -- |loft-trim|-grenser (redstone-enheter); mixeren klemmer faktisk utgang
uTrimMax = 8.0,
-- ============ PITCH (Lag 5) ============
Kp_p = 0.3, -- differanse-loft per grad pitchfeil
Kd_p = 0.15, -- demping per (grad/s) pitch-rate
pitchSign = 1, -- noeytral; sign test / kalibrering MAALER ekte fortegn -> calib.txt
-- SELF-LEARNING differanse-trim (universell -- IKKE skip-tunet). En treg integrator som laerer
-- trimmen som holder pitch=0 paa ETHVERT skip og re-laerer ved last-endring. Gjor pitchGain-
-- storrelsen irrelevant for steady-state; kun pitchSign maa stemme. Ki_p styrer konvergensfarten
-- (langsom ift. ~5 s loft-etterslep for aa unngaa windup-svingning).
-- Ki_p=0.01 verifisert i offline-sim: ~0.0 grad varig pitch, stabil for tau=3..8 s. (0.04 ga
-- grensesyklus ved tau>=5 -- IKKE hev uten ny stabilitetssjekk.)
Ki_p = 0.01, -- D_des per (grad*s) pitchfeil -- treg trim-laering
iPitchMax = 4.0, -- |trim|-tak (D_des); mixeren klemmer faktisk utgang uansett
iPitchRateGate = 1.0, -- deg/s: integrer KUN naar |pitchRate| under dette (ikke transienter)
pitchFaultDeg = 35.0, -- |pitch| over dette -> FAULT (hold hover, aldri runaway)
-- ============ MIXER / SIKKERHET (Lag 8) ============
absFloor = 1, -- aldri kommander 0 i normal drift (0 = stup)
maxSinkBelowHover = 2.0, -- nivaaer under hover vi tillater
lowAltCaution = 15, -- blokker over start: under denne strammes synk (Lag 8)
-- ============ FLY / TILSTANDSMASKIN (Lag 5-8) ============
takeoffOffset = 30, -- blokker over start hvis ingen target er satt
evDivergeFault = 2.5, -- b/s: vedvarende |e_v|-snitt over dette -> FAULT (hold hover)
evDivergeAlpha = 0.08, -- EMA paa |e_v| (residual-divergens)
-- ============ KALIBRERING (Lag 3) ============
-- Skipet har INGEN manuell styring -> kalibrering loefter seg selv fra bakken.
calibClearance = 18.0, -- blokker aa klatre fra start FOR maaling (rom for settle-oversving)
calibLiftTimeout = 55.0, -- s maks paa loft-til-klaring (skaansom ~0.8 b/s klatring tar tid)
calibMaxDrift = 8.0, -- blokker drift for abort
calibPhaseTime = 15.0, -- s tidsgrense per fase
calibStep = 4, -- nivaasteg for lambda/g-test (+4 over hover, ikke +1)
hoverSearchStep = 0.5,
responseDelayDefault = 3.0, -- s -> fallback lambda = 1/3.0
-- ============ INITIELL CALIB (NOEYTRALE placeholders -- IKKE skip-gjetninger) ============
-- Disse er bare fallback FOER kalibrering har kjoert. Kalibreringen MAALER de ekte verdiene
-- (trappa finner hover fra bunnen, uavhengig av disse). Hold dem noeytrale, ikke skip-tunet.
calib = {
uHoverFront = 3.0, -- noeytral midt i brukbart omraade (0..maxSignal); trappa maaler ekte
uHoverRear = 3.0,
lambda = 1.0 / 3.0, -- noeytral; lambda/g-fit maaler ekte
g = 1.0, -- noeytral; lambda/g-fit maaler ekte
alphaPitch = 1.0, -- noeytral; Stage 4 maaler ekte
pitchGain = 1.0, -- noeytral; Stage 4 maaler ekte
pitchSign = 1, -- noeytral; Stage 4 maaler ekte fortegn
pitchTrim = 0.0, -- self-learned differanse-trim (warm-start); fly-loopen oppdaterer
hoverTrim = 0.0, -- self-learned loft-trim (redstone, warm-start); fly-loopen oppdaterer
},
-- ============ SIGNTEST (§13.2) ============
signtest = {
liftLevel = 5, -- felles nivaa for aa loefte skipet luftbaaret (i responsivt omraade)
airborneRise = 3.0, -- blokker stigning som regnes som luftbaaret
liftTimeout = 15.0, -- s maks paa loft-fasen
nudge = 2, -- differanse (front opp / bak ned) under maalingen
settleTime = 1.0, -- (legacy: brukt av full kalibrering 'cal' Stage 3)
nudgeTime = 3.0, -- (legacy: brukt av full kalibrering 'cal' Stage 3)
-- SETTLE-BASERT maaling: lagrer FOERST naar pitch-RATEN har roet seg, ikke paa fast timer.
-- (Open-loop er skipet en naer-integrator -> vinkelen settler aldri under vedvarende moment,
-- men raten gjor det. Vi maaler endring i stabilisert rate, lag-bevisst.)
rateCalm = 0.4, -- deg/s: maks spenn i rate-vinduet for "stabil"
baseMinHold = 2.0, -- s min ved baseline foer vi godtar stabil
baseMaxWait = 8.0, -- s timeout baseline
nudgeMinHold = 6.0, -- s min under nudge (>= ~5 s loft-etterslep) foer godtatt
nudgeMaxWait = 14.0, -- s timeout nudge
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
},
}

View File

@@ -0,0 +1,48 @@
-- estimator.lua (§4) -- smoothing + regression for vertical velocity and pitch-rate.
-- INGEN andrederiverte som kontrollinngang. Fart/pitch-rate = helning fra minste-kvadraters
-- regresjon over et ~1 s vindu (robust mot kvantisering), ikke (x - x_prev)/dt.
local utils = require("utils")
local M = {}
local Est = {}
Est.__index = Est
function M.new(cfg)
return setmetatable({
cfg = cfg,
altRing = utils.newRing(cfg.velocityWindow),
pitchRing = utils.newRing(cfg.velocityWindow),
altitude = 0, verticalVelocity = 0,
pitch = 0, pitchRate = 0,
valid = false,
lastAlt = nil,
glitched = false,
}, Est)
end
-- t: veggtid i sekunder (os.epoch("utc")/1000). altitude/pitch: raa sensoravlesninger.
function Est:update(t, altitude, pitch)
local cfg = self.cfg
self.glitched = false
-- glitch-vern: avvis urealistiske hoyde-hopp -> hold forrige (caller kan flagge FAULT)
if self.lastAlt ~= nil and math.abs(altitude - self.lastAlt) > cfg.glitchH then
altitude = self.lastAlt
self.glitched = true
end
self.altRing:push(t, altitude)
self.pitchRing:push(t, pitch)
self.altitude = altitude
self.pitch = pitch
self.verticalVelocity = self.altRing:slope()
self.pitchRate = self.pitchRing:slope()
-- gyldig naar vi har nok spenn i vinduet til en meningsfull helning
self.valid = (self.altRing:count() >= 3) and (self.altRing:span() >= cfg.velocityWindow * 0.5)
self.lastAlt = altitude
return self.altitude, self.verticalVelocity, self.pitch, self.pitchRate, self.valid
end
return M

View File

@@ -0,0 +1,33 @@
-- link.lua -- lettvekts rednet-lenke mellom autopiloten (maskinrom) og piloten (pilot-hus).
-- Modem-agnostisk: virker over wired (nettverkskabel) ELLER wireless/ender modem. Graceful:
-- hvis ingen modem finnes, blir publish/send no-ops (autopiloten kjorer fint solo).
local M = {}
M.STATUS = "airship_status" -- autopilot -> pilot (status-kringkasting)
M.CMD = "airship_cmd" -- pilot -> autopilot (kommandoer)
local opened = false
-- aapne rednet paa ALLE tilkoblede modem (wired og/eller wireless)
function M.open()
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" and not rednet.isOpen(name) then
rednet.open(name); opened = true
end
end
-- ogsaa hvis et modem allerede var aapent
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" and rednet.isOpen(name) then opened = true end
end
return opened
end
function M.isOpen() return opened end
-- kringkast status (kalles hver tick av autopiloten)
function M.publishStatus(t) if opened then rednet.broadcast(t, M.STATUS) end end
-- send en kommando-tabell (kalles av piloten ved knappetrykk)
function M.sendCommand(t) if opened then rednet.broadcast(t, M.CMD) end end
return M

View File

@@ -0,0 +1,53 @@
-- logger.lua (§10) -- CSV-logg + kort HUD-hjelper.
-- ROBUST: kan ALDRI fylle CC-disken (cap + rotasjon til .old), og en full disk skal ALDRI
-- krasje autopiloten (alle fs-kall er pcall-et). Bounded til ~2x MAX_BYTES per logg.
local M = {}
local Log = {}
Log.__index = Log
local MAX_BYTES = 200000 -- per loggfil; roteres til .old over dette -> total ~400KB (<< 1MB-disk)
function M.new(path, header)
pcall(fs.delete, path) -- fersk logg per modus (frigjor plass fra forrige kjoring)
pcall(fs.delete, path .. ".old")
local self = setmetatable({ path = path, header = header }, Log)
if header then self:raw(header) end
return self
end
local function rotate(self)
local old = self.path .. ".old"
if fs.exists(old) then fs.delete(old) end -- frigjor forrige backup (henter inn plass)
if fs.exists(self.path) then fs.move(self.path, old) end
if self.header then
local f = fs.open(self.path, "w"); if f then f.writeLine(self.header); f.close() end
end
end
function Log:raw(text)
-- roter naar fila naar taket -> kan aldri fylle disken
local okSize, sz = pcall(fs.getSize, self.path)
if okSize and sz and sz > MAX_BYTES then pcall(rotate, self) end
-- skriv robust: full disk / fs-feil skal ALDRI propagere opp og stoppe styringa
pcall(function()
local f = fs.open(self.path, "a")
if f then f.writeLine(text); f.close() end
end)
end
-- skriv en CSV-rad fra en ordnet liste verdier (tall -> 3 desimaler)
function Log:row(vals)
local parts = {}
for i = 1, #vals do
local v = vals[i]
if type(v) == "number" then
parts[i] = string.format("%.3f", v)
else
parts[i] = tostring(v)
end
end
self:raw(table.concat(parts, ", "))
end
return M

View File

@@ -0,0 +1,649 @@
-- main.lua -- loop + tilstandsmaskin for v3 observer-autopiloten.
-- All UI-tekst er ENGELSK; kommentarer er norske (husstil).
-- Modus (argument eller meny):
-- monitor Lag 1: les sensorer+estimator, logg+HUD, styrer INGENTING
-- signtest §13.2: nudge front -> anbefal pitchSign
-- 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
-- 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) ============================
-- Maaler pitch-RESPONSEN paa en nudge (mer front) for aa finne pitchSign. Bruker pitch-RATEN, ikke
-- vinkelen: open-loop under et vedvarende moment er skipet en naer-integrator -> VINKELEN settler
-- aldri, men RATEN gjor det. Vi venter til raten har roet seg (lag-bevisst: minHold >= loft-
-- etterslepet) FOER vi lagrer. Baseline-raten fanger asymmetri-driften; dr = endring pga nudge.
-- Driver (fore,aft) til pitch-raten er stabil (lite spenn i et ~1.5 s vindu) ELLER timeout.
-- Returnerer snitt pitchRate over vinduet + om den faktisk stabiliserte seg.
local function holdTilRateStable(est, fore, aft, h0, label, minHold, maxWait)
local t0, win, WINSEC = now(), {}, 1.5
while now() - t0 < maxWait do
driveValves(fore, aft)
est:update(now(), sensors.readAltitude() or h0, sensors.readPitch() or 0)
win[#win + 1] = { t = now(), pr = est.pitchRate }
while #win > 1 and (now() - win[1].t) > WINSEC do table.remove(win, 1) end
local sum, lo, hi = 0, math.huge, -math.huge
for _, e in ipairs(win) do sum = sum + e.pr; lo = math.min(lo, e.pr); hi = math.max(hi, e.pr) end
local n = #win
local avg = sum / n
local span = (n > 1) and (hi - lo) or math.huge
term.clear(); term.setCursorPos(1, 1); print("SIGN TEST: " .. label)
print(string.format(" fore %d aft %d", fore, aft))
print(string.format(" pitch %+.1f pRate %+.2f", est.pitch, est.pitchRate))
print(string.format(" window avg %+.2f span %.2f deg/s", avg, span))
if math.abs(est.pitch) > cfg.signtest.maxTiltAbort then return avg, true end -- ikke roter for langt
if (now() - t0) >= minHold and n >= 5 and span < cfg.signtest.rateCalm then return avg, true end
sleep(cfg.dt)
end
local sum = 0; for _, e in ipairs(win) do sum = sum + e.pr end
return (#win > 0) and (sum / #win) or est.pitchRate, false
end
local function runSigntest()
local s, est = cfg.signtest, Estimator.new(cfg)
local h0 = sensors.readAltitude(); if not h0 then print("No altitude sensor."); return end
print("SIGN TEST. CTRL+T for emergency stop."); print("1) Lifting airborne..."); sleep(0.5)
local liftLevel, airborne, tLift = math.min(cfg.maxSignal, s.liftLevel), false, now()
while now() - tLift < s.liftTimeout do
driveValves(liftLevel, liftLevel)
local h = sensors.readAltitude() or h0
est:update(now(), h, sensors.readPitch() or 0)
term.clear(); term.setCursorPos(1, 1); print("SIGN TEST: lifting...")
print(string.format(" height %.2f (+%.2f) vVel %.2f", h, h - h0, est.verticalVelocity))
if h - h0 > s.airborneRise then airborne = true; break end
sleep(cfg.dt)
end
if not airborne then allOff(); print("Did not get airborne -- raise liftLevel / check sides."); return end
local climbOk = est.verticalVelocity > 0
-- baseline-rate (balansert) -- vent til raten roer seg (fanger evt. asymmetri-drift)
local r0 = holdTilRateStable(est, liftLevel, liftLevel, h0, "baseline (balanced)", s.baseMinHold, s.baseMaxWait)
-- nudge (mer front) -- HOLD til loftet (~5 s) har uttrykt seg OG raten er stabil
local r1, settled = holdTilRateStable(est, liftLevel + s.nudge, math.max(0, liftLevel - s.nudge),
h0, "nudge (more front)", s.nudgeMinHold, s.nudgeMaxWait)
allOff()
local dr = r1 - r0 -- endring i stabilisert pitch-RATE pga mer front = robust, lag-tolerant signal
term.clear(); term.setCursorPos(1, 1); print("==== SIGN TEST RESULT ====")
print(string.format("rate baseline %+.2f -> nudged %+.2f (dr %+.2f deg/s)", r0, r1, dr))
print(string.format("rate settled: %s", settled and "yes" or "no (timed out -- direction still used)"))
print(string.format("climb sign: %s", climbOk and "OK (vVel>0)" or "WRONG -- altitude inverted!"))
if math.abs(dr) < s.drMin then
print("WEAK response -- NOT saving. Raise signtest.nudge or re-run.")
else
local c = loadCalib()
c.pitchSign = utils.sign(dr)
c.pitchGain = 1.0 -- robust default; integral-trimmen gjor magnituden ukritisk
c.alphaPitch = 1.0
c.pitchTrim = 0 -- nullstill laert trim ved ny fortegns-maaling
saveCalib(c)
print(string.format(">>> Saved pitchSign=%d (pitchGain=1.0; integral learns the rest)", c.pitchSign))
end
print(""); print("[Enter]"); read()
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
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("Which balloon fired on '%s'? [f]ront / [a]ft / [n]one: ", side))
local a = read()
if a == "f" or a == "F" then fore = side
elseif a == "a" or a == "A" then aft = side 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()
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("Set it up first (in order):")
print(" menu 0 Wiring setup (which side = front/aft valve)")
print(" menu 2 Sign test (measures + saves pitch sign)")
print("")
print("Then run FLY -- pitch & altitude self-learn from there.")
print("(Full 'cal' is optional/legacy; controllers no longer need lambda/g.)")
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)
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
-- ============================ 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. Kjor denne (eller sett den som startup) saa
-- piloten kan starte/stoppe fly selv: stabilizeV2 standby
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 == "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()
link.open()
print("STANDBY -- waiting for ENGAGE from pilot console. CTRL+T for menu.")
while true do
local h = sensors.readAltitude() or 0
local p = sensors.readPitch() or 0
local ready = fs.exists(CALIB_FILE)
link.publishStatus({ state = "IDLE", height = h, target = loadTarget() or h, pitch = p,
vVel = 0, hold = false, ready = ready, 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) ====")
print(ready and "Profile: READY" or "Profile: MISSING -> run Wiring (0) + Sign test (2)")
print(string.format("Height %.1f Pitch %+.1f", h, p))
print("Waiting for ENGAGE from the pilot console...")
print("CTRL+T -> quit to menu (for setup).")
local cmd = awaitCmd(0.5)
if cmd == "engage" then
if fs.exists(CALIB_FILE) 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 = "Wiring + Sign test on AP", ready = false })
sleep(2.0)
end
end
end
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 pitchGain %.3f", c.pitchSign, c.pitchGain))
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 =====\n")
print(" 1) Monitor (Layer 1)")
print(" 2) Sign test")
print(" 3) Manual drive (Layer 2)")
print(" 4) Calibrate (Layer 3)")
print(" 5) Observe open-loop (Layer 4)")
print(" 6) FLY / hold (Layer 5-8)")
print(" 7) Set target altitude")
print(" 8) Show / reset profile")
print(" 0) Wiring setup (front/aft sides)")
print(" 9) Exit\n")
print(sensors.present() and "Sensors: OK" or "Sensors: MISSING - check wiring")
print(fs.exists(CALIB_FILE) and "Calib: saved" or "Calib: none (will auto-cal on fly)")
print(string.format("Wiring: fore=%s aft=%s", cfg.foreSide, tostring(cfg.aftSide)))
print(""); write("Choice: ")
local k = read()
if k == "1" then return "monitor" elseif k == "2" then return "signtest"
elseif k == "3" then return "manual" elseif k == "4" then return "cal"
elseif k == "5" then return "observe" elseif k == "6" then return "fly"
elseif k == "0" then return "wiring"
elseif k == "7" 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 == "8" then
term.clear(); term.setCursorPos(1, 1); showProfile()
print("\nR = reset calib, Enter = back"); local a = read()
if a == "r" or a == "R" then if fs.exists(CALIB_FILE) then fs.delete(CALIB_FILE) end; print("Calib reset."); print("[Enter]"); read() end
elseif k == "9" 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 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 == "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()
elseif mode == "standby" then runStandby()
else print("Unknown mode: " .. tostring(mode)) end
end)
-- opprydding: aktive driv-modus -> hold hover (aldri etterlat drivende uten beskjed);
-- monitor styrer ingenting (la utgangene staa).
if mode == "signtest" or mode == "manual" or mode == "wiring" then
allOff()
elseif mode == "cal" or mode == "fly" or mode == "observe" or mode == "standby" then
local c = loadCalib(); driveValves(c.uHoverFront, c.uHoverRear) -- hold, ikke null (aldri stup)
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

View File

@@ -0,0 +1,33 @@
-- mixer.lua (§9) -- felles + differanse -> per-segment redstone, med sikkerhetsgulv og
-- "bevar differansen ved metning" (skjev nese er verre enn et kort hoydeavvik).
local utils = require("utils")
local M = {}
-- Bevar (front - rear) naar en side ville sprengt [floor, ceil]: skyv BEGGE likt FOR clamp,
-- saa differansen overlever (krymper bare hvis omraadet er for smalt til aa holde den).
local function preserveDifferenceClamp(front, rear, floor, ceil)
-- ta bort storste overskudd over taket fra begge
local over = math.max(0, front - ceil, rear - ceil)
if over > 0 then front = front - over; rear = rear - over end
-- legg storste underskudd under gulvet til begge
local under = math.max(0, floor - front, floor - rear)
if under > 0 then front = front + under; rear = rear + under end
-- hard sluttclamp (sikkerhet hvis omraadet er for smalt for differansen)
front = utils.clamp(front, floor, ceil)
rear = utils.clamp(rear, floor, ceil)
return front, rear
end
M.preserveDifferenceClamp = preserveDifferenceClamp
-- Returnerer (frontHeat, rearHeat, floor). u_diff > 0 => mer foran.
function M.mix(cfg, calib, u_front_common, u_rear_common, u_diff)
local frontHeat = u_front_common + 0.5 * u_diff
local rearHeat = u_rear_common - 0.5 * u_diff
local hoverMin = math.min(calib.uHoverFront, calib.uHoverRear)
local floor = math.max(cfg.absFloor, hoverMin - cfg.maxSinkBelowHover)
frontHeat, rearHeat = preserveDifferenceClamp(frontHeat, rearHeat, floor, cfg.maxSignal)
return frontHeat, rearHeat, floor
end
return M

View File

@@ -0,0 +1,66 @@
-- observer.lua (§5) -- KJERNEN. Estimerer skjult tilstand: lagret loft (fylling) per segment,
-- vertikal fart, og en sakte last/modellfeil-bias. Predikér fra kommandert redstone, korrigér
-- mot maalt fart. Dette gir regulatoren framsyn i etterslepsvinduet (stopper varming naar
-- kommandert loft er nok, FOER farten snur) -- kuren mot oversving paa en lav-drag plant.
--
-- Fokus: VERTIKAL akse (der etterslepet/oversvinget er problemet). Pitch reguleres paa maalt
-- PD (pitch.lua), saa observeren her holder seg til loft/fart/bias.
local utils = require("utils")
local M = {}
local Obs = {}
Obs.__index = Obs
-- calib: { uHoverFront, uHoverRear, lambda, g }
function M.new(cfg, calib)
local uHoverSum = calib.uHoverFront + calib.uHoverRear
return setmetatable({
cfg = cfg, calib = calib,
uHoverSum = uHoverSum,
-- init: ved hover er L_front=uHoverFront/uHoverSum osv, sum = 1 (= vekt)
L_front = calib.uHoverFront / uHoverSum,
L_rear = calib.uHoverRear / uHoverSum,
v_hat = 0,
bias = 0,
}, Obs)
end
-- Kall hvert tick: u_front/u_rear = FAKTISK kommandert redstone, v_meas = glattet maalt fart,
-- dt = faktisk maalt steg, saturated = true hvis mixeren klemte en utgang (frys bias).
function Obs:step(u_front, u_rear, v_meas, dt, saturated)
local cfg, calib = self.cfg, self.calib
local lam, g = calib.lambda, calib.g
local uHoverSum = self.uHoverSum
-- 1) PREDIKSJON (foersteordens fylling drevet av faktisk u)
self.L_front = self.L_front + lam * (u_front / uHoverSum - self.L_front) * dt
self.L_rear = self.L_rear + lam * (u_rear / uHoverSum - self.L_rear ) * dt
local L_hat = self.L_front + self.L_rear
local a_hat = g * (L_hat - 1 - self.bias)
self.v_hat = self.v_hat + a_hat * dt
-- 2) RESIDUAL mot glattet maaling + outlier-avvisning
local e_v = v_meas - self.v_hat
if math.abs(e_v) > cfg.eMaxV then e_v = 0 end
-- 3) KORREKSJON: snap fart-estimat, og revider lagret loft (delt likt paa segmentene)
self.v_hat = self.v_hat + cfg.Kv * e_v
self.L_front = self.L_front + 0.5 * cfg.Kf * e_v
self.L_rear = self.L_rear + 0.5 * cfg.Kf * e_v
-- 4) SAKTE BIAS (last/steady-state modellfeil) -- kun naar ikke mettet (anti-windup)
if not saturated then
self.bias = utils.clamp(self.bias + cfg.Kbias * e_v * dt, cfg.biasMin, cfg.biasMax)
end
-- 5) FYSISKE GRENSER paa estimatet (divergens-vakt)
self.L_front = utils.clamp(self.L_front, 0, cfg.Lmax)
self.L_rear = utils.clamp(self.L_rear, 0, cfg.Lmax)
self.lastEv = e_v
return e_v
end
function Obs:L_hat() return self.L_front + self.L_rear end
return M

View File

@@ -0,0 +1,46 @@
-- pitch.lua (§8) -- PD paa MAALT pitch + TREG INTEGRAL-TRIM (self-learning differanse-trim).
-- PD-leddet demper/retter transienter; integral-leddet LAERER den varige differanse-trimmen som
-- holder pitch = 0 paa ETHVERT skip, og re-laerer naar du legger paa vekt. Speiler observerens
-- bias (som gjor det samme for felles-modus/hover). Ingen skip-spesifikke konstanter her.
--
-- VIKTIG: integratoren gjor pitchGain-STORRELSEN nesten irrelevant for steady-state (en integrator
-- driver utgangen til det som nuller feilen uansett forsterkning). KUN pitchSign maa stemme
-- (maales av sign test / kalibrering). Med feil fortegn drar baade P og I feil vei.
local utils = require("utils")
local M = {}
local Pitch = {}
Pitch.__index = Pitch
-- calib: { pitchSign, pitchGain, pitchTrim? } (pitchTrim = warm-start av laert trim, valgfritt)
function M.new(cfg, calib)
return setmetatable({
cfg = cfg, calib = calib,
iTrim = calib.pitchTrim or 0, -- laert differanse-trim (D_des-enheter), varmstart fra calib
}, Pitch)
end
-- pitch/pitchRate: MAALT. dt: faktisk steg (s). saturated: mixeren klemte en utgang (anti-windup
-- -> frys integralet). Returnerer u_diff (>0 => mer foran) + diag.
function Pitch:compute(pitch, pitchRate, dt, saturated)
local cfg, calib = self.cfg, self.calib
local pitchError = 0 - pitch -- TARGET_PITCH = 0
local pitchGain = math.max(0.05, calib.pitchGain)
-- TREG INTEGRAL-TRIM (self-learning). Frys ved metning (anti-windup) og under raske transienter
-- (ikke integrer oversving/stoy -- la PD settle foerst). Akkumulerer i D_des-enheter, fortegns-
-- korrigert slik at den drar i RIKTIG retning for skipets maalte pitchSign.
if dt > 0 and not saturated and math.abs(pitchRate) < cfg.iPitchRateGate then
self.iTrim = utils.clamp(
self.iTrim + cfg.Ki_p * calib.pitchSign * pitchError * dt,
-cfg.iPitchMax, cfg.iPitchMax)
end
-- PD (transient) + integral-trim (steady). Alt i "onsket differanse-loft"-enheter (D_des).
local D_des = calib.pitchSign * (cfg.Kp_p * pitchError - cfg.Kd_p * pitchRate) + self.iTrim
local u_diff = D_des / pitchGain
return u_diff, { pitchError = pitchError, D_des = D_des, iTrim = self.iTrim }
end
return M

View File

@@ -0,0 +1,30 @@
-- safety.lua (§11) -- feildeteksjon + regler. Kjerneregelen: i FAULT skal utgangene HOLDE
-- hover-baselinen, ALDRI nulles (null = stup naar autopiloten kobler ut).
-- Tilstandsmaskinen (FLYING <-> FAULT) ligger i main; her er de rene sjekkene + en EMA-filter
-- for residual-divergens.
local utils = require("utils")
local M = {}
-- gyldig sensoravlesning (ikke nil, ikke NaN)
function M.sensorOk(h, p)
return h ~= nil and p ~= nil and h == h and p == p
end
-- glidende snitt av |e_v| (observer-residual) -> vedvarende stor verdi = modell-/sensorsvikt
local Div = {}
Div.__index = Div
function M.newDivergence(alpha)
return setmetatable({ avg = 0, alpha = alpha or 0.1 }, Div)
end
function Div:update(ev)
self.avg = utils.ema(self.avg, math.abs(ev), self.alpha)
return self.avg
end
-- hover-baseline utganger (brukt i FAULT: hold, ikke null)
function M.holdOutputs(calib)
return calib.uHoverFront, calib.uHoverRear
end
return M

View File

@@ -0,0 +1,55 @@
-- sensors.lua -- peripheral abstraction (§13.1). Isolerer ALL sensor-tilgang bak
-- readAltitude / readPitch / readAirPressure / readAngles + present().
-- Finn paa TYPE (ikke side) saa det er robust mot ombygging.
local M = {}
local altS, gimS
local cfg
function M.init(config)
cfg = config
altS = peripheral.find("altitude_sensor")
gimS = peripheral.find("gimbal_sensor")
return M.present()
end
function M.present()
return (altS ~= nil) and (gimS ~= nil)
end
function M.readAltitude()
if not altS then return nil end
local ok, h = pcall(altS.getHeight)
if not ok or type(h) ~= "number" or h ~= h then return nil end -- nil/NaN guard
return h
end
function M.readAirPressure()
if not altS or not altS.getAirPressure then return nil end
local ok, p = pcall(altS.getAirPressure)
if not ok or type(p) ~= "number" or p ~= p then return nil end
return p
end
local function rawAngles()
local r = { gimS.getAngles() }
if type(r[1]) == "table" then r = r[1] end
return r
end
function M.readAngles()
if not gimS then return nil end
local ok, a = pcall(rawAngles)
if not ok or type(a) ~= "table" then return nil end
return a
end
function M.readPitch()
local a = M.readAngles()
if not a then return nil end
local p = a[(cfg and cfg.pitchIndex) or 1]
if type(p) ~= "number" or p ~= p then return nil end
return p
end
return M

View File

@@ -0,0 +1,66 @@
-- utils.lua -- shared helpers: clamp, round, EMA, ring buffer, least-squares slope.
-- No control logic here; pure functions + one ring-buffer type used by the estimator.
local M = {}
function M.clamp(x, lo, hi)
if x < lo then return lo end
if x > hi then return hi end
return x
end
function M.round(x)
return math.floor(x + 0.5)
end
function M.ema(prev, new, alpha)
if prev == nil then return new end
return alpha * new + (1 - alpha) * prev
end
function M.sign(x)
if x > 0 then return 1 elseif x < 0 then return -1 else return 0 end
end
-- ---- Ring buffer of (t, value) samples kept within maxAge seconds ----
local Ring = {}
Ring.__index = Ring
function M.newRing(maxAge)
return setmetatable({ maxAge = maxAge, ts = {}, vs = {} }, Ring)
end
function Ring:push(t, v)
self.ts[#self.ts + 1] = t
self.vs[#self.vs + 1] = v
-- drop samples older than maxAge relative to the newest sample
while #self.ts > 1 and (t - self.ts[1]) > self.maxAge do
table.remove(self.ts, 1)
table.remove(self.vs, 1)
end
end
function Ring:count() return #self.ts end
function Ring:newest() return self.vs[#self.vs] end
function Ring:span() return (#self.ts >= 2) and (self.ts[#self.ts] - self.ts[1]) or 0 end
-- Least-squares slope of value vs time over the buffer (units: value per second).
-- Time is rebased to the first sample to keep magnitudes small -- os.epoch seconds
-- (~1.7e9) squared overflow double-precision integer range and ruin the fit otherwise.
function Ring:slope()
local n = #self.ts
if n < 2 then return 0 end
local t0 = self.ts[1]
local st, sv, stt, stv = 0, 0, 0, 0
for i = 1, n do
local t = self.ts[i] - t0
local v = self.vs[i]
st = st + t; sv = sv + v
stt = stt + t * t; stv = stv + t * v
end
local denom = n * stt - st * st
if math.abs(denom) < 1e-9 then return 0 end
return (n * stv - st * sv) / denom
end
return M

11
files/autopilot/calib.txt Normal file
View File

@@ -0,0 +1,11 @@
{
pitchGain = 1,
hoverTrim = 1.7889825868927072,
uHoverFront = 3,
lambda = 0.33333333333333331,
g = 1,
pitchTrim = 1.345751507649297,
alphaPitch = 1,
pitchSign = -1,
uHoverRear = 3,
}

171
files/autopilot/cockpit.lua Normal file
View File

@@ -0,0 +1,171 @@
-- cockpit.lua -- pilot-hus konsoll. Status + full live-styring av autopiloten over rednet.
-- To faner: STATUS (fly/standby-styring) og SETTINGS (live brukerinnstillinger). UI = English.
-- ADAPTIV layout: kompakt paa 1x1-monitor, full paa storre.
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 link = require("link")
local mon = peripheral.find("monitor")
if not mon then print("No monitor found. Attach a monitor (Advanced recommended)."); return end
if not link.open() then print("No modem found. Attach a wireless modem."); return end
mon.setTextScale(0.5)
local W, H = mon.getSize()
local C = { bg = colors.black, title = colors.cyan, label = colors.lightGray, val = colors.white,
ok = colors.lime, warn = colors.orange, fault = colors.red, btn = colors.gray, btnTxt = colors.white,
engage = colors.green, stop = colors.red, tabOn = colors.blue, active = colors.green }
-- live-justerbare brukerinnstillinger (syntetiske noekler -> autopiloten mapper til cfg)
local SETTINGS = {
{ key = "climbSpeed", short = "Climb", step = 0.1, fmt = "%.1f", unit = "b/s", def = 0.8 },
{ key = "holdBand", short = "Band", step = 1, fmt = "%.0f", unit = "blk", def = 3 },
{ key = "maxPower", short = "Power", step = 1, fmt = "%.0f", unit = "", def = 6 },
}
local page = "status"
local status, lastRx = nil, -999
local buttons = {}
local function now() return os.clock() end
local function online() return (now() - lastRx) < 2.0 end
local function button(x1, y1, x2, y2, label, action, col)
buttons[#buttons + 1] = { x1 = x1, y1 = y1, x2 = x2, y2 = y2, action = action }
mon.setBackgroundColor(col or C.btn); mon.setTextColor(C.btnTxt)
for y = y1, y2 do mon.setCursorPos(x1, y); mon.write(string.rep(" ", x2 - x1 + 1)) end
mon.setCursorPos(x1 + math.max(0, math.floor((x2 - x1 + 1 - #label) / 2)), y1 + math.floor((y2 - y1) / 2))
mon.write(label); mon.setBackgroundColor(C.bg)
end
local function center(y, text, color)
mon.setTextColor(color or C.val)
mon.setCursorPos(math.max(1, math.floor((W - #text) / 2) + 1), y); mon.write(text:sub(1, W))
end
local function putline(y, text, color)
mon.setTextColor(color or C.val); mon.setCursorPos(1, y); mon.write(text:sub(1, W))
end
local function drawTabs()
local hw = math.floor(W / 2)
button(1, 1, hw, 1, "STATUS", function() page = "status" end, page == "status" and C.tabOn or C.btn)
button(hw + 1, 1, W, 1, "SETUP", function() page = "settings" end, page == "settings" and C.tabOn or C.btn)
end
local function curVal(p)
if status and status[p.key] ~= nil then return status[p.key] end
return p.def
end
local function drawStatus()
local compact = W < 28
local bRowH = (H >= 16) and 2 or 1
local landTop = H - bRowH + 1
local tgtTop = landTop - bRowH
local top, statusMax = 2, tgtTop - 1
if not online() then
center(math.floor((top + statusMax) / 2), "OFFLINE", C.fault)
if not compact then center(math.floor((top + statusMax) / 2) + 1, "no link to machine room", C.warn) end
return
end
local s = status
local inFlight = s.state ~= "IDLE" and s.state ~= "SETUP NEEDED"
local stateColor = C.fault
if s.state == "FLYING" then stateColor = C.ok
elseif s.state == "IDLE" then stateColor = (s.ready and C.title or C.warn) end
local pcolor = (math.abs(s.pitch or 0) < 3) and C.ok or C.warn
-- bygg status-linjer
local lines = {}
lines[#lines + 1] = { tostring(s.state) .. (s.hold and " HOLD" or ""), stateColor }
if s.reason and s.reason ~= "" then lines[#lines + 1] = { tostring(s.reason), C.fault } end
if compact then
lines[#lines + 1] = { string.format("%.0f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f", s.pitch or 0), pcolor }
if inFlight then lines[#lines + 1] = { string.format("VSPD %+.2f", s.vVel or 0), C.val } end
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup", s.ready and C.ok or C.warn } end
else
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup (Wiring+Sign)", s.ready and C.ok or C.warn } end
lines[#lines + 1] = { string.format("ALT %.1f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f deg", s.pitch or 0), pcolor }
if inFlight then
lines[#lines + 1] = { string.format("VSPD %+.2f b/s", s.vVel or 0), C.val }
lines[#lines + 1] = { string.format("OUT f%d a%d lift %.1f", s.fore or 0, s.aft or 0, s.uSum or 0), C.val }
end
end
-- render: kompakt = sentrert + luftig (fyll den ledige plassen); full = venstrejustert top-down
if compact then
local avail = statusMax - top + 1
local gap = (avail >= 2 * #lines - 1) and 1 or 0
local blockH = #lines + (#lines - 1) * gap
local startY = top + math.max(0, math.floor((avail - blockH) / 2))
for i, L in ipairs(lines) do
local y = startY + (i - 1) * (1 + gap)
if y >= top and y <= statusMax then center(y, L[1], L[2]) end
end
else
for i, L in ipairs(lines) do local y = top + i - 1; if y <= statusMax then putline(y, L[1], L[2]) end end
end
-- knapper (HOLD/LAND lyser solid naar den modusen er aktiv)
if inFlight then
local bw = math.floor(W / 4)
button(1, tgtTop, bw, tgtTop + bRowH - 1, "-5", function() link.sendCommand({ cmd = "adjust", delta = -5 }) end)
button(bw + 1, tgtTop, 2 * bw, tgtTop + bRowH - 1, "-1", function() link.sendCommand({ cmd = "adjust", delta = -1 }) end)
button(2 * bw + 1, tgtTop, 3 * bw, tgtTop + bRowH - 1, "+1", function() link.sendCommand({ cmd = "adjust", delta = 1 }) end)
button(3 * bw + 1, tgtTop, W, tgtTop + bRowH - 1, "+5", function() link.sendCommand({ cmd = "adjust", delta = 5 }) end)
local tw = math.floor(W / 3)
button(1, landTop, tw, H, "HOLD", function() link.sendCommand({ cmd = "hold" }) end, (s.mode == "hold") and C.active or C.btn)
button(tw + 1, landTop, 2 * tw, H, "LAND", function() link.sendCommand({ cmd = "land" }) end, (s.mode == "land") and C.active or C.btn)
button(2 * tw + 1, landTop, W, H, "STOP", function() link.sendCommand({ cmd = "stop" }) end, C.stop)
else
button(1, tgtTop, W, H, "ENGAGE FLY", function() link.sendCommand({ cmd = "engage" }) end, C.engage)
end
end
local function drawSettings()
local n = #SETTINGS
local areaTop = 2
local blockH = math.max(1, math.floor((H - areaTop + 1) / n))
local bw = math.max(3, math.floor(W / 5))
for i, p in ipairs(SETTINGS) do
local y1 = areaTop + (i - 1) * blockH
local y2 = (i == n) and H or (y1 + blockH - 1)
local cur = curVal(p)
button(1, y1, bw, y2, "-", function() link.sendCommand({ cmd = "set", key = p.key, value = cur - p.step }) end)
button(W - bw + 1, y1, W, y2, "+", function() link.sendCommand({ cmd = "set", key = p.key, value = cur + p.step }) end)
local txt = string.format("%s %s%s", p.short, string.format(p.fmt, cur), p.unit)
mon.setTextColor(C.val)
mon.setCursorPos(bw + 1 + math.max(0, math.floor((W - 2 * bw - #txt) / 2)), math.floor((y1 + y2) / 2))
mon.write(txt)
end
end
local function draw()
mon.setBackgroundColor(C.bg); mon.clear()
buttons = {}
drawTabs()
if page == "settings" then drawSettings() else drawStatus() end
end
local function handleTouch(x, y)
for _, b in ipairs(buttons) do
if x >= b.x1 and x <= b.x2 and y >= b.y1 and y <= b.y2 then b.action(); draw(); return end
end
end
draw()
local redraw = os.startTimer(0.5)
while true do
local e = { os.pullEvent() }
if e[1] == "rednet_message" and e[4] == link.STATUS then
status = e[3]; lastRx = now()
elseif e[1] == "monitor_touch" then
handleTouch(e[3], e[4])
elseif e[1] == "timer" and e[2] == redraw then
redraw = os.startTimer(0.5); draw()
end
end

50
files/autopilot/diag.lua Normal file
View File

@@ -0,0 +1,50 @@
-- diag.lua
-- Utforsker alle peripheraler og proever aa kalle hver metode (uten argumenter)
-- for aa se hvilke data som er tilgjengelige -- spesielt fra contraption diagram-blokken.
-- Skriver til skjerm OG diag.log.
local OUT = "diag.log"
if fs.exists(OUT) then fs.delete(OUT) end
local file = fs.open(OUT, "w")
local function emit(s)
print(s)
file.writeLine(s)
end
local function dump(v)
if type(v) == "table" then
return textutils.serialise(v):gsub("%s+", " ")
end
return tostring(v)
end
local names = peripheral.getNames()
emit("=== PERIPHERAL-UTFORSKER ===")
emit("Fant " .. #names .. " peripheral(er)")
emit("")
for _, name in ipairs(names) do
local ptype = peripheral.getType(name)
emit("##########################################")
emit("Navn: " .. name .. " Type: " .. tostring(ptype))
emit("------------------------------------------")
local methods = peripheral.getMethods(name)
table.sort(methods)
for _, m in ipairs(methods) do
-- proev aa kalle metoden uten argumenter; mange er gettere
local ok, res = pcall(function() return peripheral.call(name, m) end)
if ok then
emit(string.format(" %s() = %s", m, dump(res)))
else
-- feilet (trenger nok argumenter) -- bare noter signaturen
emit(string.format(" %s() -> [krever arg / feil: %s]", m, tostring(res):gsub("%s+"," ")))
end
end
emit("")
end
emit("=== FERDIG -> " .. OUT .. " ===")
file.close()

27
files/autopilot/manifest Normal file
View File

@@ -0,0 +1,27 @@
autopilot/altitude.lua
autopilot/calibration.lua
autopilot/config.lua
autopilot/estimator.lua
autopilot/link.lua
autopilot/logger.lua
autopilot/main.lua
autopilot/mixer.lua
autopilot/observer.lua
autopilot/pitch.lua
autopilot/safety.lua
autopilot/sensors.lua
autopilot/utils.lua
calib.txt
cockpit.lua
diag.lua
pid_gains.txt
pilot_startup.lua
probe.lua
scan.lua
scan.txt
sides.txt
stabilize.lua
stabilizev2.lua
stabilizeV2.lua
startup.lua
target.txt

View File

@@ -0,0 +1,16 @@
{
A_KI = 0.04,
P_KI = 0.05,
A_KD = 2.2,
P_KD = 0.7,
hoverMap = {},
altI = -0.27424191075575,
kCouple = -0.099857552664046,
lag = 5.4,
A_KP = 0.22,
P_KP = 0.22,
TRIM = 1.5,
pitchI = 20.305881842314,
K_PRATE = 0.08,
K_RATE = 1.2,
}

View File

@@ -0,0 +1,2 @@
-- startup for pilot-hus PC: auto-run cockpit
shell.run("cockpit")

30
files/autopilot/probe.lua Normal file
View File

@@ -0,0 +1,30 @@
-- probe.lua -- list every peripheral this computer can see, with its methods.
-- Drop a computer next to a block (or wire it up) and run `probe`. UI text in English.
local function dump(name)
print(name .. " -> type: " .. tostring(peripheral.getType(name)))
local methods = peripheral.getMethods(name)
if methods and #methods > 0 then
table.sort(methods)
for _, m in ipairs(methods) do print(" ." .. m) end
else
print(" (no methods)")
end
end
print("=== DIRECT SIDES ===")
local any = false
for _, side in ipairs({ "top", "bottom", "left", "right", "front", "back" }) do
if peripheral.isPresent(side) then any = true; dump(side) else print(side .. " -> (nothing)") end
end
print("")
print("=== ALL NAMES (incl. wired modem network) ===")
local names = peripheral.getNames()
if #names == 0 then print("(none)") end
for _, n in ipairs(names) do dump(n) end
if not any and #names == 0 then
print("")
print(">>> Nothing exposes a peripheral here.")
end

45
files/autopilot/scan.lua Normal file
View File

@@ -0,0 +1,45 @@
-- scan.lua
-- Oppdager peripheraler paa det monterte luftskipet.
-- Skriver til skjerm OG til scan.txt slik at resultatet kan deles.
-- Kjor paa CC-computeren NAR skipet er montert/lever.
local OUT = "scan.txt"
local file = fs.open(OUT, "w")
local function emit(line)
print(line)
file.writeLine(line)
end
emit("=== PERIPHERAL-SCAN ===")
emit("")
local names = peripheral.getNames()
if #names == 0 then
emit("Ingen peripheraler funnet.")
emit("Sjekk at skipet er montert og at computeren sitter paa contraptionen.")
file.close()
return
end
for _, name in ipairs(names) do
local ptype = peripheral.getType(name)
emit("------------------------------------------")
emit("Navn: " .. tostring(name))
emit("Type: " .. tostring(ptype))
emit("Metoder:")
local methods = peripheral.getMethods(name)
table.sort(methods)
for _, m in ipairs(methods) do
emit(" " .. m)
end
emit("")
end
emit("==========================================")
emit("Totalt " .. #names .. " peripheral(er).")
emit("Lagret til " .. OUT)
file.close()

27
files/autopilot/scan.txt Normal file
View File

@@ -0,0 +1,27 @@
=== PERIPHERAL-SCAN ===
------------------------------------------
Navn: bottom
Type: create:fluid_tank
Metoder:
pullFluid
pushFluid
tanks
------------------------------------------
Navn: top
Type: gimbal_sensor
Metoder:
getAngles
getAnglesRad
------------------------------------------
Navn: right
Type: altitude_sensor
Metoder:
getAirPressure
getHeight
==========================================
Totalt 3 peripheral(er).
Lagret til scan.txt

View File

@@ -0,0 +1,4 @@
{
aft = "back",
fore = "right",
}

View File

@@ -0,0 +1,571 @@
-- stabilize.lua -- GENERELT selvkalibrerende hoyde+pitch PID for ballong-luftskip.
--
-- Ingen ballong-spesifikke tall hardkodet:
-- * svevegass laeres av hoyde-integralet
-- * trim-skjevhet (volum/CoM) laeres av pitch-integralet
-- * pitch-fortegn auto-detekteres ved oppstart
-- Det eneste skip-spesifikke er hvilken redstone-side hver ventil sitter paa.
--
-- Sensorer (auto-funnet paa type): altitude_sensor (getHeight), gimbal_sensor (getAngles)
-- Aktuator: redstone analog 0..MAX_SIGNAL -> ventil (mer = mer gass = mer loft)
--
-- BRUK:
-- stabilize cal -> logger sensoravlesninger til cal.log (styrer ingenting)
-- stabilize test -> kjente ventil-monstre -> test.log
-- stabilize -> kjorer regulatoren -> run.log
-- ============================ KONFIG ============================
-- Skip-spesifikt: hvilke redstone-sider mater ventilene ("left"/"right"/"front"/"back")
local FORE_SIDE = "right"
local AFT_SIDE = "back" -- nil hvis bare EN ventil (da deaktiveres pitch-styring)
local TARGET_PITCH = 0 -- onsket pitch (vannrett)
local TAKEOFF_OFFSET = 5 -- mal settes saa mange blokker over startposisjon
-- Start-gains (dempet; auto-tuneren justerer og lagrer dem videre)
local A_KP, A_KI, A_KD = 0.22, 0.04, 2.2 -- hoyde
local P_KP, P_KI, P_KD = 0.22, 0.05, 0.70 -- pitch
local AUTOTUNE = true -- maaler egen svingning og justerer gains
local TUNE_INTERVAL = 12 -- sekunder mellom hver justering (KI laerer trim-skjevheten)
local MAX_SIGNAL = 7 -- maks redstone. Hold i responsivt omraade (loftet ditt topper ~5).
local MIN_SIGNAL = 1 -- gulv paa TOTAL-loft (collective) -> mykt fall, aldri fritt fall.
local LAG = 2.0 -- sek: tregheten i loft-respons. Regulatoren forutser saa langt fram.
local SLEW = 2 -- maks signal-endring per tick (demper voldsomme hopp)
local HOVER_SEED = 4.5 -- start-gjetning paa svevegass (naer ekte hover; kartet finjusterer)
-- Pitch-fortegn: 0 = auto-detekter ved oppstart, ellers +1/-1 manuelt
local PITCH_SIGN = 0
local PITCH_INDEX = 1 -- hvilket tall fra getAngles er pitch
-- Rock-solid hold (Tier 1) + place-and-forget (Tier 2)
local DEADBAND_H = 1.5 -- blokker: innenfor dette holdes hoyden rolig (ingen twitch)
local DEADBAND_P = 2.0 -- grader: innenfor dette holdes pitch rolig
local DERIV_FILTER = 0.3 -- lavpass paa avledning (0..1; lavere = mykere)
local SETTLE_TIME = 3.0 -- sek innenfor dodband for "STABIL"
-- KASKADE-regulering for hoyde (hindrer fartsoppbygging -> oversving)
local MAX_RATE = 2.0 -- blokker/sek: maks klatre-/synkerate
local K_POS = 0.4 -- posisjonsfeil -> onsket rate (5 blokker feil -> maks rate)
local K_RATE = 1.2 -- rate-feil -> gass (auto-tunes)
-- Grasios bevegelse + pitch-kaskade + robusthet
local MAX_ACCEL = 1.0 -- blokker/sek^2: myk ease-in/ut paa rate-endring
local MAX_PITCH_RATE = 4.0 -- grader/sek: maks pitch-endringsrate (grasios attityde)
local K_POS_P = 0.5 -- pitch-feil -> onsket pitch-rate
local K_PRATE = 0.08 -- pitch-rate-feil -> differanse (auto-tunes), mildt mot lag
local MAX_DIFF = 4.0 -- maks differanse: pitch kan ALDRI pumpe all gass til en ende
local GLITCH_H = 25 -- avvis hoyde-hopp storre enn dette per tick (sensor-glitch)
local DT = 0.2
-- ===============================================================
-- ---- selvlaert modell (Fase C/D): persisteres i profilen ----
local BIN_SIZE = 10 -- blokker per kart-bin
local hoverMap = {} -- hoyde-bin -> svevegass (laert kurve)
local kCouple = 0 -- collective->pitch koblingskoeffisient (laert)
local TRIM = 0 -- statisk balanse-differanse (maalt i balance-modus)
local altS = peripheral.find("altitude_sensor")
local gimS = peripheral.find("gimbal_sensor")
-- hoverFF: interpoler laert svevegass for en hoyde (fallback = HOVER_SEED)
local function hoverFF(h)
local b = h / BIN_SIZE
local lo, hi = math.floor(b), math.ceil(b)
local vlo, vhi = hoverMap[lo], hoverMap[hi]
if vlo and vhi then
if lo == hi then return vlo end
return vlo + (vhi - vlo) * (b - lo)
elseif vlo then return vlo
elseif vhi then return vhi end
local best, bestd
for k, v in pairs(hoverMap) do
local d = math.abs(k - b)
if not bestd or d < bestd then bestd, best = d, v end
end
return best or HOVER_SEED
end
-- learnHover: oppdater kart-bin med glidende snitt naar skipet er stabilt
local function learnHover(h, c)
local b = math.floor(h / BIN_SIZE + 0.5)
hoverMap[b] = hoverMap[b] and (hoverMap[b]*0.9 + c*0.1) or c
end
local function mapCount()
local n = 0; for _ in pairs(hoverMap) do n = n + 1 end; return n
end
local function logline(path, text)
local f = fs.open(path, "a"); if f then f.writeLine(text); f.close() end
end
local function angles()
local r = { gimS.getAngles() }
if type(r[1]) == "table" then r = r[1] end
return r
end
local function pitchRaw() return angles()[PITCH_INDEX] or 0 end
local function clampSig(x)
x = math.floor(x + 0.5)
return math.max(0, math.min(MAX_SIGNAL, x))
end
-- slew-begrenset utgang (mot forrige verdi)
local curFore, curAft = 0, 0
local function driveValves(fore, aft)
fore = clampSig(fore); aft = clampSig(aft)
if fore - curFore > SLEW then fore = curFore + SLEW end
if fore - curFore < -SLEW then fore = curFore - SLEW end
if aft - curAft > SLEW then aft = curAft + SLEW end
if aft - curAft < -SLEW then aft = curAft - SLEW end
curFore, curAft = fore, aft
redstone.setAnalogOutput(FORE_SIDE, fore)
if AFT_SIDE then redstone.setAnalogOutput(AFT_SIDE, aft) end
return fore, aft
end
local function allOff()
redstone.setAnalogOutput(FORE_SIDE, 0)
if AFT_SIDE then redstone.setAnalogOutput(AFT_SIDE, 0) end
curFore, curAft = 0, 0
end
-- ---- persistens: lagrede gains overlever omstart ----
local GAINS_FILE = "pid_gains.txt"
-- Lagrer gains (respons), arbeidspunkt OG laert modell (kart/kobling/lag).
local function saveState(altI, pitchI)
local f = fs.open(GAINS_FILE, "w")
f.write(textutils.serialise({A_KP=A_KP, A_KI=A_KI, A_KD=A_KD, P_KP=P_KP, P_KI=P_KI, P_KD=P_KD,
K_RATE=K_RATE, K_PRATE=K_PRATE, altI=altI, pitchI=pitchI, hoverMap=hoverMap, kCouple=kCouple, lag=LAG, TRIM=TRIM}))
f.close()
end
local function loadState()
if not fs.exists(GAINS_FILE) then return nil end
local f = fs.open(GAINS_FILE, "r"); local s = f.readAll(); f.close()
local g = textutils.unserialise(s)
if type(g) ~= "table" then return nil end
A_KP, A_KI, A_KD = g.A_KP or A_KP, g.A_KI or A_KI, g.A_KD or A_KD
P_KP, P_KI, P_KD = g.P_KP or P_KP, g.P_KI or P_KI, g.P_KD or P_KD
-- sikkerhet: kapp lagrede gains saa en korrupt profil ikke kan laste inn vanvittige verdier
A_KP, A_KD = math.min(A_KP, 0.6), math.min(A_KD, 3.0)
P_KP, P_KD = math.min(P_KP, 0.5), math.min(P_KD, 2.0)
K_RATE = math.min(g.K_RATE or K_RATE, 4.0)
K_PRATE = math.min(g.K_PRATE or K_PRATE, 1.0)
if type(g.hoverMap) == "table" then
for k, v in pairs(g.hoverMap) do hoverMap[tonumber(k) or k] = v end
end
kCouple = g.kCouple or kCouple
LAG = g.lag or LAG
TRIM = g.TRIM or TRIM
return g
end
-- ---- malhoyde: egen fil saa den overlever reload (place-and-forget) ----
local TARGET_FILE = "target.txt"
local function saveTarget(h)
local f = fs.open(TARGET_FILE, "w"); f.write(tostring(h)); 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
-- ---- analyse av et feilvindu: amplitude (topp-til-topp) + null-kryssinger ----
local function analyze(win)
local mn, mx, cross = math.huge, -math.huge, 0
for i, v in ipairs(win) do
if v < mn then mn = v end
if v > mx then mx = v end
if i > 1 and ((win[i-1] < 0) ~= (v < 0)) then cross = cross + 1 end
end
return (mx - mn), cross
end
-- ============================ MENY (UI) ============================
local function pause() print(""); print("[ Enter for menu ]"); read() end
local function menu()
while true do
term.clear(); term.setCursorPos(1,1)
print("====== BALLOON AUTOPILOT ======")
print("")
print(" 1) Start stabilization")
print(" 2) Set target altitude")
print(" 3) Balance check (level the ship)")
print(" 4) Calibrate sensors")
print(" 5) Actuator test")
print(" 6) Reset profile (delete learned data)")
print(" 7) Show saved profile")
print(" 8) Exit")
print("")
local sensorsOk = peripheral.find("altitude_sensor") and peripheral.find("gimbal_sensor")
print(sensorsOk and "Sensors: OK" or "Sensors: MISSING - check wiring")
print(fs.exists(GAINS_FILE) and "Profile: saved" or "Profile: none (fresh)")
local tH = loadTarget()
print("Target: " .. (tH and string.format("%.1f", tH) or "where I start"))
print("")
write("Choice: ")
local k = read()
if k == "1" then return "run"
elseif k == "2" then
write("Target altitude (empty = where I start): ")
local s = read()
if s == "" then
if fs.exists(TARGET_FILE) then fs.delete(TARGET_FILE) end
print("Target cleared - holds where it starts.")
elseif tonumber(s) then
saveTarget(tonumber(s)); print("Target altitude set to " .. s)
else print("Invalid number.") end
pause()
elseif k == "3" then return "balance"
elseif k == "4" then return "cal"
elseif k == "5" then return "test"
elseif k == "6" then
if fs.exists(GAINS_FILE) then fs.delete(GAINS_FILE) end
print("Profile reset - fresh calibration next start."); pause()
elseif k == "7" then
if fs.exists(GAINS_FILE) then
local f = fs.open(GAINS_FILE, "r"); print(f.readAll()); f.close()
else print("No saved profile yet.") end
pause()
elseif k == "8" then return "exit"
end
end
end
-- ============================ AUTO-SIGN ============================
-- Maaler hvilken vei pitch beveger seg av positiv differanse + dodtid (lag).
-- Krever at skipet kan tilte (helst i lufta). Faller tilbake til +1 hvis ingen respons.
local function detectPitchSign()
if not AFT_SIDE then return 1 end
print("Auto-sign: measuring lag, lifting, nudging...")
local h0 = altS.getHeight()
local stepLvl = math.min(MAX_SIGNAL, 5)
local lvl = stepLvl
local t0 = os.clock()
local lagMeasured = nil
while os.clock() - t0 < 12 do
driveValves(stepLvl, stepLvl)
local rise = altS.getHeight() - h0
if rise > 1 and not lagMeasured then lagMeasured = os.clock() - t0 end
if rise > 2 then break end -- luftbaaren
sleep(0.2)
end
if lagMeasured then
LAG = math.max(0.5, math.min(6, lagMeasured))
print(string.format("Measured lag = %.1f s", LAG))
end
sleep(0.5)
local p0 = pitchRaw()
local t1 = os.clock()
while os.clock() - t1 < 3 do
driveValves(math.min(MAX_SIGNAL, lvl + 3), math.max(0, lvl - 3))
sleep(0.2)
end
local p1 = pitchRaw()
allOff()
local dp = p1 - p0
if math.abs(dp) < 1.0 then
print("Weak response -- using +1. Override PITCH_SIGN if wrong.")
return 1
end
local s = dp >= 0 and 1 or -1 -- sign(dp): gir negativ tilbakekobling
print(string.format("dpitch %.1f -> PITCH_SIGN = %d", dp, s))
sleep(1)
return s
end
-- modus fra argument, ellers vis meny
local mode = ({...})[1]
if not mode then mode = menu() end
if mode == "exit" then return end
-- alle modus trenger sensorene
if not (altS and gimS) then
print("Sensors missing (altitude_sensor + gimbal_sensor). Check wiring.")
return
end
-- ============================ AUTO-BALANCE (BRUTE-FORCE SWEEP) ============================
-- Sveiper TRIM gjennom hele omraadet, maaler pitch ved hver verdi, velger den flateste.
-- Trenger INGEN fortegn-deteksjon -> enkelt og robust. Lagres som permanent TRIM.
if mode == "balance" then
local g0 = loadState() -- behold eksisterende laerte data
local ground = altS.getHeight()
local target = ground + 10
local AIRBORNE = ground + 4 -- under dette = staar paa bakken (ugyldig)
local SWEEP_MAX, STEP = 6, 0.5
local MAXWAIT, WIN, SETTLE = 18, 14, 1.2
-- 1) LOFT skipet til luftbaaren (begge ventiler likt) FOR vi maaler noe
print("AUTO-BALANCE - lifting ship first...")
local liftOk = false
local tLift = os.clock()
while os.clock() - tLift < 18 do
local h = altS.getHeight()
local g = clampSig(HOVER_SEED + 0.7*(target - h))
driveValves(g, g)
if h > ground + 6 then liftOk = true; break end
term.clear(); term.setCursorPos(1,1)
print("AUTO-BALANCE")
print(string.format("lifting... %.1f / %.0f", h, target))
sleep(0.2)
end
if not liftOk then
allOff(); print("Could not get airborne - balloons too weak?"); return
end
-- 2) SVEIP trim, men KUN gyldig naar skipet faktisk svever (h >= AIRBORNE)
local best, bestAbs = nil, math.huge
local ok, err = pcall(function()
local t = -SWEEP_MAX
while t <= SWEEP_MAX + 0.001 do
local hist, tStart, settledP = {}, os.clock(), nil
while os.clock() - tStart < MAXWAIT do
local h = altS.getHeight()
local coll = clampSig(HOVER_SEED + 0.7*(target - h)) -- hold hoyden
local fore, aft = driveValves(coll + t, coll - t)
local p = pitchRaw()
hist[#hist+1] = p; if #hist > WIN then table.remove(hist, 1) end
local mn, mx = math.huge, -math.huge
for _, v in ipairs(hist) do mn = math.min(mn, v); mx = math.max(mx, v) end
local spread = (#hist >= WIN) and (mx - mn) or 999
if spread < SETTLE and h >= AIRBORNE then settledP = (mn + mx)/2 end -- gyldig KUN luftbaaren
term.clear(); term.setCursorPos(1,1)
print("==== AUTO-BALANCE SWEEP ====")
print(string.format(" TRIM %+.1f pitch %+.1f", t, p))
print(string.format(" fore %d/aft %d height %.1f", fore, aft, h))
print(" " .. (h < AIRBORNE and "SINKING - invalid trim" or (settledP and "settled" or "settling...")))
print(string.format(" best: %s", best and string.format("TRIM %+.1f (|p| %.1f)", best, bestAbs) or "none yet"))
if settledP then break end
sleep(0.2)
end
-- bare gyldig hvis den satte seg MENS luftbaaren
if settledP and math.abs(settledP) < bestAbs then
best, bestAbs = t, math.abs(settledP)
TRIM = best; saveState(g0 and g0.altI or 0, g0 and g0.pitchI or 0) -- lagre lopende
end
t = t + STEP
end
end)
allOff()
if best then
TRIM = best; saveState(g0 and g0.altI or 0, g0 and g0.pitchI or 0)
print(string.format("Best TRIM = %+.2f (|pitch| %.1f). Saved.", best, bestAbs))
else
print("No valid (airborne) trim found.")
end
if not ok and err ~= "Terminated" then error(err, 0) end
return
end
-- ============================ CAL ============================
if mode == "cal" then
local LOG = "cal.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, "# cal: t, height, airpressure, a1, a2, a3")
print("CALIBRATION -> cal.log (controls nothing). CTRL+T to stop.")
while true do
local h = altS.getHeight()
local press = altS.getAirPressure and altS.getAirPressure() or 0
local a = angles()
logline(LOG, string.format("%.2f, %.2f, %.2f, %.3f, %.3f, %.3f",
os.clock(), h, press or 0, a[1] or 0, a[2] or 0, a[3] or 0))
term.clear(); term.setCursorPos(1,1)
print(string.format("Height %.1f Pressure %.2f", h, press or 0))
print(string.format("Angles %.2f / %.2f / %.2f", a[1] or 0, a[2] or 0, a[3] or 0))
sleep(0.3)
end
end
-- ============================ TEST ============================
if mode == "test" then
local LOG = "test.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, "# test: t, phase, fore, aft, height, pitch")
local steps = {
{"balanced", 6, 6, 8}, {"more_FRONT", 10, 3, 8},
{"balanced", 6, 6, 6}, {"more_AFT", 3, 10, 8}, {"off", 0, 0, 4},
}
print("TEST MODE -> test.log. CTRL+T to stop.")
local ok, err = pcall(function()
for _, s in ipairs(steps) do
local phase, fore, aft, secs = s[1], s[2], s[3], s[4]
local t0 = os.clock()
while os.clock() - t0 < secs do
driveValves(fore, aft)
local h, p = altS.getHeight(), pitchRaw()
logline(LOG, string.format("%.2f, %s, %d, %d, %.2f, %.2f", os.clock(), phase, fore, aft, h, p))
term.clear(); term.setCursorPos(1,1)
print("Phase: "..phase); print(string.format("fore %d aft %d", fore, aft))
print(string.format("Height %.1f Pitch %.1f", h, p))
sleep(0.2)
end
end
end)
allOff(); print("Valves 0. Test done -> test.log.")
if not ok and err ~= "Terminated" then error(err, 0) end
return
end
-- ============================ REGULERING ============================
local loaded = loadState()
if loaded then print(string.format("Loaded profile (%d map pts, lag %.1f, kC %.2f)",
mapCount(), LAG, kCouple)) end
local sign = PITCH_SIGN
if sign == 0 then sign = detectPitchSign() end -- kan over-skrive lag med fersk maling
-- Malhoyde: lagret (place-and-forget) ELLER der skipet starter
local TARGET_FINAL = loadTarget() or (altS.getHeight() + TAKEOFF_OFFSET)
print(string.format("Target = %.1f (sign %d)", TARGET_FINAL, sign))
local LOG = "run.log"; if fs.exists(LOG) then fs.delete(LOG) end
logline(LOG, string.format("# run: target_h=%.1f sign=%d A(%.2f,%.3f,%.2f) P(%.2f,%.3f,%.2f) max=%d",
TARGET_FINAL, sign, A_KP, A_KI, A_KD, P_KP, P_KI, P_KD, MAX_SIGNAL))
logline(LOG, "# t, height, pitch, eH, eP, collective, diff, fore, aft")
-- Integralet trimmer nå bare RESTEN (hoverFF gir grunngassen) -> start nær 0
local altI = (loaded and loaded.altI) or 0
local pitchI = (loaded and loaded.pitchI) or 0
local lastH = altS.getHeight()
local lastP = pitchRaw()
local fVspd, fPr = 0, 0 -- filtrerte avledninger (lavpass)
local cmdRate = 0 -- akselerasjons-begrenset onsket rate (grasios bevegelse)
local badCount = 0 -- online fortegns-vakt
local winH, winP = {}, {} -- feilvinduer for auto-tuning
local lastTune = os.clock()
local tuneMsg = "waiting..."
local settledFor = 0 -- sek innenfor dodband
print("Self-calibrating PID active -> run.log. CTRL+T to stop.")
local ok, err = pcall(function()
while true do
-- glitch-vern: avvis urealistiske sensor-hopp (robusthet)
local rawH = altS.getHeight()
local height = (lastH and math.abs(rawH - lastH) > GLITCH_H) and lastH or rawH
local pitch = pitchRaw()
-- HOYDE KASKADE: posisjon -> begrenset+myk onsket rate -> gass
local vspd = (height - lastH) / DT; lastH = height
fVspd = DERIV_FILTER*vspd + (1-DERIV_FILTER)*fVspd -- lavpass paa stigerate
local eH = TARGET_FINAL - height
local inH = math.abs(eH) < DEADBAND_H
local ff = hoverFF(TARGET_FINAL) -- laert svevegass for malet
local desRate = math.max(-MAX_RATE, math.min(MAX_RATE, K_POS*eH)) -- ytre: begrenset rate
-- akselerasjons-grense: ramp cmdRate mot desRate -> myk ease-in/ease-out
local dr = math.max(-MAX_ACCEL*DT, math.min(MAX_ACCEL*DT, desRate - cmdRate))
cmdRate = cmdRate + dr
local rateErr = cmdRate - fVspd -- indre: hold raten
local rawColl = ff + A_KI*altI + K_RATE*rateErr
local collective = math.max(0, math.min(MAX_SIGNAL, rawColl)) -- KLEM til ventilomraade
local excess = collective - ff -- klatrekraft (for avkobling)
-- integral paa RATE-feil (IKKE posisjon!) -> trimmer FF-gass, kan ikke vinde opp paa stor eH
local satLow, satHigh = rawColl <= 0, rawColl >= MAX_SIGNAL
if (not satLow and not satHigh) or (satLow and rateErr > 0) or (satHigh and rateErr < 0) then
altI = math.max(-150, math.min(150, altI + rateErr*DT))
end
-- PITCH KASKADE: pitch-feil -> begrenset onsket pitch-rate -> differanse (grasios attityde)
local diff, eP = 0, 0
local inP = true
if AFT_SIDE then
local pr = (pitch - lastP) / DT; lastP = pitch
fPr = DERIV_FILTER*pr + (1-DERIV_FILTER)*fPr
eP = TARGET_PITCH - pitch
inP = math.abs(eP) < DEADBAND_P
local desPRate = math.max(-MAX_PITCH_RATE, math.min(MAX_PITCH_RATE, K_POS_P*eP))
local pRateErr = desPRate - fPr
-- avkobling (kCouple*excess) motvirker dreiemoment fra gass-endring + laert trim
-- dynamisk pitch-korreksjon klemmes (mot bang-bang), saa legges statisk TRIM oppaa
local dyn = sign * (K_PRATE*pRateErr + P_KI*pitchI + kCouple*excess)
dyn = math.max(-MAX_DIFF, math.min(MAX_DIFF, dyn))
diff = TRIM + dyn -- per-ventil clampSig i driveValves binder sluttverdien
-- laer decoupling: RIKTIG fortegn (gradient) -> kansellerer klatre-pitchen
if math.abs(excess) > 0.5 then
kCouple = math.max(-2, math.min(2, kCouple + 0.002 * eP * excess))
end
local fSat = (collective+diff) <= 0 or (collective+diff) >= MAX_SIGNAL
local aSat = (collective-diff) <= 0 or (collective-diff) >= MAX_SIGNAL
if not inP and not (fSat or aSat) then pitchI = math.max(-200, math.min(200, pitchI + sign*eP*DT)) end
if math.abs(pitch) > 25 then badCount = badCount + 1 else badCount = 0 end
if badCount >= 20 then
sign = -sign; pitchI = 0; badCount = 0
print("Pitch diverging -> flipping sign to " .. sign)
end
end
-- settle-deteksjon: innenfor dodband paa begge akser + naa malet
local atTarget = math.abs(TARGET_FINAL - height) < DEADBAND_H
if inH and inP and atTarget then settledFor = settledFor + DT else settledFor = 0 end
local settled = settledFor >= SETTLE_TIME
-- LAER svevegass-kart naar stabilt -> kartet overtar, integralet lekkes mot 0
if inH and inP and math.abs(fVspd) < 0.4 then
learnHover(height, collective)
altI = altI * 0.97
end
-- gulv paa TOTAL-loft (ikke per ventil): kan synke til mal + beholder pitch-differanse
collective = math.max(MIN_SIGNAL, collective)
local fore, aft = driveValves(collective + diff, collective - diff)
-- ---- AUTO-TUNING: KUN naar nær malet (hold) -> reise-transienter lurer den ikke ----
local tuneNear = math.abs(TARGET_FINAL - height) < 5
and (not AFT_SIDE or math.abs(TARGET_PITCH - pitch) < 8)
if AUTOTUNE and not tuneNear then
winH, winP = {}, {}; lastTune = os.clock(); tuneMsg = "traveling (no tune)"
elseif AUTOTUNE then
winH[#winH+1] = eH
winP[#winP+1] = (TARGET_PITCH - pitch)
if os.clock() - lastTune >= TUNE_INTERVAL then
local ampH, crH = analyze(winH)
if crH >= 4 and ampH > 4 then -- oscillating -> damp inner rate gain
K_RATE = math.max(0.2, K_RATE*0.82)
tuneMsg = "alt: damping"
elseif ampH > 5 and crH < 2 then -- genuinely sluggish near target -> mild sharpen
K_RATE = math.min(4.0, K_RATE*1.08)
tuneMsg = "alt: sharpening"
else tuneMsg = "alt: ok" end
if AFT_SIDE then
local ampP, crP = analyze(winP)
if crP >= 4 and ampP > 6 then
K_PRATE = math.max(0.03, K_PRATE*0.82)
tuneMsg = tuneMsg .. " | pitch: damping"
elseif ampP > 7 and crP < 2 then
K_PRATE = math.min(0.15, K_PRATE*1.05) -- pitch: hold mildt, svingning er verre enn treghet
tuneMsg = tuneMsg .. " | pitch: sharpening"
else tuneMsg = tuneMsg .. " | pitch: ok" end
end
saveState(altI, pitchI)
logline(LOG, string.format("# TUNE %s -> A(%.3f,%.3f,%.2f) P(%.3f,%.3f,%.2f)",
tuneMsg, A_KP, A_KI, A_KD, P_KP, P_KI, P_KD))
winH, winP = {}, {}
lastTune = os.clock()
end
end
logline(LOG, string.format("%.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %.2f, %d, %d",
os.clock(), height, pitch, eH, TARGET_PITCH - pitch, collective, diff, fore, aft))
term.clear(); term.setCursorPos(1,1)
local maxed = collective >= MAX_SIGNAL-0.5 and math.abs(eH) > 5 and math.abs(fVspd) < 0.3
print(settled and "*** STABLE ***"
or (maxed and "!! MAX LIFT - target unreachable")
or (math.abs(eH) > DEADBAND_H and ">> moving..." or ".. holding .."))
print(string.format("Height %6.1f / %.0f (e %.1f)", height, TARGET_FINAL, TARGET_FINAL-height))
print(string.format("Pitch %6.1f / %.0f (e %.1f)", pitch, TARGET_PITCH, TARGET_PITCH-pitch))
print(string.format("fore %2d aft %2d %s", fore, aft, (inH and inP) and "[hold]" or ""))
print(string.format("model: ff~%.1f kR %.2f kPR %.2f kC %.2f", ff, K_RATE, K_PRATE, kCouple))
print(string.format("rate %.1f/%.1f | map %d | trim~%.1f", fVspd, cmdRate, mapCount(), sign*P_KI*pitchI))
print("tune: " .. tuneMsg)
sleep(DT)
end
end)
allOff()
saveState(altI, pitchI) -- lagre nyeste arbeidspunkt + gains
print("Valves set to 0. Operating point saved. Exited.")
if not ok and err ~= "Terminated" then error(err, 0) end

View File

@@ -0,0 +1,9 @@
-- stabilizeV2.lua -- launcher for v3 observer-autopiloten i autopilot/.
-- Videresender argumenter:
-- stabilizeV2 -> meny
-- stabilizeV2 monitor -> Lag 1
-- stabilizeV2 signtest -> §13.2
-- stabilizeV2 manual 4 3 -> Lag 2 (fore=4, aft=3)
local args = { ... }
local unpack = table.unpack or unpack
shell.run("autopilot/main", unpack(args))

View File

@@ -0,0 +1,5 @@
-- stabilizev2.lua -- lowercase alias for stabilizeV2 (CC is case-sensitive on Linux).
-- Forwards all args to the real launcher so either case works.
local args = { ... }
local unpack = table.unpack or unpack
shell.run("stabilizeV2", unpack(args))

View File

@@ -0,0 +1,3 @@
-- Maskinrom autopilot: STANDBY er default paa -- staar klar og lytter etter ENGAGE
-- fra pilot-konsollen. CTRL+T -> kjor "stabilizeV2" for menyen (wiring/sign test/setup).
shell.run("stabilizeV2", "standby")

View File

@@ -0,0 +1 @@
100

171
files/cockpit/cockpit.lua Normal file
View File

@@ -0,0 +1,171 @@
-- cockpit.lua -- pilot-hus konsoll. Status + full live-styring av autopiloten over rednet.
-- To faner: STATUS (fly/standby-styring) og SETTINGS (live brukerinnstillinger). UI = English.
-- ADAPTIV layout: kompakt paa 1x1-monitor, full paa storre.
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 link = require("link")
local mon = peripheral.find("monitor")
if not mon then print("No monitor found. Attach a monitor (Advanced recommended)."); return end
if not link.open() then print("No modem found. Attach a wireless modem."); return end
mon.setTextScale(0.5)
local W, H = mon.getSize()
local C = { bg = colors.black, title = colors.cyan, label = colors.lightGray, val = colors.white,
ok = colors.lime, warn = colors.orange, fault = colors.red, btn = colors.gray, btnTxt = colors.white,
engage = colors.green, stop = colors.red, tabOn = colors.blue, active = colors.green }
-- live-justerbare brukerinnstillinger (syntetiske noekler -> autopiloten mapper til cfg)
local SETTINGS = {
{ key = "climbSpeed", short = "Climb", step = 0.1, fmt = "%.1f", unit = "b/s", def = 0.8 },
{ key = "holdBand", short = "Band", step = 1, fmt = "%.0f", unit = "blk", def = 3 },
{ key = "maxPower", short = "Power", step = 1, fmt = "%.0f", unit = "", def = 6 },
}
local page = "status"
local status, lastRx = nil, -999
local buttons = {}
local function now() return os.clock() end
local function online() return (now() - lastRx) < 2.0 end
local function button(x1, y1, x2, y2, label, action, col)
buttons[#buttons + 1] = { x1 = x1, y1 = y1, x2 = x2, y2 = y2, action = action }
mon.setBackgroundColor(col or C.btn); mon.setTextColor(C.btnTxt)
for y = y1, y2 do mon.setCursorPos(x1, y); mon.write(string.rep(" ", x2 - x1 + 1)) end
mon.setCursorPos(x1 + math.max(0, math.floor((x2 - x1 + 1 - #label) / 2)), y1 + math.floor((y2 - y1) / 2))
mon.write(label); mon.setBackgroundColor(C.bg)
end
local function center(y, text, color)
mon.setTextColor(color or C.val)
mon.setCursorPos(math.max(1, math.floor((W - #text) / 2) + 1), y); mon.write(text:sub(1, W))
end
local function putline(y, text, color)
mon.setTextColor(color or C.val); mon.setCursorPos(1, y); mon.write(text:sub(1, W))
end
local function drawTabs()
local hw = math.floor(W / 2)
button(1, 1, hw, 1, "STATUS", function() page = "status" end, page == "status" and C.tabOn or C.btn)
button(hw + 1, 1, W, 1, "SETUP", function() page = "settings" end, page == "settings" and C.tabOn or C.btn)
end
local function curVal(p)
if status and status[p.key] ~= nil then return status[p.key] end
return p.def
end
local function drawStatus()
local compact = W < 28
local bRowH = (H >= 16) and 2 or 1
local landTop = H - bRowH + 1
local tgtTop = landTop - bRowH
local top, statusMax = 2, tgtTop - 1
if not online() then
center(math.floor((top + statusMax) / 2), "OFFLINE", C.fault)
if not compact then center(math.floor((top + statusMax) / 2) + 1, "no link to machine room", C.warn) end
return
end
local s = status
local inFlight = s.state ~= "IDLE" and s.state ~= "SETUP NEEDED"
local stateColor = C.fault
if s.state == "FLYING" then stateColor = C.ok
elseif s.state == "IDLE" then stateColor = (s.ready and C.title or C.warn) end
local pcolor = (math.abs(s.pitch or 0) < 3) and C.ok or C.warn
-- bygg status-linjer
local lines = {}
lines[#lines + 1] = { tostring(s.state) .. (s.hold and " HOLD" or ""), stateColor }
if s.reason and s.reason ~= "" then lines[#lines + 1] = { tostring(s.reason), C.fault } end
if compact then
lines[#lines + 1] = { string.format("%.0f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f", s.pitch or 0), pcolor }
if inFlight then lines[#lines + 1] = { string.format("VSPD %+.2f", s.vVel or 0), C.val } end
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup", s.ready and C.ok or C.warn } end
else
if s.state == "IDLE" then lines[#lines + 1] = { s.ready and "ready -> ENGAGE" or "needs setup (Wiring+Sign)", s.ready and C.ok or C.warn } end
lines[#lines + 1] = { string.format("ALT %.1f > %.0f", s.height or 0, s.target or 0), C.val }
lines[#lines + 1] = { string.format("PITCH %+.1f deg", s.pitch or 0), pcolor }
if inFlight then
lines[#lines + 1] = { string.format("VSPD %+.2f b/s", s.vVel or 0), C.val }
lines[#lines + 1] = { string.format("OUT f%d a%d lift %.1f", s.fore or 0, s.aft or 0, s.uSum or 0), C.val }
end
end
-- render: kompakt = sentrert + luftig (fyll den ledige plassen); full = venstrejustert top-down
if compact then
local avail = statusMax - top + 1
local gap = (avail >= 2 * #lines - 1) and 1 or 0
local blockH = #lines + (#lines - 1) * gap
local startY = top + math.max(0, math.floor((avail - blockH) / 2))
for i, L in ipairs(lines) do
local y = startY + (i - 1) * (1 + gap)
if y >= top and y <= statusMax then center(y, L[1], L[2]) end
end
else
for i, L in ipairs(lines) do local y = top + i - 1; if y <= statusMax then putline(y, L[1], L[2]) end end
end
-- knapper (HOLD/LAND lyser solid naar den modusen er aktiv)
if inFlight then
local bw = math.floor(W / 4)
button(1, tgtTop, bw, tgtTop + bRowH - 1, "-5", function() link.sendCommand({ cmd = "adjust", delta = -5 }) end)
button(bw + 1, tgtTop, 2 * bw, tgtTop + bRowH - 1, "-1", function() link.sendCommand({ cmd = "adjust", delta = -1 }) end)
button(2 * bw + 1, tgtTop, 3 * bw, tgtTop + bRowH - 1, "+1", function() link.sendCommand({ cmd = "adjust", delta = 1 }) end)
button(3 * bw + 1, tgtTop, W, tgtTop + bRowH - 1, "+5", function() link.sendCommand({ cmd = "adjust", delta = 5 }) end)
local tw = math.floor(W / 3)
button(1, landTop, tw, H, "HOLD", function() link.sendCommand({ cmd = "hold" }) end, (s.mode == "hold") and C.active or C.btn)
button(tw + 1, landTop, 2 * tw, H, "LAND", function() link.sendCommand({ cmd = "land" }) end, (s.mode == "land") and C.active or C.btn)
button(2 * tw + 1, landTop, W, H, "STOP", function() link.sendCommand({ cmd = "stop" }) end, C.stop)
else
button(1, tgtTop, W, H, "ENGAGE FLY", function() link.sendCommand({ cmd = "engage" }) end, C.engage)
end
end
local function drawSettings()
local n = #SETTINGS
local areaTop = 2
local blockH = math.max(1, math.floor((H - areaTop + 1) / n))
local bw = math.max(3, math.floor(W / 5))
for i, p in ipairs(SETTINGS) do
local y1 = areaTop + (i - 1) * blockH
local y2 = (i == n) and H or (y1 + blockH - 1)
local cur = curVal(p)
button(1, y1, bw, y2, "-", function() link.sendCommand({ cmd = "set", key = p.key, value = cur - p.step }) end)
button(W - bw + 1, y1, W, y2, "+", function() link.sendCommand({ cmd = "set", key = p.key, value = cur + p.step }) end)
local txt = string.format("%s %s%s", p.short, string.format(p.fmt, cur), p.unit)
mon.setTextColor(C.val)
mon.setCursorPos(bw + 1 + math.max(0, math.floor((W - 2 * bw - #txt) / 2)), math.floor((y1 + y2) / 2))
mon.write(txt)
end
end
local function draw()
mon.setBackgroundColor(C.bg); mon.clear()
buttons = {}
drawTabs()
if page == "settings" then drawSettings() else drawStatus() end
end
local function handleTouch(x, y)
for _, b in ipairs(buttons) do
if x >= b.x1 and x <= b.x2 and y >= b.y1 and y <= b.y2 then b.action(); draw(); return end
end
end
draw()
local redraw = os.startTimer(0.5)
while true do
local e = { os.pullEvent() }
if e[1] == "rednet_message" and e[4] == link.STATUS then
status = e[3]; lastRx = now()
elseif e[1] == "monitor_touch" then
handleTouch(e[3], e[4])
elseif e[1] == "timer" and e[2] == redraw then
redraw = os.startTimer(0.5); draw()
end
end

33
files/cockpit/link.lua Normal file
View File

@@ -0,0 +1,33 @@
-- link.lua -- lettvekts rednet-lenke mellom autopiloten (maskinrom) og piloten (pilot-hus).
-- Modem-agnostisk: virker over wired (nettverkskabel) ELLER wireless/ender modem. Graceful:
-- hvis ingen modem finnes, blir publish/send no-ops (autopiloten kjorer fint solo).
local M = {}
M.STATUS = "airship_status" -- autopilot -> pilot (status-kringkasting)
M.CMD = "airship_cmd" -- pilot -> autopilot (kommandoer)
local opened = false
-- aapne rednet paa ALLE tilkoblede modem (wired og/eller wireless)
function M.open()
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" and not rednet.isOpen(name) then
rednet.open(name); opened = true
end
end
-- ogsaa hvis et modem allerede var aapent
for _, name in ipairs(peripheral.getNames()) do
if peripheral.getType(name) == "modem" and rednet.isOpen(name) then opened = true end
end
return opened
end
function M.isOpen() return opened end
-- kringkast status (kalles hver tick av autopiloten)
function M.publishStatus(t) if opened then rednet.broadcast(t, M.STATUS) end end
-- send en kommando-tabell (kalles av piloten ved knappetrykk)
function M.sendCommand(t) if opened then rednet.broadcast(t, M.CMD) end end
return M

3
files/cockpit/manifest Normal file
View File

@@ -0,0 +1,3 @@
cockpit.lua
link.lua
startup.lua

View File

@@ -0,0 +1,2 @@
-- pilot-hus: auto-run cockpit
shell.run("cockpit")