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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# CC autopilot repo
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Airship Autopilot (Create: Aeronautics + CC:Tweaked)
|
||||||
|
|
||||||
|
Altitude/pitch autopilot for a hot-air airship, delivered as an in-game installer
|
||||||
|
so it runs on any Minecraft server regardless of the computer's id.
|
||||||
|
|
||||||
|
## Install on a server
|
||||||
|
|
||||||
|
On the CC computer, run one command. Pick the role for that machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
-- machine-room PC (full autopilot, boots to standby):
|
||||||
|
wget run https://raw.githubusercontent.com/Sterister/airship-autopilot/main/install.lua autopilot
|
||||||
|
|
||||||
|
-- pilot-house PC (control console):
|
||||||
|
wget run https://raw.githubusercontent.com/Sterister/airship-autopilot/main/install.lua cockpit
|
||||||
|
```
|
||||||
|
|
||||||
|
Omit the role to get a menu. Re-run any time to update to the latest code.
|
||||||
|
|
||||||
|
Requires HTTP enabled on the server (default on). If it is off, set
|
||||||
|
`[http] enabled = true` in `computercraft-server.toml` and restart the server.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| Role | Source PC | Boots | Purpose |
|
||||||
|
|-------------|-----------|-----------------|------------------------------------------|
|
||||||
|
| `autopilot` | 0 | `stabilizeV2 standby` | v3 observer autopilot (13 modules in `autopilot/`) |
|
||||||
|
| `cockpit` | 4 | `cockpit` | pilot control console |
|
||||||
|
|
||||||
|
Boot chain (autopilot): `startup.lua` -> `stabilizeV2` -> `autopilot/main.lua`.
|
||||||
|
Menu / wiring / sign-test: `Ctrl+T` then `stabilizeV2` for the menu.
|
||||||
|
|
||||||
|
## Updating the code
|
||||||
|
|
||||||
|
The `files/` tree mirrors the live singleplayer world. After editing the Lua
|
||||||
|
locally, regenerate the manifests and push:
|
||||||
|
|
||||||
|
```
|
||||||
|
./build.sh
|
||||||
|
git commit -am "sync autopilot"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-run the `wget run` command in-game to pull the update.
|
||||||
|
|
||||||
|
`build.sh` reads from the "Modda wii" world by default; pass a different
|
||||||
|
`computercraft/computer` path as arg 1 if the world moves.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Design history, hardware facts, tuning lessons and the recommended control
|
||||||
|
strategy are in `AIRSHIP_AUTOPILOT_NOTES.md` (in the modpack profile). The
|
||||||
|
control loop never fully converged; pitch oscillation under climb is the open
|
||||||
|
problem. Keep the altitude cascade simple and make pitch a slow trim.
|
||||||
39
build.sh
Executable file
39
build.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build.sh -- mirror the live CC autopilot from the singleplayer world into this
|
||||||
|
# repo and regenerate the per-role manifests that install.lua consumes.
|
||||||
|
#
|
||||||
|
# Run this whenever you change the Lua locally and want the server installer to
|
||||||
|
# pick it up: ./build.sh && git commit -am "sync" && git push
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
SRC_DEFAULT="/home/stian/.local/share/ModrinthApp/profiles/Modda wii/saves/New World/computercraft/computer"
|
||||||
|
SRC="${1:-$SRC_DEFAULT}"
|
||||||
|
|
||||||
|
# role name -> source computer id in the world
|
||||||
|
declare -A ROLE_SRC=(
|
||||||
|
[autopilot]=0 # maskinrom: startup -> stabilizeV2 -> autopilot/main
|
||||||
|
[cockpit]=4 # pilot-hus: startup -> cockpit
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ ! -d "$SRC" ]; then
|
||||||
|
echo "error: world computer dir not found: $SRC" >&2
|
||||||
|
echo "pass the path as arg 1 if the world moved." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for role in "${!ROLE_SRC[@]}"; do
|
||||||
|
id="${ROLE_SRC[$role]}"
|
||||||
|
dst="$REPO/files/$role"
|
||||||
|
if [ ! -d "$SRC/$id" ]; then
|
||||||
|
echo "warn: computer $id (role $role) missing in world, skipping" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
rm -rf "$dst"
|
||||||
|
cp -r "$SRC/$id" "$dst"
|
||||||
|
# Manifest = newline-separated relative paths, sorted, excluding the manifest itself.
|
||||||
|
( cd "$dst" && find . -type f ! -name manifest | sed 's|^\./||' | sort ) > "$dst/manifest"
|
||||||
|
echo "role '$role' <- computer $id : $(wc -l < "$dst/manifest") files"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done. files/ and manifests regenerated."
|
||||||
55
files/autopilot/autopilot/altitude.lua
Normal file
55
files/autopilot/autopilot/altitude.lua
Normal 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
|
||||||
241
files/autopilot/autopilot/calibration.lua
Normal file
241
files/autopilot/autopilot/calibration.lua
Normal 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
|
||||||
130
files/autopilot/autopilot/config.lua
Normal file
130
files/autopilot/autopilot/config.lua
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
48
files/autopilot/autopilot/estimator.lua
Normal file
48
files/autopilot/autopilot/estimator.lua
Normal 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
|
||||||
33
files/autopilot/autopilot/link.lua
Normal file
33
files/autopilot/autopilot/link.lua
Normal 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
|
||||||
53
files/autopilot/autopilot/logger.lua
Normal file
53
files/autopilot/autopilot/logger.lua
Normal 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
|
||||||
649
files/autopilot/autopilot/main.lua
Normal file
649
files/autopilot/autopilot/main.lua
Normal 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
|
||||||
33
files/autopilot/autopilot/mixer.lua
Normal file
33
files/autopilot/autopilot/mixer.lua
Normal 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
|
||||||
66
files/autopilot/autopilot/observer.lua
Normal file
66
files/autopilot/autopilot/observer.lua
Normal 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
|
||||||
46
files/autopilot/autopilot/pitch.lua
Normal file
46
files/autopilot/autopilot/pitch.lua
Normal 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
|
||||||
30
files/autopilot/autopilot/safety.lua
Normal file
30
files/autopilot/autopilot/safety.lua
Normal 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
|
||||||
55
files/autopilot/autopilot/sensors.lua
Normal file
55
files/autopilot/autopilot/sensors.lua
Normal 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
|
||||||
66
files/autopilot/autopilot/utils.lua
Normal file
66
files/autopilot/autopilot/utils.lua
Normal 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
11
files/autopilot/calib.txt
Normal 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
171
files/autopilot/cockpit.lua
Normal 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
50
files/autopilot/diag.lua
Normal 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
27
files/autopilot/manifest
Normal 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
|
||||||
16
files/autopilot/pid_gains.txt
Normal file
16
files/autopilot/pid_gains.txt
Normal 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,
|
||||||
|
}
|
||||||
2
files/autopilot/pilot_startup.lua
Normal file
2
files/autopilot/pilot_startup.lua
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- startup for pilot-hus PC: auto-run cockpit
|
||||||
|
shell.run("cockpit")
|
||||||
30
files/autopilot/probe.lua
Normal file
30
files/autopilot/probe.lua
Normal 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
45
files/autopilot/scan.lua
Normal 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
27
files/autopilot/scan.txt
Normal 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
|
||||||
4
files/autopilot/sides.txt
Normal file
4
files/autopilot/sides.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
aft = "back",
|
||||||
|
fore = "right",
|
||||||
|
}
|
||||||
571
files/autopilot/stabilize.lua
Normal file
571
files/autopilot/stabilize.lua
Normal 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
|
||||||
9
files/autopilot/stabilizeV2.lua
Normal file
9
files/autopilot/stabilizeV2.lua
Normal 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))
|
||||||
5
files/autopilot/stabilizev2.lua
Normal file
5
files/autopilot/stabilizev2.lua
Normal 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))
|
||||||
3
files/autopilot/startup.lua
Normal file
3
files/autopilot/startup.lua
Normal 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")
|
||||||
1
files/autopilot/target.txt
Normal file
1
files/autopilot/target.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
100
|
||||||
171
files/cockpit/cockpit.lua
Normal file
171
files/cockpit/cockpit.lua
Normal 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
33
files/cockpit/link.lua
Normal 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
3
files/cockpit/manifest
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
cockpit.lua
|
||||||
|
link.lua
|
||||||
|
startup.lua
|
||||||
2
files/cockpit/startup.lua
Normal file
2
files/cockpit/startup.lua
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- pilot-hus: auto-run cockpit
|
||||||
|
shell.run("cockpit")
|
||||||
115
install.lua
Normal file
115
install.lua
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
-- install.lua -- role-based installer for the Create: Aeronautics airship autopilot.
|
||||||
|
-- Fetches every file for the chosen role over HTTP and writes it to this computer,
|
||||||
|
-- so it works on any server regardless of the computer's id. Re-run to update.
|
||||||
|
--
|
||||||
|
-- Usage on a CC computer:
|
||||||
|
-- wget run https://raw.githubusercontent.com/Sterister/airship-autopilot/main/install.lua
|
||||||
|
-- wget run https://raw.githubusercontent.com/Sterister/airship-autopilot/main/install.lua autopilot
|
||||||
|
-- wget run https://raw.githubusercontent.com/Sterister/airship-autopilot/main/install.lua cockpit
|
||||||
|
--
|
||||||
|
-- Roles:
|
||||||
|
-- autopilot machine-room PC: full v3 observer autopilot (boots to standby)
|
||||||
|
-- cockpit pilot-house PC: control console
|
||||||
|
|
||||||
|
local BASE = "https://raw.githubusercontent.com/Sterister/airship-autopilot/main"
|
||||||
|
local ROLES = { "autopilot", "cockpit" }
|
||||||
|
|
||||||
|
local args = { ... }
|
||||||
|
|
||||||
|
-- ---- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
local function die(msg)
|
||||||
|
term.setTextColor(colors.red)
|
||||||
|
print(msg)
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
error("", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function get(url)
|
||||||
|
local h, err = http.get(url)
|
||||||
|
if not h then return nil, tostring(err) end
|
||||||
|
local body = h.readAll()
|
||||||
|
h.close()
|
||||||
|
return body
|
||||||
|
end
|
||||||
|
|
||||||
|
local function writeFile(path, data)
|
||||||
|
local dir = fs.getDir(path)
|
||||||
|
if dir ~= "" and not fs.exists(dir) then fs.makeDir(dir) end
|
||||||
|
local f = fs.open(path, "w")
|
||||||
|
f.write(data)
|
||||||
|
f.close()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function chooseRole()
|
||||||
|
print("Select role to install:")
|
||||||
|
for i, r in ipairs(ROLES) do print((" %d) %s"):format(i, r)) end
|
||||||
|
write("> ")
|
||||||
|
local sel = read()
|
||||||
|
local n = tonumber(sel)
|
||||||
|
if n and ROLES[n] then return ROLES[n] end
|
||||||
|
for _, r in ipairs(ROLES) do if r == sel then return r end end
|
||||||
|
die("Unknown role: " .. tostring(sel))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- preflight -------------------------------------------------------------
|
||||||
|
|
||||||
|
if not http then
|
||||||
|
die("HTTP API is disabled on this server.\n" ..
|
||||||
|
"Enable it in the server's computercraft-server.toml:\n" ..
|
||||||
|
" [http] enabled = true then restart the server.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local role = args[1]
|
||||||
|
if not role then
|
||||||
|
role = chooseRole()
|
||||||
|
else
|
||||||
|
local ok = false
|
||||||
|
for _, r in ipairs(ROLES) do if r == role then ok = true end end
|
||||||
|
if not ok then die("Unknown role '" .. role .. "'. Valid: " .. table.concat(ROLES, ", ")) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- fetch manifest --------------------------------------------------------
|
||||||
|
|
||||||
|
print("Installing role: " .. role)
|
||||||
|
local manifestUrl = BASE .. "/files/" .. role .. "/manifest"
|
||||||
|
local manifest, mErr = get(manifestUrl)
|
||||||
|
if not manifest then die("Could not fetch manifest (" .. mErr .. ")\n" .. manifestUrl) end
|
||||||
|
|
||||||
|
local files = {}
|
||||||
|
for line in manifest:gmatch("[^\r\n]+") do
|
||||||
|
local p = line:match("^%s*(.-)%s*$")
|
||||||
|
if p ~= "" then files[#files + 1] = p end
|
||||||
|
end
|
||||||
|
if #files == 0 then die("Manifest is empty: " .. manifestUrl) end
|
||||||
|
|
||||||
|
-- ---- download --------------------------------------------------------------
|
||||||
|
|
||||||
|
local ok, fail = 0, {}
|
||||||
|
for i, rel in ipairs(files) do
|
||||||
|
term.write(("[%d/%d] %s "):format(i, #files, rel))
|
||||||
|
local data, err = get(BASE .. "/files/" .. role .. "/" .. rel)
|
||||||
|
if data then
|
||||||
|
writeFile(rel, data)
|
||||||
|
term.setTextColor(colors.green); print("ok"); term.setTextColor(colors.white)
|
||||||
|
ok = ok + 1
|
||||||
|
else
|
||||||
|
term.setTextColor(colors.red); print("FAIL"); term.setTextColor(colors.white)
|
||||||
|
fail[#fail + 1] = rel .. " (" .. tostring(err) .. ")"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("Installed %d/%d files."):format(ok, #files))
|
||||||
|
if #fail > 0 then
|
||||||
|
term.setTextColor(colors.red)
|
||||||
|
print("Failed:")
|
||||||
|
for _, f in ipairs(fail) do print(" " .. f) end
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
die("Install incomplete -- re-run after fixing the errors above.")
|
||||||
|
end
|
||||||
|
|
||||||
|
term.setTextColor(colors.green)
|
||||||
|
print("Done. Reboot to auto-start (startup.lua).")
|
||||||
|
term.setTextColor(colors.white)
|
||||||
|
write("Reboot now? [y/N] ")
|
||||||
|
if read():lower() == "y" then os.reboot() end
|
||||||
Reference in New Issue
Block a user