Sensors and degradation¶
navkit.sensors.models
¶
Measurement sources derived from a reference trajectory.
The framework replays a recorded trajectory and synthesises the measurements a downstream estimator would receive. Two generators:
gnss_fixes
Samples the reference position at a configured rate and adds position
noise. Optionally adds a slowly varying common-mode bias, which is a
stand-in for multipath or a biased correction stream. It is explicitly a
surrogate: real multipath depends on satellite geometry and the
environment in ways this cannot represent, and the flag
multipath_is_surrogate records that.
visual_updates
Produces the relative-pose stream a visual front end would emit,
T_cur_prev, from consecutive reference poses, plus noise. The
convention matches the output of a standard two-frame relative pose
estimator.
Both generators are seeded. Given the same seed and the same reference trajectory they produce bit-identical output, which is what makes a scenario comparison a controlled experiment rather than a anecdote.
GnssConfig
dataclass
¶
GNSS measurement model, in metres and hertz.
VisionConfig
dataclass
¶
Visual front end surrogate, in degrees, metres and hertz.
gauss_markov(n, dt, sigma, tau, rng)
¶
First-order Gauss-Markov sequence with stationary std sigma.
tau is the correlation time. sigma == 0 gives an all-zero sequence.
gnss_fixes(reference, cfg)
¶
Synthesise a GNSS position-fix stream from a reference trajectory.
visual_updates(reference, cfg)
¶
Synthesise a stream of relative-pose measurements between frames.
For consecutive sampled poses this emits, for each current frame i, the
two blocks of the single transform T_prev_cur = T_{i-1}^{-1} T_i:
R_rel[i] = R_{i-1}^T R_i-- current-frame axes in the previous frame;t_rel[i] = R_{i-1}^T (p_i - p_{i-1})-- current origin in the previous frame.
R_rel is the rotation of T_prev_cur and must not be confused with
R_i R_{i-1}^T, which is the rotation of the inverse transform
T_i^{-1} T_{i-1}. Mixing the two makes the rotation and translation
blocks mutually inconsistent, and the resulting measurement stream does not
telescope: chaining it does not reproduce the trajectory. This is what a
two-frame relative pose front end outputs. The camera-to-IMU extrinsic is
assumed identity; a real system must transform both parts into the IMU frame
before fusing them, and this assumption is one of the recorded limitations.
Poses are resampled with SLERP on rotation, not by linear interpolation of matrix entries, so the resampled rotations stay orthonormal.
angular_rate_between(a, b)
¶
Body-frame angular rate between two trajectories on a common grid.
Exposed for diagnostics: it is the cleanest way to check that a timestamp-offset scenario actually shifted the signal by the intended amount. Rotation is resampled with SLERP.
rereference_visual_updates(vision, reference, cfg, time_offset_s=0.0)
¶
Make each delivered measurement relative to the last delivered frame.
:func:visual_updates measures frame i against frame i - 1 of the sampling grid. When frames
are dropped, the next delivered measurement is still against the dropped one, so a filter that compares
with the last frame it received is handed a measurement of a different transform. A front end that
tracks against its last keyframe would report the transform from that keyframe. This re-derives such a
measurement from the reference trajectory with a fresh draw of the same per-measurement noise, so its
error does not grow with the gap. A measurement whose predecessor was delivered is left untouched.
navkit.degrade.config
¶
Scenario schema, parsing and validation.
A scenario describes what is broken and for how long, not how the estimator should behave. Times are relative to the start of the replayed sequence, in seconds, so a scenario is portable between sequences of different length.
The validation is strict and returns every problem it finds rather than the first one, because a config that silently clamps a 45 s outage to 10 s is worse than one that refuses to run.
ConfigError
¶
Bases: ValueError
Raised when a configuration or scenario is invalid.
Outage
dataclass
¶
A time window in which a channel carries no usable data.
CameraDropConfig
dataclass
¶
Bursty frame loss, the realistic shape of frame drops under load.
A uniformly random per-frame drop is easy to write and misleading: real frame loss is correlated in time (buffer underruns, exposure stalls, transport hiccups) and a long gap between usable frames is what actually hurts a relative-pose front end. This generator produces bursts separated by a quiet period, with the dropped fraction controlled directly.
Scenario
dataclass
¶
One degradation configuration.
total_degraded_time_s()
¶
Wall-clock time each channel spends unusable, by simple union.
A channel that is disabled is unusable for the whole run, so its value
is -1.0 rather than 0.0: zero would read as "this channel was
never degraded", which is the opposite of what a disabled channel means.
scenario_from_dict(d, problems=None)
¶
Build a :class:Scenario from a plain mapping, collecting problems.
navkit.degrade.inject
¶
Deterministic degradation injection.
This is the layer that turns a healthy replay into an experiment. It takes the
clean synthetic sensor streams produced by :mod:navkit.sensors and applies,
in a fixed order:
- channel outages (GNSS, vision, IMU),
- time offsets (vision, GNSS, IMU),
- camera frame drops,
- measurement noise (GNSS is already noisy by construction; IMU and vision noise are applied here),
- a manifest of everything applied.
Determinism
Every stochastic step draws from an explicitly seeded
:class:numpy.random.Generator derived from the scenario seed and the channel
name. Two runs of the same scenario produce identical samples. The manifest
includes the seed and a hash of the scenario configuration, so a result file
can be tied back to the exact configuration that produced it.
Order matters and is fixed because it is observable: applying the time offset before or after dropping frames changes which frames survive, and applying IMU noise before or after a dropout changes the bias a filter sees on re-entry. Those are documented consequences of the chosen order, not accidents.
InjectedStreams
dataclass
¶
The degraded sensor streams handed to an estimator.
config_hash(scenario, *, estimator=None)
¶
Stable short hash of a scenario, for reproducibility records.
estimator is folded in when supplied. A benchmark record's hash is a
provenance label: it is what lets a reader tell that two runs used the same
setup. Hashing the scenario alone cannot do that, because the estimator is
configured separately and several of its settings (vision_fuse,
vision_anchor_modelled, vision_keyframe_interval, the anchor
sigmas) change the filter without touching the scenario at all. Three
structurally different filters then record one indistinguishable hash.
Pass the resolved estimator config -- {"class": ..., **cfg.as_dict()} --
not the raw override keys, so a record hashes the filter that actually ran.
mark_outages(t, outages)
¶
Boolean mask of samples inside any outage window.
apply_gnss_outage(gnss, outages, total_enabled)
¶
Mark fixes unavailable inside the outage windows.
available is always populated, even when there is no outage, so that
downstream code has one shape to handle.
apply_camera_drop(vision, drop_fraction, burst_period_s, seed)
¶
Drop frames in correlated bursts.
Within each period of burst_period_s a single contiguous burst of
frames is dropped. The burst length is chosen so the overall dropped
fraction matches drop_fraction; the rounding remainder is carried into
the last burst so the total is exact.
A single contiguous burst is a simplification: real drops are neither perfectly uniform nor perfectly contiguous. It is used because the quantity that matters for a relative-pose front end is the longest gap between usable frames, and this generator controls that directly.
apply_imu_outage(imu, outages)
¶
Remove IMU samples inside outage windows.
The estimator then receives a genuine gap in its inertial propagation, with the state frozen across it. This is the honest representation of a dead IMU: the filter cannot know what happened while it was blind, which is the point of the scenario.
apply_imu_noise(imu, scenario)
¶
Add noise and bias according to the scenario's IMU noise model.
inject(scenario, imu, gnss, vision, reference=None)
¶
Apply a scenario to clean sensor streams and return the degraded set.
build_manifest(scenario, imu, gnss, vision, gyro_bias, accel_bias)
¶
A record of what was injected, with measured rather than claimed counts.
navkit.synthetic
¶
Synthetic sequence generator.
CI must not download anything, so the test suite needs a sequence that is small, fast, and physically consistent. This module builds one from a closed-form motion: a superposition of smooth translation and rotation on a circlet-like path, sampled at any rate, from which the same IMU / GNSS / visual streams are synthesised as for a recorded sequence.
What this is good for
- deterministic, dependency-free tests of parsing, sync, injection, metrics and the estimators;
- a known-answer test: because the motion is analytic, the synthesised IMU can be integrated back and compared against the analytic pose, which validates the propagation maths independently of any dataset.
What this is not good for
It has no noise floor, no vibration, no motion blur, no lighting change and no operator behaviour. It must not be used to claim anything about real localization performance. It is a test fixture.
SyntheticConfig
dataclass
¶
Motion parameters for the generator.
Every amplitude is a peak-to-trough excursion and the motion is built
from 1 - cos(w t) terms, which means position, velocity, rotation and
angular rate are all zero at t = 0. That is deliberate and it is what
makes the fixture usable as a known-answer test: an estimator initialised at
the identity pose with zero velocity is then exactly correct at t=0, so
any subsequent error is attributable to the estimator and not to an
uninitialised state. A circular orbit cannot satisfy this -- every point on
a circle is a point of nonzero speed -- which is why the path is a smooth
wander rather than a loop.
seeded_scene(cfg, seed)
¶
A :class:SyntheticConfig with its motion parameters varied by seed.
The seed sweep already exists and varies sensor noise. That answers "is this filter overconfident under this noise", which is not the same question as "does this filter overconfident in general". Every published number comes from one trajectory, so a result that is an artefact of that trajectory -- a particular excursion, a particular turn -- would be indistinguishable from a property of the estimator. This makes that distinction testable.
Two invariants are preserved, and both are load-bearing:
duration_sandrate_hzare untouched, so a swept result is directly comparable to the committed single-scene baseline.- The motion is still built from
1 - cos(w t)terms, so position, velocity, and rotation are exactly zero att = 0regardless of the seed. An estimator initialised at the identity pose with zero velocity remains exactly correct at the first sample, which is the property that makes the fixture a known-answer test. A generator that varied the start state would quietly break that and make every error un-attributable.
Parameters are drawn jointly from one generator, so a given seed is a single reproducible scene rather than a sequence of independent ones.
analytic_pose(cfg, t)
¶
Pose at times t from the closed-form motion. Returns (N, 4, 4).
The body starts at the identity attitude (yaw and roll both zero) and the
first pose is exactly start_position.
synthetic_trajectory(cfg=None, name='synthetic')
¶
Sample the analytic motion into a :class:Trajectory.
analytic_kinematics(cfg, t)
¶
World acceleration and body angular velocity of the closed-form motion, by derivation.
Position is x0 + A (1 - cos(w t)) per axis, so the acceleration is
A w^2 cos(w t). Attitude is R = Rz(yaw) Rx(roll), so the angular velocity
in the body frame is (roll_dot, yaw_dot sin(roll), yaw_dot cos(roll)). Both are
exact, and neither involves a difference of nearly equal numbers.
This replaces a second finite difference of :func:analytic_pose with a step of
1e-5 s. That version divided a rounding error of about 1e-16 by 1e-10, so a one-ulp
change in cos (which differs between math libraries) moved the accelerometer by up
to 7e-5 m/s^2, and the benchmark disagreed between Linux and macOS or Windows at the
1e-7 level. Returns (a_world (N, 3), omega_body (N, 3)).
synthetic_imu(cfg=None, rate_hz=200.0)
¶
Analytic gyro and specific force for the synthetic motion.
The derivatives come from :func:analytic_kinematics, in closed form, so a test that
integrates this signal and compares against :func:analytic_pose is testing the
propagation maths and nothing else, on every platform.
offset_rotation_sequence(duration_s, rate_hz, omega)
¶
A pure constant-rate yaw about world Z, starting at the identity.
The simplest possible trajectory with a non-trivial, exactly predictable angular rate. Used by tests that need a known answer.