axilex

ship_stabilizer

May 2nd, 2026 (edited)
33
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Lua 8.26 KB | Gaming | 0 0
  1. -- Ship PID Stabilizer
  2. -- Reads a gimbal_sensor and outputs analog redstone to control propeller speed.
  3. -- Ship is assumed to face SE/NW, so world X/Z angles are rotated 45 degrees
  4. -- into ship-local pitch and roll before feeding the PID controllers.
  5. --
  6. -- If an altitude_sensor is also attached, a third PID maintains a target height
  7. -- by adjusting all four propellers collectively (↑/↓ arrows change target).
  8.  
  9. local SAMPLE_RATE = 0.05 -- seconds per update (~20 Hz)
  10.  
  11. -- How many blocks to move the altitude target per key press
  12. local ALT_STEP = 1
  13.  
  14. -- Tune these gains for your ship's mass and propeller responsiveness.
  15. -- Tuning order: zero kI, raise kP until it corrects, raise kD until oscillation stops,
  16. -- then add tiny kI to kill steady-state lean. Never raise kP past the oscillation point.
  17. local GAINS = {
  18.     pitch    = { kP = 0.25, kI = 0.0,  kD = 2.0  },
  19.     roll     = { kP = 0.25, kI = 0.0,  kD = 2.0  },
  20.     altitude = { kP = 0.4,  kI = 0.01, kD = 1.5  },
  21. }
  22.  
  23. -- Low-pass smoothing for the derivative term (0 = no filter, 1 = freeze).
  24. -- Reduces noise-driven output spikes. Raise toward 0.8 if output chatters.
  25. local D_FILTER = 0.4
  26.  
  27. -- Map logical propeller positions to computer output sides.
  28. local OUT = {
  29.     front = "back",
  30.     back  = "front",
  31.     left  = "left",
  32.     right = "right",
  33. }
  34.  
  35. -- Propellers cut out above this signal strength — keep output strictly below it.
  36. local MAX  = 14
  37.  
  38. -- Neutral signal (level flight, no altitude correction).
  39. local BASE = math.floor(MAX / 2)
  40.  
  41. -- ── PID ──────────────────────────────────────────────────────────────────────
  42.  
  43. local pidState = {
  44.     pitch    = { integral = 0, prevErr = 0, dSmooth = 0 },
  45.     roll     = { integral = 0, prevErr = 0, dSmooth = 0 },
  46.     altitude = { integral = 0, prevErr = 0, dSmooth = 0 },
  47. }
  48.  
  49. local function clamp(v, lo, hi)
  50.     return math.max(lo, math.min(hi, v))
  51. end
  52.  
  53. local function pidStep(axis, err, dt)
  54.     local g = GAINS[axis]
  55.     local s = pidState[axis]
  56.  
  57.     -- Integral with windup guard (skip accumulation when kI is zeroed out)
  58.     if g.kI > 0 then
  59.         s.integral = s.integral + err * dt
  60.         -- Limit integral so its contribution stays within ±(BASE/2)
  61.         s.integral = clamp(s.integral, -(BASE / 2) / g.kI, (BASE / 2) / g.kI)
  62.     else
  63.         s.integral = 0
  64.     end
  65.  
  66.     -- Derivative with low-pass filter to reduce sensor noise spikes
  67.     local rawDeriv = (err - s.prevErr) / dt
  68.     s.dSmooth = D_FILTER * s.dSmooth + (1 - D_FILTER) * rawDeriv
  69.     s.prevErr = err
  70.  
  71.     return g.kP * err + g.kI * s.integral + g.kD * s.dSmooth
  72. end
  73.  
  74. -- ── Coordinate transform ──────────────────────────────────────────────────────
  75. -- For a SE-facing ship: pitch = xAngle + zAngle, roll = xAngle - zAngle.
  76. -- The √2 normalisation is absorbed into PID gains.
  77.  
  78. local function worldToShip(xDeg, zDeg)
  79.     return xDeg + zDeg, xDeg - zDeg
  80. end
  81.  
  82. -- ── Peripherals ───────────────────────────────────────────────────────────────
  83.  
  84. local gimbal = peripheral.find("gimbal_sensor")
  85. if not gimbal then
  86.     error("gimbal_sensor peripheral not found — attach one to this computer")
  87. end
  88.  
  89. local altimeter = peripheral.find("altitude_sensor")
  90. local mode3D    = altimeter ~= nil
  91. local targetAlt = 0
  92. if mode3D then
  93.     targetAlt = altimeter.getHeight()
  94. end
  95.  
  96. -- ── Redstone output ───────────────────────────────────────────────────────────
  97.  
  98. local function setOutputs(front, back, left, right)
  99.     redstone.setAnalogOutput(OUT.front, math.floor(clamp(front, 0, MAX)))
  100.     redstone.setAnalogOutput(OUT.back,  math.floor(clamp(back,  0, MAX)))
  101.     redstone.setAnalogOutput(OUT.left,  math.floor(clamp(left,  0, MAX)))
  102.     redstone.setAnalogOutput(OUT.right, math.floor(clamp(right, 0, MAX)))
  103. end
  104.  
  105. local function resetOutputs()
  106.     for _, side in pairs(OUT) do
  107.         redstone.setAnalogOutput(side, 0)
  108.     end
  109. end
  110.  
  111. -- ── Main ──────────────────────────────────────────────────────────────────────
  112.  
  113. if mode3D then
  114.     print(string.format("3D mode  —  holding altitude %.1f", targetAlt))
  115.     print("↑/↓ arrows adjust target altitude, Q to stop.")
  116. else
  117.     print("2D mode  —  no altitude_sensor found, tilt-only stabilization.")
  118.     print("Press Q to stop.")
  119. end
  120.  
  121. local running  = true
  122. local lastTime = os.clock()
  123.  
  124. parallel.waitForAny(
  125.     -- ── Control loop ──────────────────────────────────────────────────────────
  126.     function()
  127.         while running do
  128.             local now = os.clock()
  129.             local dt  = now - lastTime
  130.             if dt < SAMPLE_RATE then
  131.                 os.sleep(SAMPLE_RATE - dt)
  132.                 dt = os.clock() - lastTime
  133.             end
  134.             lastTime = os.clock()
  135.             dt = math.max(dt, 0.001)
  136.  
  137.             local angles     = gimbal.getAngles()
  138.             local xDeg, zDeg = angles[1], angles[2]
  139.             local pitch, roll = worldToShip(xDeg, zDeg)
  140.  
  141.             local pitchOut = pidStep("pitch", pitch, dt)
  142.             local rollOut  = pidStep("roll",  roll,  dt)
  143.  
  144.             -- Collective: altitude PID adds the same offset to all four props.
  145.             local collective = 0
  146.             local curAlt     = 0
  147.             if mode3D then
  148.                 curAlt     = altimeter.getHeight()
  149.                 local altErr = targetAlt - curAlt
  150.                 collective = pidStep("altitude", altErr, dt)
  151.             end
  152.  
  153.             -- Mix collective (altitude) with differential (pitch/roll).
  154.             --   pitch: front/back pair  — positive pitch means nose up → more rear, less front
  155.             --   roll:  left/right pair  — positive roll means right down → more left, less right
  156.             local front = BASE + collective - pitchOut
  157.             local back  = BASE + collective + pitchOut
  158.             local left  = BASE + collective + rollOut
  159.             local right = BASE + collective - rollOut
  160.  
  161.             setOutputs(front, back, left, right)
  162.  
  163.             term.clear()
  164.             term.setCursorPos(1, 1)
  165.             print(string.format("Gimbal   X: %+6.2f deg   Z: %+6.2f deg", xDeg, zDeg))
  166.             print(string.format("Ship   Pitch: %+6.2f     Roll: %+6.2f", pitch, roll))
  167.             if mode3D then
  168.                 print(string.format("Altitude  cur: %7.2f   target: %7.2f   err: %+.2f",
  169.                     curAlt, targetAlt, targetAlt - curAlt))
  170.             else
  171.                 print("Altitude  [no sensor]")
  172.             end
  173.             print(string.format("Output  F:%2d  B:%2d  L:%2d  R:%2d  (max %d)",
  174.                 math.floor(clamp(front, 0, MAX)),
  175.                 math.floor(clamp(back,  0, MAX)),
  176.                 math.floor(clamp(left,  0, MAX)),
  177.                 math.floor(clamp(right, 0, MAX)),
  178.                 MAX))
  179.             print("")
  180.             if mode3D then
  181.                 print("↑/↓ adjust altitude target  |  Q quit")
  182.             else
  183.                 print("Q quit")
  184.             end
  185.         end
  186.     end,
  187.  
  188.     -- ── Key listener ──────────────────────────────────────────────────────────
  189.     function()
  190.         while true do
  191.             local _, key = os.pullEvent("key")
  192.             if key == keys.q then
  193.                 running = false
  194.                 return
  195.             elseif mode3D and key == keys.up then
  196.                 targetAlt = targetAlt + ALT_STEP
  197.             elseif mode3D and key == keys.down then
  198.                 targetAlt = targetAlt - ALT_STEP
  199.             end
  200.         end
  201.     end
  202. )
  203.  
  204. resetOutputs()
  205. print("Stabilizer stopped — all outputs zeroed.")
  206.  
Advertisement
Add Comment
Please, Sign In to add comment