Skip to content

AP_NavEKF3: anchor yaw while ground-learning in MAG_CAL=7 - #34057

Open
andyp1per wants to merge 2 commits into
ArduPilot:masterfrom
andyp1per:pr-ek3-magcal7-ground-yaw-anchor
Open

AP_NavEKF3: anchor yaw while ground-learning in MAG_CAL=7#34057
andyp1per wants to merge 2 commits into
ArduPilot:masterfrom
andyp1per:pr-ek3-magcal7-ground-yaw-anchor

Conversation

@andyp1per

@andyp1per andyp1per commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

EK3_MAG_CAL=7 (6774c1b) learns the mag field states while on the ground, but a stationary vehicle gives the 3-axis fusion no yaw observability, so the yaw estimate can walk away from the compass heading and block arming. This anchors the yaw with a magnetic heading fusion while the vehicle is stationary on the ground.

Classification & Testing (check all that apply and add your own)

  • Checked by a human programmer
  • Tested manually, description below (e.g. SITL)
  • Tested on hardware
  • Logs available on request

Replay A/B on the two bench logs that hit the failure, same tree with only this change different. Unfixed reproduces the walk, 54 to 207 deg over 105 s.

The fixed-side runs are being re-measured against the current onGroundNotMoving gate. The earlier figures (both cores within 1 deg of the compass heading for the full log, and a second vehicle tracking DCM within 1.6 deg across 47 s stationary plus a ~200 deg hand rotation) were taken with an earlier !inFlight gate, which anchored through the rotation that the current gate deliberately releases.

Description

With mag field learning active on a stationary vehicle, a yaw error is absorbed by the body field states with zero innovation, so nothing corrects it and each core free-integrates its own gyro bias. We hit this on an octaquad at a site whose local field disagrees with the WMM tables: the walk starts the moment the origin is set and the earth field states reset to the tables, each core reconciling the mismatch by rotating yaw in its own direction at ~0.35 deg/s. The pre-arm reported "EKF3 Yaw inconsistent" growing 86 to 169 deg (so "Wait" could never clear it) while DCM held the true heading on the same magnetometer and the mag test ratio never exceeded 0.34 - the filter was self-consistent and wrong.

The fix fuses the magnetic heading alongside the 3-axis fusion while the vehicle is stationary on the ground in mode 7. The heading measurement comes from the raw compass, not the learned field states, so the anchor is not circular. With yaw pinned and the earth field held to the tables, the body field states become fully observable at a fixed heading, which is the battery-signature learning the mode exists for. Falling back to plain heading fusion on the ground (as mode 3 does) would also stop the walk, but it inhibits the ground learning that is the point of mode 7.

The gate is onGroundNotMoving rather than a simple in-flight test. Motion is what gives the 3-axis fusion yaw observability in the first place, so the anchor is only needed while stationary and should release as soon as the vehicle moves. onGroundNotMoving already gates the equivalent job at AP_NavEKF3_MagFusion.cpp:271, the STATIC yaw fusion that stops ground yaw drift when there is no yaw sensor. An !inFlight test looks equivalent but is not: it stays true well past takeoff on planes, and through the first 1.5 m of climb or 5 s of flight on copters, which would leave the heading anchored into exactly the motor interference window mode 7 exists to avoid.

While anchored, the same compass sample feeds both the heading fusion and the 3-axis fusion in one cycle, and the second fusion does not account for the correlation. With EK3_YAW_M_NSE at 0.5 rad the heading update is weak - at a converged ground yaw variance the gain is of order 0.005 - and the onGroundNotMoving gate confines it to a disarmed, stationary vehicle. Decimating the anchor would reduce it further but would also weaken the yaw gyro bias observation, which is what actually stops the walk rather than merely opposing it.

Replay of the failing log, unfixed vs fixed, against the compass-only DCM reference (the step at ~42 s is a real field disturbance that DCM also tracks). To be regenerated with the current gate:

pr_magcal7_yaw_anchor

Mode 4 (ALWAYS) shares the same on-ground observability gap but its behaviour is long-standing, so this change is scoped to mode 7 only.

@tridge

tridge commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Deprecated — see below for the updated review.

Previous review (2026-08-17)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Reviewed at head dc0c982fb7.

Verdict: REQUEST CHANGES. CI is green and no maintainer has weighed in yet, so treat this as input rather than a verdict that outranks priseborough's.

What's right, and worth saying first: the guard is exactly the existing mode-7 ground-learning gate, so the anchor is live precisely when the learning it references is live — that's the right coupling. States and covariance are handled consistently (full Jacobian update, P -= KHP symmetrised, quaternion renormalised), so there's no state-without-covariance bug. MAG_CAL 0/1/2/3/4 are provably unchanged. And the ground→flight transition is continuous: when inFlight goes true, inhibitMagStates routes to the FUSE_YAW branch which calls the same fuseEulerYaw(MAGNETOMETER), so the anchor isn't released, it becomes the sole yaw source. I checked for a state discard too — resetMagFieldStates() re-inflates P[19..21] but doesn't overwrite body_magfield, so the ground-learned values survive the in-flight reset. Mode 7's value proposition is intact on that axis.

Two things I think need a response before merge.

Issues

  • AP_NavEKF3_MagFusion.cpp:443 (mechanism at :1032-1034) — the anchor structurally cannot learn the body-field component perpendicular to horizontal earth field; it converts that component into yaw error. fuseEulerYaw's innovation is built from the raw magDataDelayed.mag with no subtraction of stateStruct.body_magfield. In every pre-existing caller inhibitMagStates is true, so there's no active learned bias and the assumption is sound — this is the first caller that runs it while states 19–21 are converging, so the assumption is newly violated. Working it through: the anchor sets yaw so the horizontal projection of the rotated measurement lies along the declination direction, which means the residual the body states absorb has zero perpendicular horizontal component by construction. Any true body bias in that direction is forced into yaw instead, with error atan(b_perp / H_earth) — for H = 0.2 G, b_perp = 0.02 G that's 5.7° of permanently anchored yaw error, which persists into the climb. This bears on the stated purpose: a battery whose magnetic signature happens to lie perpendicular to magnetic north is exactly the case mode 7 exists to capture, and it's the one case the anchor can't learn. I'm not suggesting changing the maths — subtracting body_magfield reintroduces the circularity you correctly avoided — but I think the limitation should be stated in the commit message and parameter doc rather than left implicit.
  • AP_NavEKF3_MagFusion.cpp:443 and :453 — one magnetometer sample is fused twice per cycle, and the first fusion pre-conditions the innovations that gate the second. fuseEulerYaw updates states and covariance and renormalises the quaternion; FuseMagnetometer then re-reads stateStruct.quat and recomputes MagPred/innovMag from the already-corrected attitude, using the identical sample. The concrete harm is at :582-592: magTestRatio and magHealth are computed from those pre-conditioned innovations, so a disturbed sample master would have rejected can now pass the 3-axis health gate and be absorbed into the field states. I checked every fuseEulerYaw call site in EKF2 and EKF3 (13 of them) — all the others are mutually exclusive with FuseMagnetometer, so :443 is the only place in the tree where both run on one sample. Decimating the anchor to ~1–2 Hz and/or inflating R_YAW for this call would keep the anchoring authority (which only needs to counter a ~0.35 deg/s walk) while cutting the double-counting by an order of magnitude. I haven't computed the equilibrium variance factor — that needs simulation — so treat the magnitude as unquantified while the mechanism is verified.
  • AP_NavEKF3_MagFusion.cpp:1099-1105 — the innovation gate is inoperative on this path: the rejection branch is guarded by if (inFlight) and the new call site guarantees !inFlight, so a gate failure only sets a flag that :587 later overwrites. Computed threshold: gate factor 3.0, R_YAW = 0.25 rad², so it can't trip below 85.9° of innovation — and even then doesn't reject; the per-step correction is bounded only by the ±28.6° clamp. To be fair the ungated-on-ground policy is pre-existing and deliberate (your comment at :1101-1102 explains why) and already applies to the FUSE_YAW branch for every other MAG_CAL value, so this is not a regression in the yaw path. What's new is that the ungated yaw update now also feeds the field-state learning via the point above. That combination is what I'd want a second opinion on.

Notes

  • :442 and AP_NavEKF3.cpp:277!inFlight is materially broader than the new doc text's "On the ground". For a copter it stays true from arming through ~1.5 m of climb or up to 5 s of powered flight — the window of maximum motor interference, which is what mode 7's own commit message says the mode exists to protect against. "Until the EKF detects flight" would be accurate, and it'd be worth disclosing that heading and 3-axis fusion now run concurrently.
  • :443, :1127-1129yawAnchored doesn't reliably mean fusion occurred: FinishFusion returns true when it skips the update to avoid a negative variance, and fuseEulerYaw returns true unconditionally. yawAnchored = fuseEulerYaw(...) && !faultStatus.bad_yaw; fixes it locally without changing the global return semantics that :299 and :267 depend on.
  • The new call opens a path to a full CovarianceInit() reset (whole P memset) during ground learning that didn't previously exist in mode-7. Rare, but a new failure mode with no log signature beyond faultStatus.bad_yaw.
  • Not zeroing yawTestRatio is defensible and arguably more truthful, but it changes three consumers on the arming path undisclosed — the 5-second auto heading-and-field-state reset in calcGpsGoodToAlign, the GPS pre-arm yaw check (a new pre-arm failure path in mode 7, notable when the motivation is unblocking arming), and the Copter EKF-failsafe compass variance.
  • XKFS.mag_fusion stays FUSE_MAG, so the log can't distinguish anchored from unanchored 3-axis fusion — in the one mode where a log reader most needs to know. A fourth enum value would be cheap.

Checked and clean: lane switching is unaffected — errorScore() never uses yawTestRatio, and the magTestRatio shrink applies identically on every unmasked core, so there's no relative lane advantage.

@tridge

tridge commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@andyp1per even though I approved the original mode 7 PR, I unfortunately don't remember it, and looking at PR 32200 I don't understand how it works. How is yaw observable on the ground? Where does it get the yaw from?

@tridge tridge removed the DevCallEU label Aug 19, 2026
@andyp1per

Copy link
Copy Markdown
Contributor Author

Short answer: on the ground, in mode 7, it isn't observable - and that's the bug this PR fixes.

Mode 7 turns on 3-axis fusion (earth + body field states) while the vehicle is stationary. 3-axis fusion has no independent yaw reference: the only heading information is in the magnetometer, and the filter is simultaneously free to explain any measurement with the body field states. On a stationary vehicle a yaw error and a body field offset are indistinguishable - the filter can rotate yaw and shift MX/MY/MZ to match, with zero innovation. So yaw isn't held by anything. It sits wherever setYawFromMag() left it at alignment and then free-integrates the z gyro bias. Once the origin is set and EK3_MAG_EF_LIM pins the earth field to the WMM tables, the residual has to go somewhere, and it goes into yaw. Each core walks in its own direction, which is what trips the yaw consistency pre-arm check. I measured 0.35 deg/s and 150 deg of walk on the bench with mag test ratio never above 0.34 - the filter is perfectly happy, it just isn't measuring heading.

That's the difference from the other ground modes. MAG_CAL 0/2/3 use heading fusion on the ground - the FUSE_YAW branch of SelectMagFusion(), which takes the tilt-compensated compass heading and fuses it as a direct yaw observation - that is where their yaw comes from. Mode 7 skipped that branch entirely because it's in FUSE_MAG, so it got the field learning but lost the heading reference.

This PR gives mode 7 the same heading observation the other ground modes already have, alongside the 3-axis fusion rather than instead of it, gated on onGroundNotMoving - motion is what makes yaw observable to the 3-axis fusion, so the anchor is only needed while stationary. That is also the gate already used for the STATIC yaw fusion in the no-yaw-sensor fallback of SelectMagFusion(), which exists to stop ground yaw drift for the same reason. The heading comes from the raw compass, not from the learned field states, so it isn't circular. Two consequences that matter:

  • yaw is pinned to the compass, so the walk stops and the cores stay consistent;
  • with yaw pinned and the earth field held to the tables, the body field states become observable at a fixed heading, which is the battery-signature learning the mode exists for. Without the anchor they were absorbing yaw error, so what mode 7 learned on the ground was partly garbage.

On whether that learning survives to be useful: it does. On the in-flight reset, resetMagFieldStates() rewrites earth_magfield and re-inflates P[19..21], but zeroStatesVarCov() only zeroes covariance rows and columns, not the states - so the ground-learned body_magfield carries through.

Known limitation, same one ordinary heading fusion has always had: a body field component perpendicular to the horizontal earth field is not separable from yaw by a heading measurement, so it shows up as a heading offset of asin(b_perp/H) rather than being learned - about 5.7 deg for H = 0.2 G and b_perp = 0.02 G. Subtracting body_magfield from the heading measurement would fix that on paper but reintroduces exactly the circularity above, so I left it.

The one thing I'd flag for you rather than defend: while anchored this fuses the same compass sample twice per cycle, once as a scalar heading and again as three components, and the second fusion doesn't account for the correlation. With EK3_YAW_M_NSE at 0.5 rad the heading update is weak - at a converged ground yaw variance of ~2 deg the Kalman gain is 0.005, so it perturbs the subsequent 3-axis innovations by about 1% - but it is statistically double-counting. Gating on onGroundNotMoving confines it to a disarmed, stationary vehicle. Decimating it further would cut the double-count, but it would also weaken the z gyro bias observation, which is the part that actually stops the walk rather than just opposing it. I'd rather do it this way and say so than hide it.

@andyp1per
andyp1per force-pushed the pr-ek3-magcal7-ground-yaw-anchor branch from dc0c982 to 5c02dd7 Compare August 19, 2026 19:12
@tridge

tridge commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Deprecated — see below for the updated review.

Previous review (2026-08-20)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_08_20_1016/devcall_pr_reviews.html

Re-reviewed at head 5c02dd713f (previous review was at dc0c982fb7); my earlier comment above is superseded.

Both concerns from my previous comment are now honestly disclosed, and where I could compute your numbers they're right — including one where my severity claim was too strong, which I'm withdrawing. Verdict: downgraded from REQUEST CHANGES to COMMENT. Three asks remain, none requiring a change to the algorithm.

The guard changed materially (!inFlightonGroundNotMoving && effectiveMagCal == GROUND_AND_INFLIGHT), so I re-traced this from scratch rather than re-checking.

Resolved

"!inFlight is broader than on-ground" — fixed, and more thoroughly than you claim: onGroundNotMoving requires onGround, which is literally !motorsArmed, so the anchor can never be active while armed, by construction. That's structural, not incidental.

Withdrawn — my previous comment was wrong or overstated

  • The double-fusion severity. Your arithmetic checks out exactly: R_YAW = 0.25 rad², and at 2° yaw sigma P = 0.0012185 so K = P/(P+R) = 0.00485. At that gain the pre-conditioning shifts the 3-axis innovations by ~0.5% and magTestRatio by ~1%, so "a disturbed sample master would reject can now pass" is real in mechanism but negligible in the converged state — it can only flip a ratio within ~1% of the boundary. I withdraw the severity; the mechanism stands.
    One refinement you didn't state: setYawFromMag resets yaw variance to sq(_yawNoise) = 0.25, i.e. P₀ = R, so the gain sequence is Kₙ = 1/(n+2)0.50 on the first sample after alignment, 0.091 at 1 s, 0.0049 at ~20 s. So "about 1%" is steady-state, reached ~10 s after alignment or after any ground yaw reset; in the first few seconds the double-count is tens of percent, and the direction is permissive.
  • The yawAnchored note was simply wrong. FinishFusion does return true on the skip path, but every path returning false returns before yawTestRatio is written — so yawAnchored == false correctly means "stale ratio, zero it", and the skip path writes a fresh truthful ratio and keeps it. Correct as written.

Still open

  • The one concrete ask from last round. The perpendicular-body-field limitation is documented only in this thread, not in the commit message or the param doc. We agree on the substance (you write asin where I wrote atan; both compute to 5.7°). That text won't survive into the tree as things stand.
  • The innovation gate is still inoperative here, and now provably so, since onGroundNotMoving ⇒ onGround and onGround/inFlight are mutually exclusive. Recomputed: yawTestRatio > 1 needs |innovYaw| > 85.94°, and even then there's no rejection. Pre-existing policy shared with the branch every other MAG_CAL value uses on the ground — not a regression.
  • XKFS.mag_fusion still can't distinguish anchored from unanchored 3-axis fusion.

New

The new guard leaves an unbounded window of exactly the condition the commit message diagnoses. Since the anchor is disarmed-only, trace the armed-on-ground state in mode 7: onGround false, inFlight false, magCalRequested true, magCalDenied false → FUSE_MAG with no anchor. I looked for any other yaw observation in that window — the 5 s ground reset is && !motorsArmed, the GSF bank isn't a yaw source unless EK3_SRCn_YAW says so, and GPS velocity carries no yaw at zero groundspeed. There is none. For a copter, inFlight needs 1.5 m of climb, 0.5 m of rangefinder rise, or 5 s of flight time — an armed copter idling on the pad satisfies none, so the window is unbounded; for a plane it's the whole taxi and takeoff roll.

This is not a regression against master, but it is a narrowing against your previous revision, which covered that window — and my own "too broad" note is what prompted the change. The trade is real in both directions, since anchoring while armed means anchoring under motor interference. Worth confirming it's deliberate and saying so in the commit message. Magnitude unconfirmed — the 0.35 deg/s bench figure was measured with no anchor and an unlearned bias, so it's an upper bound.

Doc wording

"While the vehicle is stationary on the ground" describes an armed stationary copter — a state that is provably not anchored. "While disarmed and stationary" is accurate and one word longer. The second clause implies motion restores observability, but onGroundNotMoving also clears on pure translation, which doesn't obviously separate body field from yaw (I didn't try to prove observability under translation in the full filter, so treat that as a question).

I independently confirmed several of your claims: onGroundNotMoving already gates the STATIC yaw fusion elsewhere; the ground-learned body_magfield survives the in-flight reset (resetMagFieldStates overwrites earth_magfield only, and P[19..21] re-inflate to 1σ = 0.05 G, larger than the 0.02 G example bias, so it acts as a prior); MAG_CAL 0/1/2/3/4 unchanged; and decimating would genuinely weaken the z gyro bias observation. The Copter EKF-failsafe consumer of yawTestRatio is now closed by the disarmed-only guard.

CI was 1 pass / 82 pending when I looked. This should go to @priseborough before merge regardless — he hasn't commented yet.

@andyp1per
andyp1per force-pushed the pr-ek3-magcal7-ground-yaw-anchor branch from 5c02dd7 to 43fa2ba Compare August 20, 2026 19:15
@andyp1per

Copy link
Copy Markdown
Contributor Author

Thanks - the three open asks are addressed.

The perpendicular-body-field limitation is now in the commit message and, in one sentence, in the EK3_MAG_CAL doc, so it survives into the tree rather than living only in this thread. The double-fusion disclosure is in the commit message too, including your point that the gain starts at 0.5 after a yaw reset and only settles to ~1% after a few seconds - that refinement is right and worth having on the record.

Param doc now reads "While disarmed and stationary", not "stationary on the ground".

XKFS.MAG_FUSION gets a fourth value, 3 = fuse mag with yaw anchored, so a log can tell the two apart.

On the armed-on-ground window: deliberate, and now stated in the commit message. onGround is literally !motorsArmed, so anchoring there means anchoring to a heading the running motors are disturbing. It also isn't the case that motivated the PR - the walk trips the pre-arm yaw consistency check, so the failure I set out to fix happens entirely while disarmed. Whatever walk is left in the armed window should be smaller than the 0.35 deg/s bench figure, since the disarmed anchor has converged the body field that was driving it by then, but I haven't measured that.

On the innovation gate: agreed it's inoperative here and I'm leaving it. It's the same ungated-on-ground policy every other MAG_CAL value already gets through the FUSE_YAW branch, and gating it only for mode 7 would reject exactly the large-initial-innovation case the anchor exists to correct.

On your translation question: updateMovementCheck() keys off gyro magnitude, gyro jerk, accel magnitude deviation from g, and accel jerk. A constant-velocity translation doesn't clear it - accel magnitude stays at g. What clears it is rotation or acceleration, and rotation is what makes the body field separable from yaw. Acceleration on the ground while disarmed is someone handling the vehicle, which isn't a heading you want anchored to anyway.

@tridge

tridge commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Deprecated — see below for the updated review.

Previous review (2026-08-22)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.

Full report: https://uav.tridgell.net/DevCallReviews/followups/2026_08_22_1100/devcall_pr_reviews.html

Re-reviewed at head 43fa2ba253 (previous review was at 5c02dd713f); my earlier comment above is superseded.

All three previously-open asks are resolved. Verdict stays COMMENT, nothing blocking. CI fully green (99/99), including the three jobs that would catch a malformed @Param/@Field doc. The delta contains no algorithm change — the guard, the fuseEulerYaw call and the yawTestRatio handling are byte-identical to the previous revision.

Resolved

  • The perpendicular-body-field limitation is now in the tree, not just the thread — in both the param doc and the commit message, and the wording states the mechanism rather than overclaiming. Magnitude re-checked: asin(0.02/0.2) = 5.739°, atan gives 5.711° — "5.7 deg" is right either way.
  • The doc wording is now "While disarmed and stationary". I re-verified the claim underneath rather than trusting my previous round: onGround = !motorsArmed in both branches, and updateMovementCheck() returns early with onGroundNotMoving = false whenever !onGround, so onGroundNotMoving ⇒ disarmed holds structurally.
  • The armed-on-ground gap is declared deliberate in the commit message. Your argument in the thread is the stronger one and I accept it: the failure this PR fixes is a pre-arm check failure, so it occurs entirely inside the disarmed window — the armed window was never the target.
  • XKFS.mag_fusion can now distinguish anchored fusion. I traced every consumer: magFusionSel has no behavioural consumer anywhere in the EKF — three writers, one reader in AP_NavEKF3_Logging.cpp — so adding a value can't change filter behaviour. uint8_t, so 3 fits with no format or packet-size change. The only in-tree log consumer is an ArduSub autotest exercising EK3_MAG_CAL 0/3/4, never 7, and no vehicle defaults to mode 7.

A correction I owe you

I previously questioned whether onGroundNotMoving clears under pure translation. You corrected this and you're rightupdateMovementCheck() keys off gyro magnitude, accel.length() - GRAVITY_MSS and two jerk metrics, all zero under constant-velocity straight translation, so it doesn't clear. My note was wrong.

New, all minor

  • The new log value can claim an anchoring that didn't happen. fuseEulerYaw returns true unconditionally, and FinishFusion returns early having changed nothing when KHP[s][s] > P[s][s] — so on that skip path yawAnchored is true and the log records FUSE_MAG_ANCHORED for a cycle with no state or covariance update. This is not a re-raise of the note I withdrew: for the original use of the flag (zeroing yawTestRatio) the semantics are correct. It's the new consumer that has the stricter meaning. if (yawAnchored && !faultStatus.bad_yaw) on the log assignment alone would make it exact.
  • The commit's observability argument is conditional on a parameter the guard doesn't test. "With the earth field held to the tables the body field states become observable at a fixed heading" is correct, but that holding requires EK3_MAG_EF_LIM > 0 (default 50) and have_table_earth_field. With EK3_MAG_EF_LIM = 0 the anchor still runs, but at fixed attitude three mag components can't separate three earth-field from three body-field states. Likely benign — the anchor still pins yaw, and the drift mechanism this fixes is itself table-driven — and the commit does state the precondition honestly. Unconfirmed severity: that's an argument from state/measurement counting, not an observability proof of the full 24-state filter.
  • Tools/autotest/ardusub.py's Python mirror of MagFuseSel still stops at FUSE_MAG = 2. Harmless today, but now incomplete against the C++ enum — one line.

The innovation gate is unchanged and still can't reject here (needs |innovYaw| > 85.94°, and rejection is behind if (inFlight) which is provably false). You've explicitly declined to change it on the ground that gating only mode 7 would reject exactly the large-initial-innovation case the anchor exists to correct — I find that sound and am closing it.

@priseborough still hasn't commented; reviewDecision is REVIEW_REQUIRED and the branch is 148 commits behind master, so a rebase with green CI is needed regardless.

@Hwurzburg Hwurzburg added the WikiNeeded needs wiki update label Aug 23, 2026
@andyp1per
andyp1per force-pushed the pr-ek3-magcal7-ground-yaw-anchor branch from 43fa2ba to 03d30cb Compare August 24, 2026 23:35
When EK3_MAG_CAL=7 (GroundAndInflight) learns the field states on the
ground, a stationary vehicle gives the 3-axis fusion no yaw
observability: a yaw error is absorbed by the body field states with
zero innovation and each core then free-integrates its own gyro bias.
On a vehicle whose local field disagrees with the WMM tables the yaw
walks away from the compass heading as soon as the origin is set and
the earth field states reset to the tables - each core in its own
direction, which trips the pre-arm yaw consistency check and blocks
arming. Observed on the bench at 0.35 deg/s and 150 deg of walk while
DCM held the true heading throughout, with the mag test ratio never
exceeding 0.34.

Fuse the magnetic heading alongside the 3-axis fusion while disarmed
and stationary. That pins yaw to the compass, and with the earth field
held to the tables the body field states become observable at a fixed
heading - which is the battery-signature learning the mode exists for.
The heading measurement comes from the raw compass, not the learned
field states, so the anchor is not circular.

Holding the earth field to the tables requires EK3_MAG_EF_LIM non-zero
and a table field, which is the default. With EK3_MAG_EF_LIM=0 the
earth field is initialised from the measurement instead, so the table
mismatch that drives the walk does not arise; the anchor still pins yaw
there, but three mag components cannot separate the earth and body
field states at a fixed attitude.

Gate on onGroundNotMoving rather than !inFlight. Rotation is what makes
yaw observable to the 3-axis fusion, so the anchor is only needed while
stationary, and onGroundNotMoving already gates the equivalent STATIC
yaw fusion used to stop ground yaw drift without a yaw sensor. It also
avoids !inFlight, which stays true well past takeoff on planes and
through the first 1.5 m of climb on copters.

That gate also requires the motors to be disarmed, so an armed vehicle
idling on the pad or taxiing runs 3-axis fusion with no anchor, as it
does today. That is deliberate: anchoring there would pin yaw to a
heading the running motors are disturbing. Mode 4 has the same on-ground
observability gap, but its behaviour is long-standing and is left alone.

The anchor inherits the limitation heading fusion has always had. A
body field component perpendicular to the horizontal earth field is not
separable from yaw by a heading measurement, so it lands in yaw as an
offset of asin(b_perp/H) - 5.7 deg for H = 0.2 G and b_perp = 0.02 G -
rather than being learned. Subtracting body_magfield from the heading
measurement would remove that on paper, at the cost of making the
anchor circular.

Leaving the yaw test ratio live while anchored is deliberate. It feeds
the pre-arm compass variance check, the GPS yaw check and the 5 second
ground heading reset, none of which mode 7 could reach while the ratio
was forced to zero. Modes 0, 2 and 3 already fuse the same heading and
leave the same ratio live on the ground, so this reports a yaw
disagreement mode 7 used to hide rather than adding a failure mode of
its own - a sustained 40 deg disagreement blocks arming on copter in
either mode.

While anchored the same compass sample is fused twice, once as a
heading and again as three components, with no allowance for the
correlation. At the converged ground yaw variance the heading gain is
0.005, so it moves the subsequent 3-axis innovations by around 1%; the
gain starts at 0.5 on the first sample after a yaw reset and decays
over the following few seconds. Decimating the anchor would cut the
double count but also weakens the z gyro bias observation, which is the
part that stops the walk rather than just opposing it.

Log the anchored case as its own XKFS.MAG_FUSION value so a log reader
can tell it from unanchored 3-axis fusion.

Replay reproduces the unfixed walk on the two bench logs that hit this,
54 to 207 deg over 105 s.
@andyp1per
andyp1per force-pushed the pr-ek3-magcal7-ground-yaw-anchor branch from 03d30cb to fc7735c Compare August 25, 2026 02:43
@tridge

tridge commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Deprecated — see below for the updated review.

Previous review (2026-08-25)

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Follow-up report: https://uav.tridgell.net/DevCallReviews/followups/2026_08_25_1328/devcall_pr_reviews.html

Re-reviewed at head fc7735c888; my earlier comment above is superseded. Both prior asks are resolved. Verdict: COMMENT — no blockers; one non-blocking coverage gap.

(The raw delta looks big, but master moved since my last review — several of this PR's earlier commits plus unrelated AP_RTC/terrain work are now merged. The PR itself adds two commits: the anchoring change and its ardusub log mirror.)

  • RESOLVED — the XKFS.MAG_FUSION log value could claim an anchoring that didn't happen: now guarded if (yawAnchored && !faultStatus.bad_yaw) (MagFusion.cpp:557). Traced end-to-end: FinishFusion returns fault when it skips the state update, fuseEulerYaw writes bad_yaw fresh before the label is read, so value 3 records only a completed yaw update.
  • RESOLVED — the ardusub FUSE_MAG_ANCHORED = 3 mirror is three-way consistent with the C++ enum and the XKFS field doc.
  • Verified safe — the anchor is ground-only: onGroundNotMoving && effectiveMagCal == GROUND_AND_INFLIGHT, and onGroundNotMoving requires onGround == !motorsArmed, so it can't fire in flight; no interference with GPS-yaw/EXTNAV/GSF/finalInflightYawInit.
  • ISSUE (non-blocking) — the new path ships with no regression coverage: FUSE_MAG_ANCHORED is defined but never used, and no autotest exercises EK3_MAG_CAL=7 — the existing MagFuse test covers only MAG_CAL 0/3/4 and asserts FUSE_MAG(2), never the anchored(3) value. Worth a test that arms/stations a sub in MAG_CAL=7 and asserts the anchored selection + ground yaw stability. (Found by the cold Codex pass and confirmed independently by the validation pass.)
  • NOTE — the "body-field states become observable" rationale is conditional on both EK3_MAG_EF_LIM>0 and have_table_earth_field (both disclosed); it concerns the secondary claim, not the primary fix.

CI was 99/99 green at the prior head; a fresh run is pending after the rebase.

Mirror the anchored XKFS.MAG_FUSION value and add a Sub leg asserting
it while disarmed and stationary, that arming releases the anchor, and
that disarming restores it. Without the anchoring change the leg fails
with mag fusion selection 2 where 3 is expected.
@andyp1per
andyp1per force-pushed the pr-ek3-magcal7-ground-yaw-anchor branch from fc7735c to f2d42a1 Compare August 25, 2026 18:52
@andyp1per

Copy link
Copy Markdown
Contributor Author

Test added as a fourth leg of the Sub FuseMag test, alongside the existing MAG_CAL 0/3/4 legs. It fails without the fix: reverting AP_NavEKF3_MagFusion.cpp to master gives "Expected mag fusion selection 3, found 2".

One correction to the shape you suggested. Arming is exactly when the anchor cannot be active - Sub takes the non-fly-forward branch of detectFlight(), where onGround is !motorsArmed, so an armed sub reads FUSE_MAG and a test that arms and asserts 3 would fail. The leg asserts both sides instead: anchored while disarmed and stationary, released on arm, restored on disarm. That pins down the disarmed-only gate rather than just the log value.

Being straight about the other half of what you asked for: the leg also checks that the heading holds within 5 deg over 30 s while anchored, but that part is a guard rather than a regression test. SITL's field comes from the same WMM tables the EKF resets to, so there is no mismatch to drive the walk, and the unfixed build fails at the selection assert before it reaches the heading check. Reproducing the real failure needs an induced table/field disagreement, which I have not attempted.

@tridge

tridge commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Automated review note — AI-generated (Claude), validated against the live diff. Please sanity-check before acting.
Full report: https://uav.tridgell.net/DevCallReviews/DevCallTopic/devcall_pr_reviews.html#pr34057

Re-reviewed at head f2d42a175e; my earlier comment above is superseded. APPROVE — no blockers. The one ISSUE from my previous round is resolved: the new Sub FuseMag test leg (anchored→released on arming→re-anchored on disarm) ran green in CI, and your correction of the suggested test shape was right — an armed sub has onGround=false, so asserting anchored-while-armed would have failed.

What I verified at this head: the anchor is structurally impossible while armed (onGround = !motorsArmed in both detectFlight() branches, arming sampled before detectFlight); it fuses the live delay-aligned calibrated compass heading each cycle so it tracks disarmed hand-rotation (or releases via the 3 deg/s movement check); the disclosed numbers reproduce independently (innovation gate trip 85.94°, steady-state heading gain 0.0049 at 2° sigma); EK3_MAG_MASK cores are unaffected; in-flight mode-7 behaviour is untouched and resetMagFieldStates preserves the ground-learned body field. A cold Codex second review raised the heading+3-axis double-fusion — that is the trade-off your commit message already discloses, so it was not adopted as a defect.

Two non-blocking notes:

  1. NOTEAP_NavEKF3_MagFusion.cpp:553-558: worth stating precisely in a comment: FUSE_MAG_ANCHORED in XKFS records a completed anchor update; a gate-rejected anchor cycle logs as ordinary FUSE_MAG with bad_yaw set and retains the failed yawTestRatio — same semantics the normal FUSE_YAW path has always had, so consumers see consistent meaning.
  2. NOTETools/autotest/ardusub.py:1068: the test pins the onGround half of the gate but not onGroundNotMoving; a disarmed-motion case (anchor releases during movement, re-engages after settling) would pin the second half too. Non-blocking, given you've already been candid that the SITL test is a selection-logic guard rather than a reproduction of the original yaw walk.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants