E-CC-HELPERS closes with the three remaining Layer-1 helpers and E-CC-CONF closes with the env > YAML > defaults precedence test gate. All four tickets ship with frozen public surfaces, hermetic unit tests, and no upward (components.*) imports. * AZ-271 — tests/unit/shared/config/test_precedence.py (5 ACs + smoke test + helper that names the layer in failure messages). * AZ-282 — helpers/ransac_filter.py: static RansacFilter + RansacResult; cv2.setRNGSeed(0) for byte-equal determinism; median residual semantics pinned by contract. * AZ-276 — helpers/imu_preintegrator.py + make_imu_preintegrator; GTSAM PreintegratedCombinedMeasurements; strict-monotonic ts_ns guard runs before any state mutation. Adjacent hygiene: _types/nav.py ImuSample/ImuWindow now use ts_ns:int and the spec-mandated ImuBias dataclass. * AZ-278 — helpers/lightglue_runtime.py: structural R14 fix. LightGlueRuntime + non-blocking concurrent-access guard that raises rather than serialising. EngineHandle Protocol in _types/manifests.py + KeypointSet/CorrespondenceSet in _types/matching.py (Protocol surface adds approved by spec). Dependency conflict (Finding 1, user-approved): gtsam 4.2 (PyPI) is numpy-1.x-ABI only; opencv-python>=4.12 needs numpy>=2 at runtime. Resolution: opencv-python pin relaxed to >=4.11.0.86,<4.12. The D-CROSS-CVE-1 ratchet at ci/opencv_pin_gate.py is held at 4.11.0 with the original 4.12.0 floor restored once a numpy-2-compatible gtsam wheel ships. Full replay procedure in _docs/_process_leftovers/2026-05-11_d_cross_cve_1_opencv_pin_deferred.md. Tests: 294 passed, 2 skipped (cmake/actionlint env-skips, pre-existing). 43 new tests added for batch 5. Ruff check + format clean. Co-authored-by: Cursor <cursoragent@cursor.com>
8.8 KiB
ImuPreintegrator Helper Module
Task: AZ-276_imu_preintegrator
Name: ImuPreintegrator Helper
Description: Implement the shared ImuPreintegrator helper that wraps GTSAM's PreintegrationCombinedParams + PreintegratedCombinedMeasurements so C1 (VIO) and C5 (StateEstimator) consume one canonical preintegration of every FC IMU window. Single-threaded by design; one instance per writer thread, bound by the composition root. Bias drift remains the consumer's responsibility.
Complexity: 2 points
Dependencies: AZ-263_initial_structure
Component: shared.helpers.imu_preintegrator (cross-cutting; epic AZ-264 / E-CC-HELPERS)
Tracker: AZ-276
Epic: AZ-264 (E-CC-HELPERS)
Document Dependencies
_docs/02_document/contracts/shared_helpers/imu_preintegrator.md— frozen public interface this task produces._docs/02_document/common-helpers/01_helper_imu_preintegrator.md— design rationale and consumer mapping.
Problem
C1's VIO loop and C5's state estimator both consume the same FC IMU window every keyframe. Without a shared preintegrator:
- They drift into two slightly-different integrations of the same physical IMU stream (different sample-rejection rules, different bias-application order).
- The GTSAM
CombinedImuFactorshape that goes into C5's iSAM2 graph diverges from the one C1 uses for its own pose update, breaking the "single source of IMU truth" invariant insolution.md. - Per-deployment IMU noise covariances (which live in
CameraCalibration) get parsed twice, with subtle unit differences.
Outcome
- A single
ImuPreintegratoris the only path through which any onboard process integrates IMU samples for a GTSAMCombinedImuFactor. C1 and C5 import it; nothing else does. - The composition root binds ONE instance per writer thread; the helper's contract test confirms it does not acquire any locks (so no surprise serialisation under load).
- Sample monotonicity is enforced — non-monotonic samples raise
ImuPreintegrationErrorbefore any state is mutated. - Re-bias is explicit:
reset_with_biasis called by consumers when their bias estimate changes; the helper never re-estimates bias internally.
Scope
Included
ImuPreintegratorclass + factorymake_imu_preintegrator(calibration: CameraCalibration) -> ImuPreintegrator.- Integration entrypoints:
integrate_sample(ImuSample),integrate_window(ImuWindow). - Factor accessors:
current_preintegration() -> CombinedImuFactor,reset_for_new_keyframe() -> CombinedImuFactor(destructive). - Bias control:
reset_with_bias(ImuBias) -> None. ImuPreintegrationErrorexception type re-exported alongside the helper.- Re-export of GTSAM's
CombinedImuFactor(or a thin alias) so consumers do not import GTSAM directly. - Public interface contract published at
_docs/02_document/contracts/shared_helpers/imu_preintegrator.md.
Excluded
- IMU sample acquisition / FC adapter integration — C8.
- Bias estimation / re-bias logic — C1, C5.
- Multi-threaded sample feeding — out of contract; helper is single-thread by design.
- Serialising preintegrated factors to FDR records — C13.
- The ImuSample / ImuWindow / ImuBias DTOs themselves — owned by
_types/nav.py(AZ-263).
Acceptance Criteria
AC-1: Round-trip preintegration
Given a synthetic IMU sequence of 100 samples with strictly-monotonic ts_ns
When the producer calls integrate_sample 100 times then current_preintegration()
Then a CombinedImuFactor is returned whose deltaTij equals the time span and whose delta_pose is non-zero
AC-2: Strict monotonicity rejects non-monotonic samples
Given a preintegrator with the last integrated sample at ts_ns = T
When integrate_sample(sample) is called with sample.ts_ns <= T
Then ImuPreintegrationError is raised AND the preintegrator's internal accumulators are unchanged (a subsequent valid sample integrates as if the bad one never came)
AC-3: reset_for_new_keyframe is destructive
Given a preintegrator with N integrated samples
When reset_for_new_keyframe() is called
Then the returned factor reflects all N samples AND a subsequent current_preintegration() (with no further samples) raises ImuPreintegrationError
AC-4: Re-bias affects subsequent samples only
Given a sequence: reset_with_bias(bias_a), integrate 50 samples, reset_with_bias(bias_b), integrate 50 more
When current_preintegration() is called
Then the resulting factor reflects bias_a applied to samples 1–50 and bias_b applied to samples 51–100 (not bias_b retroactively)
AC-5: Determinism
Given two instances constructed from the same calibration and fed the same (bias, samples) sequence
When both call current_preintegration()
Then the outputs are deep-equal
AC-6: Single-threaded, lock-free
Given the helper's source code
When inspected by the contract test (static analysis OR runtime reflection)
Then no threading.Lock, RLock, Semaphore, or mutex is acquired anywhere in the integration path
AC-7: No upward imports (Layer 1 invariant)
Given the helper module
When a static-import check runs across gps_denied_onboard.helpers.imu_preintegrator
Then it imports ONLY from _types, GTSAM, numpy, and stdlib — no gps_denied_onboard.components.* imports anywhere
Non-Functional Requirements
Performance
integrate_samplep99 ≤ 200 µs on Tier-2 (Jetson Orin Nano Super) — overhead vs. inline GTSAM PIM ≤ 5 % (per E-CC-HELPERS hot-path NFR).current_preintegrationp99 ≤ 100 µs on the same hardware.
Reliability
- Pure deterministic: same inputs → byte-equal
CombinedImuFactoroutputs. ImuPreintegrationErroris the ONLY exception type the public surface raises on schema/timestamp violation; GTSAM's lower-level exceptions MUST be wrapped.
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | 100 monotonic samples → current_preintegration() |
factor deltaTij ≈ time span; non-zero delta_pose |
| AC-2 | non-monotonic sample injection | ImuPreintegrationError; internal state unchanged (next valid sample integrates correctly) |
| AC-3 | reset_for_new_keyframe then current_preintegration |
second call raises ImuPreintegrationError (state cleared) |
| AC-4 | re-bias mid-window | resulting factor distinguishes bias_a vs bias_b epochs |
| AC-5 | two instances, same input | deep-equal factor outputs |
| AC-6 | static / runtime lock check | no lock acquisition on the integration path |
| AC-7 | importlinter / grep gate | no gps_denied_onboard.components.* imports |
| NFR-perf | microbench integrate_sample (10k iterations on Tier-2 fixture) |
p99 ≤ 200 µs; overhead ≤ 5 % |
Constraints
- Public surface frozen by
_docs/02_document/contracts/shared_helpers/imu_preintegrator.mdv1.0.0. - Layer 1 Foundation only (per
module-layout.md§ Allowed Dependencies). NO upward imports. - GTSAM is the single math backend — do not introduce a second IMU-preintegration library.
- No new dependency beyond what AZ-263 / E-BOOT pinned.
Risks & Mitigation
Risk 1: Concurrent calls from a misconfigured composition root silently corrupt the GTSAM PIM accumulator
- Risk: Two threads call
integrate_samplesimultaneously; GTSAM's PIM is not thread-safe; numerical drift goes undiagnosed. - Mitigation: Helper is single-threaded by contract; the composition root binds one instance per writer thread. The contract test (AC-6) asserts no internal locking — making it a hard error if a future change tries to "make it thread-safe" instead of fixing the composition.
Risk 2: Sample-monotonicity-rejection silently masks an upstream FC clock skew
- Risk: A real IMU stream produces a non-monotonic sample (clock jitter); the helper rejects it; the consumer never learns.
- Mitigation:
ImuPreintegrationErrorcarries the offending vs. previous timestamp in its message so the consumer's catch-and-log path can record it as an FDRkind="imu.skew"event.
Runtime Completeness
- Named capability: GTSAM
CombinedImuFactorpreintegration viaPreintegrationCombinedParams+PreintegratedCombinedMeasurements(architecture / E-CC-HELPERS /01_helper_imu_preintegrator.md). - Production code that must exist: real GTSAM-backed integration; real noise-model parsing from
CameraCalibration; real strict-monotonic guard. - Allowed external stubs: none — GTSAM is the production runtime.
- Unacceptable substitutes: pure-numpy "approximate" preintegration that ignores GTSAM's covariance propagation; deterministic-fallback that returns a zero factor; "for now we just integrate position with Euler" placeholder. Each would silently break C5's iSAM2 covariance honesty (AC-NEW-4).
Contract
This task produces the contract at _docs/02_document/contracts/shared_helpers/imu_preintegrator.md.
Consumers MUST read that file — not this task spec — to discover the interface.