Inertial Navigation System (INS)

Inertial navigation is fully self-contained — it needs no GPS, ground stations or any external signal. Gyroscopes sense attitude and angular rate, accelerometers sense acceleration, and the system integrates acceleration into velocity, then into position.
Because it needs no external reference, inertial navigation cannot be jammed or spoofed — it is the core navigation of nuclear submarines, missiles and spacecraft; the IMU in your smartphone works on the same principle (step counting, gesture recognition).
The Inertial Navigation System (ccpe:ins) is an attitude indicator block for physics bodies (Sable sub-levels). The center block's red end always points north.
Attitude readings gate
All INS-gated methods require the physics body (including constraint chains) to have at least 1 INS (ccpe:ins) installed. Without one, they all return nil:
| Method | Returns | Description |
|---|---|---|
getAngles() |
table / nil | Body attitude {pitch, roll, yaw} in degrees (see convention below) |
getPosition() |
table / nil | World position {x, y, z} of the most recently placed INS block |
getBodyPosition() |
table / nil | World position {x, y, z} of the physics body origin (its pivot / center-of-mass axis) |
getOrientation() |
table / nil | Body orientation quaternion {x, y, z, w} (world frame) |
getAngularVelocity() |
table / nil | Body-frame angular rate {x, y, z} (rad/s) around the body's own X/Y/Z axes (equals the world frame when the body attitude is identity) |
getAngleRates() |
table / nil | Attitude-angle rates {pitchRate, rollRate, yawRate} in deg/s (derivatives of pitch/roll/yaw; see "why use it" below) |
getVelocity() |
table / nil | World-frame linear velocity {x, y, z} (m/s) of the body origin (world X/Y/Z axes; exactly 0 when stationary) |
The INS also appears in getSensors() as {type="ins", pos={x,y,z}, pos_rel={x,y,z}} (no per-sensor readings — use the dedicated methods above).
Angular velocity frame
getAngularVelocity() returns the rotation rate around the body's own axes (roll/pitch/yaw-rate style components): the world-frame angular velocity (Sable's per-tick pose-orientation difference latestAngularVelocity, ×20 to rad/s — exactly 0 when stationary, unlike the raw physics-handle value which reports phantom readings) rotated into the body frame with the same-tick orientation quaternion exposed by getOrientation(). To recover the world-frame angular velocity, rotate the result by that quaternion (q * ω_body).
Why use getAngleRates() instead of getAngularVelocity() for attitude rates
The body-axis components of getAngularVelocity() (especially body-Z) cannot be used directly as roll/pitch rates. Measured flight logs (2026-09) found two problems:
- Body-axis components are contaminated by the world rotation axis — during pitch+yaw maneuvers the world rotation axis projects onto the (pitched) body-Z axis, so
av.zreports ±30~45 °/s of "fake roll" while the true roll is only ~5 °/s (av.z ≈ −yaw_rate·sin(pitch)). Used as roll damping, it turns pitch maneuvers into roll commands, causing large roll swings. - Sable's angular-velocity source is imprecise during fast maneuvers —
latestAngularVelocity(world pose-orientation difference) deviates from the quaternion sequence's true angular velocity by up to 0.5+ rad/s during fast pitch (verified: the finite difference of world-down-in-bodyld≠ld × ω_body).
Meanwhile the attitude angles behind getAngles() are exactly consistent with the quaternion (recomputation error 0.00). getAngleRates() differentiates the attitude angles directly (minimal-angle wraparound for ±180° + EMA low-pass, time constant ~0.09 s @20 Hz), giving exactly the pitch/roll/yaw rates a controller needs — same sign convention as getAngles(), so a PD controller pairs the P term (angle) with the D term (rate) naturally. The return value is ready to use; no Lua-side filtering needed.
Velocity frame and source
getVelocity() returns the world-frame translational velocity of the body origin (m/s) — computed by Sable each tick from the world pose position difference (ServerSubLevel.latestLinearVelocity, ×20 to per-second), so it is exactly 0 when the body is stationary. It is NOT the raw physics-handle velocity nor the Sable.HELPER.getVelocity point velocity: both mix in phantom non-world values from the physics handle (a world-stationary body still reports ≈ −0.03 m/s and ≈ 0.008 rad/s there — confirmed via flight-log columns). To get the body-frame velocity (along the body's own X/Y/Z axes), rotate the result by the inverse of the orientation quaternion exposed by getOrientation() (q⁻¹ * v).
Angle convention
- pitch — rotation around the body-local X axis; positive = nose up.
- roll — rotation around the body-local Z axis; positive = right wing down (banking right).
- yaw — 0 = the body's local −Z points north; positive = turning right (clockwise seen from above); range −180..180. In steady state it equals the reading shown by the INS north marker.
Gimbal-lock caveat
pitch/roll are derived from the gravity vector (same algorithm as simulated:gimbal_sensor). Near vertical attitudes (±90° pitch) the decomposition degrades — the same limitation as a real attitude indicator.
Position semantics
getPosition()— where the INS block itself is in the world (its plot coordinates projected through the Sable physics-body transform). It moves as the body moves/rotates.getBodyPosition()— where the whole body's origin is (the pivot used by the physics system). The two are usually close but not identical, because the INS block is usually mounted off the origin.
Example
local ss = require("ccpe.sensor_system")
if not ss.isOnBody() then
error("computer not on a physics body")
end
-- Attitude (degrees)
local a = ss.getAngles()
if a then
print(string.format("pitch=%.1f roll=%.1f yaw=%.1f", a.pitch, a.roll, a.yaw))
end
-- INS block position vs body origin (world coordinates)
print("ins pos: ", textutils.serialize(ss.getPosition()))
print("body origin:", textutils.serialize(ss.getBodyPosition()))
-- Orientation quaternion {x,y,z,w}
print("quaternion:", textutils.serialize(ss.getOrientation()))
-- Angular rate (rad/s, body frame; see note above)
print("ang vel: ", textutils.serialize(ss.getAngularVelocity()))
-- Attitude-angle rates (deg/s, pre-filtered; use as the D term of a PD controller; see note above)
print("angle rates:", textutils.serialize(ss.getAngleRates()))
-- Linear velocity (m/s, world frame; see note above)
print("velocity: ", textutils.serialize(ss.getVelocity()))
The shared methods (isOnBody(), getBodyId(), getSensors(), ...) behave as documented on the Static Port page. Physics data gated by the Flight Management Computer (FMC) is documented on the Flight Management Computer page.