Skip to content

feat(falcon): v0.2.0 — Mahony complementary filter EKF#13

Merged
avrabe merged 1 commit into
mainfrom
falcon/v0.2
May 19, 2026
Merged

feat(falcon): v0.2.0 — Mahony complementary filter EKF#13
avrabe merged 1 commit into
mainfrom
falcon/v0.2

Conversation

@avrabe
Copy link
Copy Markdown
Contributor

@avrabe avrabe commented May 19, 2026

Summary

Replaces the v0.1 EKF stub with a real attitude estimator: Mahony, Hamel & Pflimlin (2008) complementary filter on SO(3), gravity-only correction. no_std + libm, embedded-ready. Adds a runnable accuracy bench that doubles as the v0.2 acceptance test.

What's in

  • crates/relay-ekf — the estimator. 16 unit + proptest cases covering EKF-P01..P05 surrogates.
  • examples/falcon-ekf-bench — 25 s × 200 Hz synthetic IMU trajectory (rest → roll → rest → yaw → rest), reports RMS attitude error vs ground truth in degrees, exits 0/1 on budget.
  • FV-FALCON-EKF-001 — v0.2 verification artifact with extractable fields.steps.
  • FEAT-FALCON-v0.2 bumped pendingapproved with achieved metrics inline.
  • CHANGELOG.md entry for falcon-v0.2.0.

Achieved on this branch

metric result budget
RMS-steady error (last 2.5 s) 3.31° ≤ 5°
Final error 3.02° ≤ 5°
Convergence to <5° (sustained) 0.68 s
Peak error during trajectory 19.8°
NaN/∞ in estimator output none none
Estimator wall time (5000 samples) 333 µs
cargo test --workspace 52 suites green all pass
Verification gate ✅ 4/4 artifacts, 13/13 steps all pass

Test plan

  • CI green on this PR
  • Verification gate posts ✅ sticky comment
  • cargo run -p falcon-ekf-bench --release locally prints PASS
  • (optional) cargo run -p falcon-ekf-bench --release -- --noise 0.2 still PASSes

After merge

TAG=falcon-v0.2.0 bash scripts/tag-and-release.sh

Triggers release.yml to build 5 binaries (linux x86_64/aarch64, macOS x86_64/aarch64, windows x86_64), cosign-keyless-sign each via Fulcio OIDC, compute SHA-256, publish the GitHub Release.

Honestly deferred

  • Magnetometer fusion → v0.4 with relay-att (heading is unobservable from gravity alone; residual yaw drift after the trajectory's yaw phase is fundamental)
  • Verus SMT proofs on quaternion algebra → v0.4 with src/ Verus-annotated track + Bazel verus_test rules
  • Lean WCET proof on Ekf::tick → v0.4 with rules_lean wiring (empirically ≤ 1 µs/tick; formal proof later)
  • WASM-component compilation → v0.3 when wit-bindgen integration arrives

🤖 Generated with Claude Code

Replaces the v0.1 stub with a real attitude estimator based on
Mahony, Hamel & Pflimlin (2008) on SO(3), gravity-only correction.
no_std + libm, suitable for the embedded synth→gale path.

What lands:

Crates
- relay-ekf — Ekf::tick(ImuSample) → VehicleState. Defaults
  Kp=2.0, Ki=0.05 tuned for 200 Hz–1 kHz consumer IMU. Bias bounded
  ±0.5 rad/s. Pure-math helpers (quat_mul, quat_conj,
  rotate_body_to_ned_inverse, cross, normalise, is_unit_quaternion)
  exported for the controller layer.

Example
- examples/falcon-ekf-bench — runnable accuracy bench. 25 s × 200 Hz
  synthetic trajectory (rest at 20° pitch → roll 0.3 rad/s → rest →
  yaw 0.5 rad/s → rest). Achieved: RMS-steady 3.31°, final 3.02°,
  convergence 0.68 s, peak 19.8°. PASS budget: ≤ 5° RMS-steady, ≤ 5°
  final, no NaN/∞.

Rivet
- FV-FALCON-EKF-001 — v0.2 verification artifact with extractable
  fields.steps (cargo test + release rerun + 4 k proptest fuzz +
  bench tests + bench binary smoke).
- FEAT-FALCON-v0.2 bumped pending → approved with achieved metrics
  inline in the description.

Verification posture
- cargo test --workspace: 52 test suites green (was 49 in v0.1)
- relay-ekf: 16 unit + proptest cases covering EKF-P01..P05 surrogates
- falcon-ekf-bench: 5 unit + integration tests
- python3 scripts/run-falcon-verification.py: ✅ 4/4 artifacts, 13/13 steps green
- rivet validate: 0 broken cross-references

Cross-product convention
- e = cross(measured, predicted) — the body-frame rotation Δq that
  maps predicted ŷ onto measured y has axis y × ŷ (right-hand rule,
  shorter arc). Initial implementation had cross(predicted, measured)
  which rotates the estimator away from truth; caught by the
  deterministic bench when the test reported 179° RMS error instead
  of the expected <5°. Documented in the source comment.

Honestly deferred
- Magnetometer fusion → v0.4 with relay-att (residual yaw drift
  is fundamental for an accel-only Mahony)
- Verus SMT proofs on quaternion algebra → v0.4 with src/ track
- Lean WCET proof → v0.4 with rules_lean wiring
- WASM-component compilation → v0.3 with wit-bindgen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

running 33 tests
test crc::tests::accumulate_slice_equals_individual ... ok
test crc::tests::empty_input_keeps_seed ... ok
test crc::tests::mavlink_reference_vector_123456789 ... ok
test crc::tests::order_matters ... ok
test crc::tests::single_byte_zero ... ok
test frame::tests::encode_rejects_payload_length_mismatch ... ok
test frame::tests::encode_rejects_too_small_output ... ok
test frame::tests::frame_msg_id_three_bytes_little_endian ... ok
test frame::tests::frame_payload_length_byte_matches ... ok
test frame::tests::frame_starts_with_magic_v2 ... ok
test frame::tests::peek_message_id_finds_heartbeat ... ok
test frame::tests::peek_rejects_v1_magic ... ok
test frame::tests::rejects_bad_crc ... ok
test frame::tests::rejects_truncated_header ... ok
test frame::tests::rejects_truncated_payload ... ok
test frame::tests::rejects_v1_magic ... ok
test frame::tests::rejects_wrong_crc_extra ... ok
test frame::tests::round_trip_heartbeat_through_frame ... ok
test heartbeat::tests::crc_extra_is_fifty ... ok
test heartbeat::tests::custom_mode_little_endian ... ok
test heartbeat::tests::decode_empty_payload ... ok
test heartbeat::tests::decode_rejects_long_payload ... ok
test heartbeat::tests::decode_rejects_short_payload ... ok
test heartbeat::tests::field_offsets_match_spec ... ok
test heartbeat::tests::mav_mode_flag_or_and_contains ... ok
test heartbeat::tests::payload_length_constant ... ok
test heartbeat::tests::round_trip_falcon_default ... ok
test heartbeat::tests::round_trip_gcs_default ... ok
test heartbeat::tests::round_trip_max_values ... ok
test heartbeat::tests::round_trip_zero ... ok
test frame::tests::encode_parse_round_trip_arbitrary_seq ... ok
test heartbeat::tests::round_trip_arbitrary ... ok
test frame::tests::parser_never_panics ... ok

test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 33 tests
test crc::tests::accumulate_slice_equals_individual ... ok
test crc::tests::empty_input_keeps_seed ... ok
test crc::tests::mavlink_reference_vector_123456789 ... ok
test crc::tests::order_matters ... ok
test crc::tests::single_byte_zero ... ok
test frame::tests::encode_rejects_payload_length_mismatch ... ok
test frame::tests::encode_rejects_too_small_output ... ok
test frame::tests::frame_msg_id_three_bytes_little_endian ... ok
test frame::tests::frame_payload_length_byte_matches ... ok
test frame::tests::frame_starts_with_magic_v2 ... ok
test frame::tests::peek_message_id_finds_heartbeat ... ok
test frame::tests::encode_parse_round_trip_arbitrary_seq ... ok
test frame::tests::peek_rejects_v1_magic ... ok
test frame::tests::rejects_bad_crc ... ok
test frame::tests::rejects_truncated_header ... ok
test frame::tests::rejects_truncated_payload ... ok
test frame::tests::rejects_v1_magic ... ok
test frame::tests::round_trip_heartbeat_through_frame ... ok
test frame::tests::rejects_wrong_crc_extra ... ok
test heartbeat::tests::crc_extra_is_fifty ... ok
test heartbeat::tests::custom_mode_little_endian ... ok
test heartbeat::tests::decode_empty_payload ... ok
test heartbeat::tests::decode_rejects_long_payload ... ok
test heartbeat::tests::decode_rejects_short_payload ... ok
test heartbeat::tests::field_offsets_match_spec ... ok
test heartbeat::tests::mav_mode_flag_or_and_contains ... ok
test heartbeat::tests::payload_length_constant ... ok
test heartbeat::tests::round_trip_falcon_default ... ok
test heartbeat::tests::round_trip_gcs_default ... ok
test heartbeat::tests::round_trip_max_values ... ok
test heartbeat::tests::round_trip_zero ... ok
test heartbeat::tests::round_trip_arbitrary ... ok
test frame::tests::parser_never_panics ... ok

test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 33 tests
test crc::tests::accumulate_slice_equals_individual ... ok
test crc::tests::empty_input_keeps_seed ... ok
test crc::tests::mavlink_reference_vector_123456789 ... ok
test crc::tests::order_matters ... ok
test crc::tests::single_byte_zero ... ok
test frame::tests::encode_rejects_payload_length_mismatch ... ok
test frame::tests::encode_rejects_too_small_output ... ok
test frame::tests::frame_msg_id_three_bytes_little_endian ... ok
test frame::tests::frame_payload_length_byte_matches ... ok
test frame::tests::frame_starts_with_magic_v2 ... ok
test frame::tests::peek_message_id_finds_heartbeat ... ok
test frame::tests::peek_rejects_v1_magic ... ok
test frame::tests::rejects_bad_crc ... ok
test frame::tests::rejects_truncated_header ... ok
test frame::tests::rejects_truncated_payload ... ok
test frame::tests::rejects_v1_magic ... ok
test frame::tests::rejects_wrong_crc_extra ... ok
test frame::tests::round_trip_heartbeat_through_frame ... ok
test heartbeat::tests::crc_extra_is_fifty ... ok
test heartbeat::tests::custom_mode_little_endian ... ok
test heartbeat::tests::decode_empty_payload ... ok
test heartbeat::tests::decode_rejects_long_payload ... ok
test heartbeat::tests::decode_rejects_short_payload ... ok
test heartbeat::tests::field_offsets_match_spec ... ok
test heartbeat::tests::mav_mode_flag_or_and_contains ... ok
test heartbeat::tests::payload_length_constant ... ok
test heartbeat::tests::round_trip_falcon_default ... ok
test heartbeat::tests::round_trip_gcs_default ... ok
test heartbeat::tests::round_trip_max_values ... ok
test heartbeat::tests::round_trip_zero ... ok
test heartbeat::tests::round_trip_arbitrary ... ok
test frame::tests::encode_parse_round_trip_arbitrary_seq ... ok
test frame::tests::parser_never_panics ... ok

test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.40s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 16 tests
test tests::cross_of_parallel_is_zero ... ok
test tests::ekf_p01_tick_preserves_unit_quaternion ... ok
test tests::ekf_p02_extreme_accel_does_not_produce_nan ... ok
test tests::ekf_p02_zero_accel_does_not_produce_nan ... ok
test tests::ekf_p03_innovation_monotone_with_tilt_disagreement ... ok
test tests::ekf_p04_static_rest_converges_to_gravity_aligned ... ok
test tests::ekf_p05_pure_yaw_gyro_does_not_destabilise_attitude ... ok
test tests::fresh_estimator_is_at_identity ... ok
test tests::normalise_nan_returns_none ... ok
test tests::normalise_zero_returns_none ... ok
test tests::quat_mul_identity_left ... ok
test tests::quat_mul_identity_right ... ok
test tests::rotate_inverse_identity_passes_through ... ok
test tests::ekf_p01_property ... ok
test tests::bias_estimate_bounded ... ok
test tests::ekf_p02_property_sequence ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 16 tests
test tests::cross_of_parallel_is_zero ... ok
test tests::ekf_p01_tick_preserves_unit_quaternion ... ok
test tests::ekf_p02_extreme_accel_does_not_produce_nan ... ok
test tests::ekf_p02_zero_accel_does_not_produce_nan ... ok
test tests::ekf_p03_innovation_monotone_with_tilt_disagreement ... ok
test tests::ekf_p04_static_rest_converges_to_gravity_aligned ... ok
test tests::ekf_p05_pure_yaw_gyro_does_not_destabilise_attitude ... ok
test tests::fresh_estimator_is_at_identity ... ok
test tests::normalise_nan_returns_none ... ok
test tests::ekf_p01_property ... ok
test tests::normalise_zero_returns_none ... ok
test tests::quat_mul_identity_left ... ok
test tests::quat_mul_identity_right ... ok
test tests::rotate_inverse_identity_passes_through ... ok
test tests::ekf_p02_property_sequence ... ok
test tests::bias_estimate_bounded ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 16 tests
test tests::cross_of_parallel_is_zero ... ok
test tests::ekf_p01_tick_preserves_unit_quaternion ... ok
test tests::ekf_p02_extreme_accel_does_not_produce_nan ... ok
test tests::ekf_p02_zero_accel_does_not_produce_nan ... ok
test tests::ekf_p03_innovation_monotone_with_tilt_disagreement ... ok
test tests::ekf_p04_static_rest_converges_to_gravity_aligned ... ok
test tests::ekf_p05_pure_yaw_gyro_does_not_destabilise_attitude ... ok
test tests::fresh_estimator_is_at_identity ... ok
test tests::normalise_nan_returns_none ... ok
test tests::normalise_zero_returns_none ... ok
test tests::quat_mul_identity_left ... ok
test tests::quat_mul_identity_right ... ok
test tests::rotate_inverse_identity_passes_through ... ok
test tests::bias_estimate_bounded ... ok
test tests::ekf_p01_property ... ok
test tests::ekf_p02_property_sequence ... ok

test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.80s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 5 tests
test tests::phases_are_consistent_with_trajectory_duration ... ok
test tests::quat_error_is_zero_for_identical_quaternions ... ok
test tests::rotate_ned_to_body_identity_passes_through ... ok
test tests::deterministic_bench_passes ... ok
test tests::noisy_bench_passes_loose ... ok

test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

--- noise=0 (deterministic) ---
samples 5000
RMS error (full) 3.306°
RMS error (steady) 3.312° (last 2.5 s)
peak error 19.804°
final error 3.023°
convergence time 0.68s (first sustained <5°)
estimator wall time 633 µs
NaN/∞ seen false
falcon-ekf-bench: PASS

running 9 tests
test tests::deterministic_across_ticks_with_same_time ... ok
test tests::fresh_stub_has_zero_time ... ok
test tests::innovation_is_quiet_in_stub ... ok
test tests::tick_passes_time_through ... ok
test tests::tick_returns_identity_quaternion ... ok
test tests::tick_returns_zero_position_and_velocity ... ok
test tests::unit_quaternion_check_rejects_clearly_non_unit ... ok
test tests::tick_innovation_within_healthy_range ... ok
test tests::tick_always_emits_unit_quaternion ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 9 tests
test tests::deterministic_across_ticks_with_same_time ... ok
test tests::fresh_stub_has_zero_time ... ok
test tests::innovation_is_quiet_in_stub ... ok
test tests::tick_passes_time_through ... ok
test tests::tick_returns_identity_quaternion ... ok
test tests::tick_returns_zero_position_and_velocity ... ok
test tests::unit_quaternion_check_rejects_clearly_non_unit ... ok
test tests::tick_always_emits_unit_quaternion ... ok
test tests::tick_innovation_within_healthy_range ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 9 tests
test tests::deterministic_across_ticks_with_same_time ... ok
test tests::fresh_stub_has_zero_time ... ok
test tests::innovation_is_quiet_in_stub ... ok
test tests::tick_passes_time_through ... ok
test tests::tick_returns_identity_quaternion ... ok
test tests::tick_returns_zero_position_and_velocity ... ok
test tests::unit_quaternion_check_rejects_clearly_non_unit ... ok
test tests::tick_innovation_within_healthy_range ... ok
test tests::tick_always_emits_unit_quaternion ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

running 9 tests
test tests::args_default_ports_for_gcs_mode ... ok
test tests::args_default_ports_for_vehicle_mode ... ok
test tests::args_rejects_missing_mode ... ok
test tests::args_rejects_unknown_mode ... ok
test tests::handle_inbound_propagates_bad_crc ... ok
test tests::handle_inbound_rejects_unsupported_message ... ok
test tests::handle_inbound_truncated ... ok
test tests::current_timestamp_is_monotone_within_a_run ... ok
test tests::vehicle_and_gcs_exchange_heartbeats_over_udp ... ok

test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s

[falcon-hello-demo] building release binary...
[falcon-hello-demo] launching gcs on 127.0.0.1:14700
[falcon-hello-demo] launching vehicle (4 Hz × 4s)
[falcon-hello-demo] vehicle sent 13 heartbeat(s)
[falcon-hello-demo] gcs received 13 heartbeat(s)
[falcon-hello-demo] PASS

falcon verification gate (filter: (has-tag "falcon"))

4 artifact(s) matched: FV-FALCON-MAVLINK-001, FV-FALCON-EKF-001, FV-FALCON-EKF-STUB-001, FV-FALCON-WORLD-001

[ PASS] ( 9.07s) FV-FALCON-MAVLINK-001: cargo test -p relay-mavlink
[ PASS] ( 10.41s) FV-FALCON-MAVLINK-001: cargo test -p relay-mavlink --release
[ PASS] ( 0.47s) FV-FALCON-MAVLINK-001: PROPTEST_CASES=4096 cargo test -p relay-mavlink
[ PASS] ( 1.28s) FV-FALCON-EKF-001: cargo test -p relay-ekf
[ PASS] ( 1.85s) FV-FALCON-EKF-001: cargo test -p relay-ekf --release
[ PASS] ( 0.87s) FV-FALCON-EKF-001: PROPTEST_CASES=4096 cargo test -p relay-ekf
[ PASS] ( 0.15s) FV-FALCON-EKF-001: cargo test -p falcon-ekf-bench
[ PASS] ( 0.56s) FV-FALCON-EKF-001: cargo run -q -p falcon-ekf-bench --release
[ PASS] ( 0.34s) FV-FALCON-EKF-STUB-001: cargo test -p relay-ekf-stub
[ PASS] ( 0.72s) FV-FALCON-EKF-STUB-001: cargo test -p relay-ekf-stub --release
[ PASS] ( 0.15s) FV-FALCON-EKF-STUB-001: PROPTEST_CASES=4096 cargo test -p relay-ekf-stub
[ PASS] ( 0.30s) FV-FALCON-WORLD-001: cargo test -p falcon-hello
[ PASS] ( 4.69s) FV-FALCON-WORLD-001: scripts/falcon-hello-demo.sh

✅ Rivet verification gate — falcon

4/4 passed

count
Passed 4
Failed 0
Skipped (no steps) 0

Source of truth: artifacts/verification/FV-FALCON-*.yaml.

@avrabe avrabe merged commit a06caf4 into main May 19, 2026
7 checks passed
@avrabe avrabe deleted the falcon/v0.2 branch May 19, 2026 14:35
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.

1 participant