Position & Altitude Hold¶
Indoors there is no GPS, so "position hold" is not a feature you switch on — it is the product of an EKF3 state estimate built from the sensors we chose. This page explains how the assisted flight modes depend on that estimate, how the EKF's sources are configured for our indoor aircraft, and how to verify the estimate before it flies — because when the estimate is wrong, the controllers chase it with full authority. Ours did.
How the flight modes use the EKF¶
ArduPilot's modes form a ladder: each rung hands one more control loop to the autopilot, and each loop needs one more piece of the EKF estimate to be valid.
| Mode | Autopilot controls | Needs from the EKF |
|---|---|---|
| Stabilize | attitude only — throttle is the pilot's | attitude (gyro/accel), always available |
| AltHold | attitude + altitude | a valid height estimate |
| Loiter / PosHold | attitude + altitude + horizontal position | height and a horizontal position/velocity estimate |
| Guided | everything — flies position/velocity targets from the companion computer | full position estimate; indoors that means the flow/rangefinder chain below |
The ladder is also the risk model: a broken height estimate is harmless in Stabilize and catastrophic in any altitude-controlled mode. Our crash happened the moment a fence breach forced the vehicle out of Stabilize into LAND — the first altitude-controlled mode of the flight.
EKF sources for indoor flight¶
EKF3 lets each axis of the estimate take its measurement from a different sensor
(EK3_SRC1_*). The GPS defaults vs. our indoor configuration:
| Parameter | Default (GPS) | Indoor (ours) | Meaning |
|---|---|---|---|
EK3_SRC1_POSXY |
3 (GPS) | 0 (None) | no absolute horizontal position exists indoors |
EK3_SRC1_VELXY |
3 (GPS) | 5 (Optical Flow) | MTF-01P flow supplies horizontal velocity |
EK3_SRC1_POSZ |
1 (Baro) | 2 (Range Finder) | height comes from the LiDAR rangefinder, flown only under the safety protocol |
EK3_SRC1_VELZ |
3 (GPS) | 0 (None) | no sensor for vertical velocity |
EK3_SRC1_YAW |
1 (Compass) | 1 (Compass) | heading reference |
EK3_SRC_OPTIONS |
0 | 0 | do not fuse all velocity sources at once |
Two subtleties in that table carry the whole story:
Optical flow is a velocity, not a position — and it needs height. Flow measures an
angular rate; converting it into metres per second requires the height above ground,
which comes from the MTF-01P's rangefinder. With flow as the only horizontal source the
EKF provides a relative position estimate (EKF_POS_HORIZ_REL), good enough for
local-NED navigation but anchored to nothing absolute.
EK3_SRC1_POSZ is 2 (Range Finder). The rangefinder is the EKF height source and the
barometer stays as an independent witness in the logs. Putting the barometer back in as
the height source (EK3_SRC1_POSZ = 1) remains available.
EK3_SRC1_YAW is 1 (Compass), and that turned out to be its own failure mode. Loiter
and PosHold convert the optical flow's body-frame velocity into the earth frame using
this yaw estimate, so a wrong yaw does not just point the nose the wrong way, it makes
the position controller correct in the wrong direction. The hall's magnetic field is
distorted makes the EKF reset yaw by 30 to 80 degrees at a
time, and we believe that this is the actual cause of the Loiter drift seen in the hall, not a flow or
rangefinder problem. Both AltHold and Loiter now fly on the real aircraft, Loiter
good in the lab and still imperfect but flyable in the hall with a "minimise ground
dwell before climbing" procedure. See
Loiter drifts in the hall for the full mechanism, the
mitigation, and the log evidence from two separate sessions.
Why EK3_SRC1_POSZ = 2 crashed the aircraft — and how we fly it now¶
Our aircraft flew with the rangefinder as the only EKF height source. On the ground
the rangefinder reads a constant ~0.02 m — a constant carries no usable height
information, so EKF3 never fused a single height measurement. The vertical estimate
became pure inertial integration of accelerometer bias and diverged quadratically
while the aircraft stood on the floor: −268 m after 90 s, −1070 m after three
minutes. When a fence breach forced LAND, the altitude controller tried to arrest a
1000 m descent that only existed in the filter and went to 100 % throttle — into the
hall ceiling. The full chain (fence sized inside the baro's downwash noise band,
ARMING_CHECK = 0 letting it arm at all) is analysed in the
incident report. The rangefinder hardware was
not at fault — it tracked the fatal climb sample by sample; the configuration made
its healthy readings meaningless.
One related lesson for anyone reproducing this: the barometer spikes 4–6.7 m on every takeoff from propeller downwash. Baro height is trustworthy in flight, but never place a fence or a decision threshold inside that near-ground noise band.
Moving the EKF height source back to the barometer is an option we have deliberately
not taken. The rangefinder is the sensor the task is about (Task 4 asks for altitude
and position hold using LiDAR and optical flow, and says nothing about which EKF
source parameter carries the vertical position), and the 2026-08-25 SITL work showed
the on-ground non-fusion was the RNGFND1_MIN_CM validity floor rather than the source
choice itself. It stays on the table if the real aircraft disagrees. So we fly
EK3_SRC1_POSZ = 2 as our own decision, under a safety protocol implemented in the
Pi-Code companion:
- a ground-drift GO/NO-GO in
preflight.pybefore every arming, plus a bench hand-lift test proving the EKF altitude follows a real lift; ARMING_CHECK = 41350and the geofence off, no baro-referenced threshold near the ground (these are set in Mission Planner and the companion verifies them read-only before every mission, refusing to fly withFC_PARAMS_MISMATCHwhen a flight-critical parameter differs from the published set, a re-enabled fence included; see Flight Parameters);- a rangefinder-gated pilot takeover that refuses when the EKF altitude and the raw rangefinder disagree;
- a continuous in-flight EKF-vs-rangefinder cross-check (
EKF_ALT_DIVERGED→ LAND); RNGFND1_GNDCLEAR = 5 cm, the closest match to the sensor's true ~2 cm mounting height that this firmware build allows (Mission Planner refused the true value when we tried it on 2026-09-21; 5 is the parameter's own minimum, not a re-measurement). The reading the EKF expects when landed now overstates the real height by about 3 cm, the best available given that floor.
Why the on-ground fusion never engaged has an answer in simulation: the 2026-08-25 SITL
work traced it to the RNGFND1_MIN_CM validity floor rather than to the height source
choice. Confirming that on the real aircraft is the open step. A colleague team flies
the same sensor with POSZ = 2 successfully, so a full parameter diff against their
aircraft stays on the list; further suspects were RNGFND1_GNDCLEAR (left at the 10 cm
default while the sensor sits ~2 cm up) and EK3_ALT_M_NSE. On 2026-09-21 we tried
closing that GNDCLEAR gap by setting it to the true ~2 cm mounting height and found
Mission Planner refuses anything below 5, so RNGFND1_GNDCLEAR = 5 is now the adopted
value (the firmware's own floor, not a re-measurement); whether that change is enough to
fix the on-ground fusion is still to be confirmed. The alternative
explanation is that their EKF drifts on the ground too but is simply never left standing
for minutes. The barometer remains an independent witness in the logs, and returning it
to the EKF as the height source stays an available option if the real aircraft
disagrees with the simulation.
The on-ground deadlock, and the pilot takeover¶
A flow-only vehicle may not report EKF_POS_HORIZ_REL while it sits on the floor:
optical flow needs height above ground before the filter trusts it, and there is no
height without a takeoff. A companion that insists on a position estimate before
arming therefore deadlocks — no estimate without flying, no flying without the
estimate.
The designed escape is the companion's --takeover mode: the pilot flies the first
metre by hand (Stabilize/AltHold), and the companion requests GUIDED only once the
aircraft is above ~0.8 m and the EKF reports a usable position estimate. Since the
crash, that handover gate additionally checks the rangefinder height and refuses
when EKF and rangefinder disagree — the EKF altitude alone had read +1070 m on the
floor and would have handed over instantly.
Verifying the estimate before flight¶
Two procedures, both from the Pi-Code companion repo, both possible with props off:
1. preflight.py — the standing-still check. Read-only FC inspection: parameters,
sensor health, EKF status flags, and an EKF-altitude drift check while the aircraft
is disarmed and motionless. The divergence that killed us is quadratic, so even its
first seconds are visible from the ground — twenty seconds of preflight.py would have
flagged it before any of the crash-day flights. It also mirrors live STATUSTEXT, so
"try to arm while it runs" shows the autopilot's own objection verbatim.
2. Hand-lift test with fclog.py running. fclog.py continuously records every
STATUSTEXT, mode change and EKF-flag transition. With it running (props off),
lift the aircraft by hand to about a metre and set it back down. The log must show the
rangefinder-derived height following the lift and the EKF position flags coming up —
on the ground a dead rangefinder and a healthy one both read ~0 m, so only a height
change proves the chain works. The mission code runs the same idea in the air:
verify_rangefinder_tracks_altitude() right after every takeoff.