Skip to content

Fix iOS AHRS filter init, mag heading, rest, and ZUPT - #1

Draft
dpyeates wants to merge 14 commits into
mainfrom
cursor/ios-ahrs-filter-fixes-daba
Draft

dpyeates wants to merge 14 commits into
mainfrom
cursor/ios-ahrs-filter-fixes-daba

Conversation

@dpyeates

@dpyeates dpyeates commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

Get the iOS 18-state GPS/INS EKF working correctly before a full Android feature port.

Filter (fusionml/src/uNavINS)

  • Initialize only on finite, plausible accel/mag; clamp |ax| > g; atan2 heading; skip mag if the field is unusable
  • Allow init when dt <= 0 (first iOS IMU sample), then return without propagating
  • Mag heading is tilt-compensated; inclination is compared in NED; no yaw correction when banked past ~30°
  • Mag LPF/timer live on the instance (reset-safe); Kalman mag-bias states are not mixed with the LPF
  • GPS measurement is 6×6 with a gross-outlier gate; first fix accepted on TOW or non-origin lat/lon
  • Rest uses a 1 s sliding window plus gyro mean-magnitude, and exits on ~0.17 s of motion
  • ZUPT triggers from fresh GPS speed; when GPS confirms a stop it can pull back a diverged INS
  • Weak 1 g accelerometer tilt aid to relevel after turns (excluded during nG coordinated turns)
  • Baro fused at 5 Hz; unused IMU delay buffer removed

iOS

  • attitudeValid from isInitialized() + finite + health; no extra landscape heading offset
  • Declination has an explicit hasDeclination flag; poor hacc does not invent velocity
  • Playback applies WMM; X-Plane first TOW is nonzero
  • Emits atRest and zuptActive on each AHRS update

Android

  • JNI now calls uNavINS (the old fusion/ EKF is gone)
  • Kotlin implements the current Turbo Module spec, including recording stubs
  • Emits the same AhrsData fields as iOS (health, rest, ZUPT, declination, track/speed)
  • Location accuracy uses API 26+ speedAccuracyMetersPerSecond / verticalAccuracyMeters

Example app

  • Shows filter health, IMU rest, ZUPT, magnetic vs true heading, ground track, and NED velocity
  • PFD overlay reads ALIGNING until attitudeValid
  • RN 0.83 TypeScript config added so the example typechecks against library source
  • Packages updated on the RN 0.83 line: React Native 0.83.10, React 19.2.8, Skia 2.9.1 (not 2.11, which needs Reanimated 4)

Tests

  • Init/safety (including cardinal magnetic headings 0/90/180/270), static (desk, handheld, pickup), walking
  • Car: cruise, accel, brake, level 90° and 360° turns, stoplight ZUPT, urban GPS (no climb/descent)
  • Aircraft: cruise, accel/decel, climb, descent, coordinated / climbing / descending turns, 360° horizon, baro, GPS outage
  • Vertical and horizontal FPA, crab, ground track, NED velocity, lat/lon — eastbound heading and HFPA asserts are kept
  • Plant magnetometer is a real NED→body rotation of the World WMM field (bn/be/bd), not my = B·sin(heading) (that flipped 90° to 270°)
  • Plant matches iOS: 60 Hz IMU, GPS frozen between 1 Hz (ground) / 5 Hz (air) fixes, first sample dt = 0
  • yarn test:fusion runs in GitHub CI (128 uNavINS asserts, 0 failed)

CI

  • Turbo 2 pipeline renamed to tasks; ESLint Flow plugin; example app ignored
  • iOS job pinned to macos-15 + Xcode 16.4 (macos-latest is Xcode 26-only)
  • iOS uses pod update so RN 0.83.10 can move fmt 11.0.2 → 12.1.0
  • Android writes sdk.dir and installs NDK 27.1 via sdkmanager (Gradle zip download was corrupt)

Packaging

  • package.json ships fusionml; podspec includes XYZgeomag.hpp

Android recording/playback is still stubbed. The filter path now matches iOS.

Open in Web Open in Cursor 

cursoragent and others added 14 commits September 4, 2026 15:43
Make the 18-state EKF initialize safely on the first iOS IMU sample,
use tilt-compensated mag heading in NED, keep mag/rest state on the
instance, and stop rest/ZUPT from sticking or refusing to recover.
GPS is 6x6 with a gross-outlier gate; baro is rate-limited; packaging
ships fusionml and the WMM header.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Drive the filter like iOS (60 Hz IMU, GPS frozen between 1 Hz / 5 Hz
fixes, dt=0 first sample). Cover desk rest, handheld ZUPT, walking,
level car turns and stoplights, and coordinated flight. Drop the old
enhancement-named cases. Run fusionml tests in CI.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Typecheck/build-library failed because the required health field was
missing from the mock payloads.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Rename turbo pipeline to tasks so Turbo 2 can dry-run, add the Flow
ESLint plugin RN config expects, ignore the example app, and fix src
Prettier so lint can pass.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Car covers throttle, braking, and a full level circle without banking.
Aircraft covers climb, descent, accel/decel, climbing and descending
turns, and heading wrap after a 360.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Bump React Native 0.83.1 to 0.83.10 and React 19.2.8, along with matching
tooling. Expose atRest/zuptActive from iOS, and show filter health, rest,
ZUPT, ground track, and aligning state in the example app.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
The example app now typechecks against RN 0.83's typescript-config, mapping
react-native-ahrs to the library source. Drop the unused no-console disable
after the ESLint bump.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
macos-latest no longer ships Xcode 16.2, so iOS CI now runs on macos-15
with 16.4. Gradle 9 was not picking up ANDROID_HOME; write sdk.dir into
example/android/local.properties before the Android example build.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
iOS: run pod update so CocoaPods can move fmt 11.0.2 to 12.1.0 from the
new React Native podspec instead of failing on the stale Podfile.lock.

Android: install NDK 27.1.12297006 with sdkmanager (and retry) so Gradle
does not download a corrupt NDK zip during configure.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Assert vertical and horizontal flight-path angles on climb, descent,
level, stationary, and crab cases. Also check ground track, NED
velocity components, and lat/lon change so each public filter output
has a representative assertion.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Ground track, NED velocity, and longitude still cover eastbound motion.
Heading-at-90 is already covered by the walking-corner case.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
The eastbound case failed because the simulated body mag used
my = B * sin(heading), which is the opposite Y sign from a NED
body frame. Init heading is atan2(-By, Bx), so a 90° heading
looked like 270°. Restore the heading and HFPA asserts and drive
the plant with a consistent NED-to-body field.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Android CI failed compiling AhrsModule against the current Turbo
Module spec (recording APIs, removed setMagneticDeclination, Location
accuracy fields, Float vs Double). Point JNI at uNavINS instead of
the deleted fusion/ EKF, emit the same AhrsData fields as iOS, and
keep recording as explicit stubs until that path is ported.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
The Android NDK build failed because reset did `filter = uNavINS()`.
uNavINS has const members, so assignment is deleted. Reconstruct the
instance in place and call FlightPhaseDetector::reset() instead.

Co-authored-by: Darren Yeates <darren@sensorworks.co.uk>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants