# Common Helper — `ImuPreintegrator`

## Purpose

Shared GTSAM `CombinedImuFactor` preintegration buffer. Both C1 (VIO) and C5 (StateEstimator) consume the same FC IMU window and want the same preintegrated Δ-pose / Δ-velocity / Δ-bias quantities. Centralising the preintegrator avoids two inconsistent integrations of the same IMU stream.

## Used By

- C1 — Visual / Visual-Inertial Odometry.
- C5 — State Estimator.

## Interface (sketch)

```
class ImuPreintegrator:
    def reset_with_bias(bias: ImuBias) -> None
    def integrate_sample(sample: ImuSample) -> None
    def integrate_window(window: ImuWindow) -> None
    def current_preintegration() -> CombinedImuFactor
    def reset_for_new_keyframe() -> CombinedImuFactor   # returns the closed factor; clears state
```

## Implementation Notes

- Wraps GTSAM's `PreintegrationCombinedParams` + `PreintegratedCombinedMeasurements`.
- Holds the gyro/accel noise model from the camera-calibration artifact (which carries IMU-noise covariances per-deployment).
- Single-threaded by design — composition root binds one preintegrator instance to one writer thread.

## Caveats

- Bias drift is the responsibility of the consumers (C1 + C5) who call `reset_with_bias(...)` whenever their estimate of the IMU bias changes.
- The preintegrator does not own a clock — every `integrate_*` call requires a monotonic timestamp on the IMU sample.

## Cycle-1 operational reality

The shipped surface in `src/gps_denied_onboard/helpers/imu_preintegrator.py` (AZ-276) extends the sketch above; this section is the authoritative inventory of what cycle-1 consumers actually see. Sketch return types in § Interface remain accurate for *intent*; the precise types appear here.

- **Factory** — `make_imu_preintegrator(calibration: CameraCalibration) -> ImuPreintegrator` reads gyro/accel noise covariances from `calibration.metadata["imu_noise_model"]` and constructs `gtsam.PreintegrationCombinedParams.MakeSharedU(gravity_m_s2)`. Every key in the noise block is optional and independently defaulted, so partial blocks are honoured.
- **BMI088-class defaults** (used when `imu_noise_model` is absent — bring-up + unit tests only; production deployments MUST supply a per-deployment noise model in `CameraCalibration.metadata`): `accel_noise_density=1.86e-3 m/s²/√Hz`, `gyro_noise_density=1.87e-4 rad/s/√Hz`, `accel_bias_rw=4.33e-4 m/s³/√Hz`, `gyro_bias_rw=2.66e-5 rad/s²/√Hz`, `integration_noise=1e-8`, `gravity_m_s2=9.80665`.
- **`ImuPreintegrationError`** — single public exception type. Raised on (a) non-monotonic `sample.ts_ns`, (b) GTSAM's lower-level PIM rejection (wrapped, not propagated), (c) `current_preintegration()` / `reset_for_new_keyframe()` called with zero samples since last reset. Carries offending vs. previous `ts_ns` in its message so consumers can write FDR `kind="imu.skew"` events per AZ-276 Risk 2.
- **Actual return type** of `current_preintegration()` and `reset_for_new_keyframe()` is `gtsam.PreintegratedCombinedMeasurements` (the PIM), not a constructed `CombinedImuFactor`. Consumers build the factor at attach time as `gtsam.CombinedImuFactor(*keys, pim)` — the helper cannot know the GTSAM keys. The `CombinedImuFactor` alias is still re-exported (so consumers do NOT import GTSAM directly), but it names the **type** the consumer constructs, not the helper's return value. Contract `imu_preintegrator.md` v1.0.0 still reflects the original "returns CombinedImuFactor" wording — a contract minor revision is queued for the next contracts-folder sweep.
- **`reset_with_bias` is destructive in cycle-1** — it discards the partial integration accumulator (re-initialises the PIM with the new bias and a clean `last_ts_ns=None`). The contract's "subsequent samples only" wording is honoured at the new-window granularity: consumers MUST close the prior window via `reset_for_new_keyframe()` BEFORE changing bias if they want to retain its contribution.
- **First-sample dt handling** — the first sample after a reset is recorded (`_last_ts_ns` updated, `_sample_count` incremented) but NOT integrated (GTSAM rejects `dt==0`). The second sample is the first integration call. This matters for AC-1 length counting: 100 samples ⇒ 99 GTSAM integrations.

### Cycle-1 task lineage

- AZ-276 — initial helper, contract producer.
- No cycle-1 follow-up tasks touched this helper.
