Introduction
Welcome to the Strapdown-rs documentation! This guide will help you understand and use the Strapdown-rs library, a high-performance Rust implementation of strapdown inertial navigation system (INS) algorithms.
What is Strapdown-rs?
Strapdown-rs is a straightforward strapdown inertial navigation system (INS) implementation in Rust. It is designed to be simple, performant, and easy to understand, making it an excellent starting point for those interested in learning about or implementing strapdown INS algorithms.
The project provides:
- A source library of strapdown INS algorithms and utilities (
strapdown-core) - A simulation program for simulating INS performance in various GNSS conditions (
strapdown-sim) - Experimental geophysical navigation capabilities for GNSS-denied environments (
strapdown-geonav)
Key Features
- Modern Rust Implementation: Memory-safe, high-performance code with cross-platform support
- Multiple Navigation Filters: Extended Kalman Filter (EKF), Unscented Kalman Filter (UKF), and Particle Filters
- GNSS Degradation Scenarios: Simulate dropouts, reduced update rates, and measurement corruption
- Geophysical Navigation: Experimental work on gravity and magnetic anomaly navigation
- Comprehensive Documentation: Well-documented code with examples and tutorials
- Research-Focused: Designed for research, teaching, and development purposes
Target Audience
This library is intended for:
- Researchers working on navigation systems and sensor fusion
- Engineers developing autonomous systems, robotics, or aerospace applications
- Students learning about inertial navigation and state estimation
- Developers needing a high-performance INS implementation in Rust
Project Status
Strapdown-rs is under active development as part of ongoing PhD research. The project prioritizes correctness, numerical stability, and performance while maintaining extensibility for researchers and engineers.
Getting Help
- User Guide: Start with the Quick Start guide
- API Documentation: See the API Reference for detailed function documentation
- Examples: Check out the Examples and Tutorials
- Issues: Report bugs or request features on GitHub
Citation
If you use Strapdown-rs in your research, please cite:
License
Strapdown-rs is licensed under the MIT License. See the LICENSE file for details.
Installation
This page provides detailed instructions for installing Strapdown-rs on your system.
Prerequisites
Before installing Strapdown-rs, ensure you have the following:
- Rust: Version 1.91 or higher (install from rustup.rs)
- A C/C++ compiler and cmake >= 3.26, for the features that use a C library
Build Dependencies
There are no system libraries to install. libhdf5, libnetcdf, zlib and freetype are compiled from vendored sources that ship as ordinary cargo dependencies, so nothing is looked for on your machine. You only need a toolchain to compile them with.
Ubuntu/Debian
sudo apt update
sudo apt install -y build-essential cmake
Check cmake --version: the bundled HDF5 needs 3.26 or newer, which is more than Ubuntu
22.04 (3.22) or Debian 12 (3.25) ship. On those, install cmake from
Kitware's APT repository, or with pip install cmake, or with
snap install cmake --classic.
Fedora/RHEL
sudo dnf install -y gcc gcc-c++ cmake
macOS
xcode-select --install
brew install cmake
Windows
Install the MSVC toolchain from Visual Studio Build Tools, plus cmake. vcpkg is not needed.
Note:
strapdown-coreon its own needs none of this --cargo add strapdown-coreand a Rust toolchain are enough unless you turn on itshdf5ornetcdffeatures.
Installation Methods
Method 1: Install from Crates.io (Recommended)
The easiest way to use Strapdown-rs is to add it as a dependency in your project:
cargo add strapdown-core
Or add manually to your Cargo.toml:
[dependencies]
strapdown-core = "0.1"
To install the simulation binary:
cargo install strapdown-sim
Method 2: Build from Source
Clone the repository and build locally:
# Clone the repository
git clone https://github.com/jbrodovsky/strapdown-rs.git
cd strapdown-rs
# Build the entire workspace
cargo build --workspace --all-features --release
# Install the simulation binary
cargo install --path sim
# Optionally, with geophysical (gravity/magnetic) navigation. This is the variant that
# compiles libnetcdf from source, so it needs cmake.
cargo install --path sim --features geonav
The repository used to ship a
pixi.tomlfor environment management. It was removed once the C libraries began building from source, since there was nothing left for it to provide;cargo buildis now the whole story.
Verifying Installation
After installation, verify everything works:
# Check strapdown-sim version
strapdown-sim --version
# Run a simple test
cargo test -p strapdown-core
Troubleshooting
HDF5/NetCDF Build Issues
These libraries are compiled from source, so failures look like cmake or compiler errors rather than linker errors.
-
CMake 3.26 or higher is required-- your cmake is too old. See the Build Dependencies section above. -
Confusing cmake failures inside the netCDF build -- check whether
HDF5_DIRis set:echo "${HDF5_DIR:-unset}"If it is, the HDF5 build script quietly switches to looking for a system library instead of building the vendored one, and the netCDF build is then handed the wrong headers. Unset it. A leftover conda or pixi shell is the usual source.
-
cargo installis slow the first time -- that is the one-off source build of libhdf5 and libnetcdf, roughly a minute on a modern machine. It is cached afterwards.
Rust Version Issues
Ensure you're using a recent Rust version:
rustc --version
Inside a clone of the repository, rust-toolchain.toml pins the version and rustup fetches it
for you, so this should already agree with what CI uses.
Next Steps
- Continue to the Quick Start guide
- Learn about System Requirements
- Explore Building from Source in detail
System Requirements
This page outlines the system requirements for running Strapdown-rs.
Hardware Requirements
Minimum
- CPU: Any modern 64-bit processor (x86_64 or ARM64)
- RAM: 2 GB
- Storage: 100 MB for binaries, plus space for data files
Recommended
- CPU: Multi-core processor (4+ cores) for parallel processing
- RAM: 8 GB or more for large simulations
- Storage: SSD with sufficient space for trajectory data and results
Software Requirements
Operating Systems
Strapdown-rs supports all major operating systems:
- Linux: Ubuntu 24.04+, Debian 13+, Fedora 40+, or equivalent. Older releases work too, but only once a cmake newer than the distribution's own is installed -- see the C toolchain note below.
- macOS: 11.0 (Big Sur) or later
- Windows: Windows 10/11 with MSVC or MinGW
Rust Toolchain
- Minimum Rust version: 1.91
- Required components:
rustc,cargo,clippy,rustfmt
Contributors working in a clone of the repository do not need to choose a version: a
rust-toolchain.toml pins 1.91 and rustup fetches it automatically on the first cargo
command.
Install via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
C Toolchain
There are no system libraries to install. libhdf5, libnetcdf, zlib and freetype are all compiled from vendored sources shipped as ordinary crates.io dependencies. What you do need is a toolchain to compile them with:
- A C/C++ compiler: GCC, Clang, or MSVC.
- cmake, version 3.26 or later: required by the bundled HDF5.
Both are needed only when you build something that touches a C library -- strapdown-geonav
always, strapdown-core's hdf5/netcdf features, and strapdown-sim's plotting feature
(for freetype). cargo build -p strapdown-core on its own needs neither.
One caveat on "nothing is searched for": inside a clone of this repository that is exactly
true, because .cargo/config.toml sets FREETYPE2_NO_PKG_CONFIG and the HDF5/netCDF/zlib
builds are pinned to their vendored sources by cargo features. freetype-sys on its own would
prefer a system FreeType when pkg-config reports one, so cargo install strapdown-sim -- which
does not see this repository's cargo config -- may link the copy on your machine instead. Both
work; only the repository build is fully hermetic.
cmake 3.26 is newer than some distributions ship. Ubuntu 22.04 LTS has 3.22 and Debian 12 has 3.25; both fail. Install a newer cmake from Kitware's APT repository, with
pip install cmake, or withsnap install cmake --classic. Ubuntu 24.04 and Fedora 40+ are new enough as shipped.
Runtime
- libfontconfig: needed at run time by
strapdown-sim'splottingfeature, which loads it on demand rather than linking it. A machine without it builds fine and fails only when a plot is first rendered. Present by default on virtually every desktop Linux install; on a minimal container,apt install libfontconfig1.
A variable to keep unset
HDF5_DIR: if this is set, the HDF5 build script looks for a system library instead of building the vendored one, and the netCDF build then fails against those headers with an error that does not name the cause. Unset it (a leftover conda or pixi shell is the usual source).
Platform-Specific Notes
Linux
Install a compiler and cmake with your package manager -- for example apt install build-essential cmake -- then check cmake --version against the 3.26 note above.
Performance Note: For best performance, consider building with native CPU optimizations:
RUSTFLAGS="-C target-cpu=native" cargo build --release
macOS
Apple Silicon (M1/M2/M3) is fully supported. The ARM64 architecture provides excellent performance for navigation algorithms.
Xcode Command Line Tools provide the compiler (xcode-select --install) and brew install cmake provides the rest.
Windows
The MSVC toolchain from Visual Studio Build Tools, plus cmake, is all that is required. vcpkg is no longer needed: the C libraries are built from source rather than located on the system.
Alternatively, use WSL2 (Windows Subsystem for Linux) for a native Linux environment.
Development Requirements
If you plan to contribute to Strapdown-rs development:
- Git: Version control
- clippy: Rust linter (
rustup component add clippy) - rustfmt: Code formatter (
rustup component add rustfmt) - cargo-edit: For managing dependencies (
cargo install cargo-edit)
Testing Your Environment
After installing dependencies, verify your environment:
# Check Rust version
rustc --version
# Check the C toolchain
cc --version
cmake --version # must be 3.26 or newer
# Make sure no stale HDF5 override is in the environment
echo "HDF5_DIR=${HDF5_DIR:-(unset, good)}"
# Test compilation. The first run compiles libhdf5 and libnetcdf from source, which takes
# roughly a minute; later runs reuse them.
cargo build --workspace
Next Steps
- Proceed to Installing from Crates.io
- Or learn about Building from Source
Installing from Crates.io
Detailed instructions for installing from Crates.io.
Content coming soon.
Building from Source
Detailed instructions for building from source.
Content coming soon.
Quick Start
This guide will get you up and running with Strapdown-rs in minutes.
Installation
Add Strapdown-rs to your Cargo.toml:
cargo add strapdown-core
Or install the simulation binary:
cargo install strapdown-sim
Running Your First Simulation
1. Prepare Input Data
Strapdown-sim expects CSV files with IMU and GNSS data. The format follows the Sensor Logger app convention:
timestamp,gyro_x,gyro_y,gyro_z,accel_x,accel_y,accel_z,latitude,longitude,altitude
1234567890.0,0.01,-0.02,0.03,0.5,-0.2,9.81,45.5,-122.6,100.0
...
2. Declare the frame
Every command below assumes NED -- north, east, down -- which is the library default and
what strapdown-sim syn emits.
Sensor Logger exports are ENU: at rest the device reports +g along its up axis, not
-g along a down axis. For those, add --enu (or is_enu = true in a config file):
strapdown-sim cl --enu -i data/sensor_logger.csv -o results/output.csv
You will not get this wrong silently. The tool checks the declared frame against the leading records' sensed specific force and refuses a mismatch, naming the flag to pass -- mechanizing ENU records as NED adds the sensed force to the gravity model instead of cancelling it, and integrates at 2 g (#296).
3. Run a Simulation
Dead Reckoning (dr; ol is a different, unimplemented mode):
strapdown-sim dr -i data/input.csv -o results/output.csv
Closed-Loop (16-state ESKF, the default):
strapdown-sim cl -i data/input.csv -o results/output.csv
Closed-Loop with EKF:
strapdown-sim cl -i data/input.csv -o results/output.csv --filter ekf
Closed-Loop with UKF:
strapdown-sim cl -i data/input.csv -o results/output.csv --filter ukf
Particle Filter:
strapdown-sim pf -i data/input.csv -o results/output.csv --num-particles 100
4. View Results
The output CSV contains the estimated navigation state:
timestamp,latitude,longitude,altitude,velocity_north,velocity_east,velocity_down,roll,pitch,yaw
1234567890.0,45.50001,-122.59999,100.1,1.2,0.5,-0.1,0.01,0.02,90.5
...
Using Configuration Files
For more complex scenarios, use TOML configuration files:
# config.toml -- a whole `SimulationConfig`. `mode` is the only required field.
mode = "closed-loop" # dead-reckoning | open-loop | closed-loop | particle-filter | synthetic
input = "data/trajectory.csv"
output = "results/navigation.csv"
seed = 42
is_enu = false # NED is the default; Sensor Logger exports need `true`
[logging]
level = "info"
[closed_loop]
filter = "eskf" # eskf (default) | ukf | ekf
[aiding]
seed = 42
[aiding.scheduler] # when GNSS fixes arrive
kind = "fixed_interval"
interval_s = 5.0
phase_s = 0.0
[aiding.fault] # how they are corrupted
kind = "none"
Run it with the config alone — --config supplies the whole run, so a subcommand and its
-i/-o/--filter arguments are ignored when it is present:
strapdown-sim --config config.toml
core/tests/example_configs.rs parses every file under examples/configs/ and asserts it
deserializes into the scenario it describes, because every field has a default: a misspelled
section is dropped silently and the run simulates nothing.
Run with:
strapdown-sim --config config.toml
Next Steps
- Read the User Guide for detailed usage instructions
- Explore Example Configurations
- Learn about Navigation Filters
- Check the API Reference for programmatic usage
User Guide Overview
Welcome to the Strapdown-rs User Guide! This section provides comprehensive information on using the library and simulation tools.
What You'll Learn
This guide covers:
- Core Concepts: Understanding strapdown INS, coordinate frames, and state representation
- Running Simulations: How to use the
strapdown-simbinary - Data Formats: Preparing and formatting your input data
- Configuration: Setting up simulations with TOML config files
- Logging and Debugging: Monitoring simulation progress
Quick Navigation
For Beginners
If you're new to strapdown INS or this library:
- Start with Core Concepts to understand the fundamentals
- Learn about Coordinate Frames used in the library
- Review State Representation to understand the 9-state and 15-state models
- Try the Quick Start tutorial
For Experienced Users
If you're familiar with INS and want to jump in:
- Check Input Data Format to prepare your data
- Review Configuration Files for advanced options
- Explore Running Simulations for different modes
- See Logging for debugging and monitoring
Simulation Modes
Strapdown-rs supports three main simulation modes:
Dead Reckoning (dr)
Pure inertial navigation without corrections. Useful for:
- Understanding INS error growth
- Baseline comparisons
- Testing IMU data quality
Note that dead reckoning is dr, not ol. The ol subcommand is a separate feed-forward
mode -- its own help describes a Kalman filter estimating and applying corrections, so it
consumes GNSS and is not "without corrections" -- and it is not implemented: it validates
its paths, writes no output and prints "Open-loop mode is not yet fully implemented".
See: Open-Loop Mode
Closed-Loop (Kalman Filtering)
INS with GNSS corrections using the ESKF (the default), UKF or EKF, selected with
--filter. Best for:
- Realistic navigation scenarios
- GNSS degradation studies
- Production-like simulations
See: Closed-Loop Mode
Particle Filter
Non-parametric Bayesian filtering for non-Gaussian distributions. Useful for:
- Multimodal uncertainty
- Highly nonlinear scenarios
- Research applications
See: Particle Filter Mode
Navigation Filters
The library provides multiple filter implementations:
- Error-State Kalman Filter (ESKF): multiplicative attitude error. The default for
cl. - Extended Kalman Filter (EKF): Fast, efficient, works well for mildly nonlinear systems
- Unscented Kalman Filter (UKF): Better accuracy for nonlinear systems, 2-3x slower
- Rao-Blackwellized Particle Filter (RBPF): Canciani & Raquet's marginalized particle filter
-- horizontal position as particles; altitude, velocity, tilt, the barometer loop's two states
and the map biases as a Kalman filter shared by every particle. See
the RBPF page. The only particle filter implementation --
ParticleFilterTypehas this one variant, andparticle.rsis a module of building blocks rather than a filter that can be selected on its own
Learn more: Navigation Filters
State Models
9-State Model
The basic navigation-only model:
- Position: latitude, longitude, altitude
- Velocity: north, east, down
- Attitude: roll, pitch, yaw
15-State Model
Extended model with IMU bias estimation:
- 9 navigation states (as above)
- Accelerometer biases: 3 states
- Gyroscope biases: 3 states
The 15-state model provides better long-term accuracy by estimating and correcting sensor biases.
Typical Workflow
- Collect or prepare IMU/GNSS data in CSV format
- Create a configuration file specifying simulation parameters
- Run the simulation using
strapdown-sim - Analyze results from the output CSV
- Iterate by adjusting parameters as needed
Common Use Cases
Research and Development
- Testing new navigation algorithms
- Comparing filter performance
- Studying error characteristics
- Publishing research results
Education
- Teaching INS fundamentals
- Demonstrating sensor fusion
- Illustrating error sources
- Hands-on learning
System Development
- Prototyping navigation systems
- Evaluating sensor requirements
- Testing GNSS-denied scenarios
- Performance benchmarking
Getting Help
- FAQ: Check the Frequently Asked Questions
- Examples: Browse Example Configurations
- API Docs: See API Reference for detailed documentation
- Issues: Report problems on GitHub
Next Steps
Choose your path:
- New to INS? → Core Concepts
- Ready to simulate? → Running Simulations
- Need data format info? → Input Data Format
- Want advanced features? → Configuration Files
Core Concepts
Strapdown Mechanization
Coordinate Frames
State Representation
Running Simulations
Open-Loop Mode
Closed-Loop Mode
Particle Filter Mode
Input Data Format
Overview
The strapdown-rs project uses CSV data files for simulation and testing. The primary data format is compatible with the Sensor Logger mobile application, which records IMU, GNSS, and other sensor measurements.
CSV Column Descriptions
The data files contain the following columns (not all columns may be present in every file):
Timestamp
- time - ISO UTC timestamp of the form
YYYY-MM-DD HH:mm:ss.ssss+HH:MM(where the+HH:MMis the timezone offset)
GNSS Measurements
- latitude - Latitude measurement in degrees
- longitude - Longitude measurement in degrees
- altitude - Altitude measurement in meters
- speed - Speed measurement in meters per second
- bearing - Bearing measurement in degrees
IMU Measurements (Body Frame)
- acc_x - Acceleration in the X direction (meters per second squared)
- acc_y - Acceleration in the Y direction (meters per second squared)
- acc_z - Acceleration in the Z direction (meters per second squared)
- gyro_x - Angular velocity around the X axis (radians per second)
- gyro_y - Angular velocity around the Y axis (radians per second)
- gyro_z - Angular velocity around the Z axis (radians per second)
Attitude (Orientation)
- roll - Roll angle in degrees
- pitch - Pitch angle in degrees
- yaw - Yaw angle in degrees
- qw - Quaternion scalar component
- qx - Quaternion X component
- qy - Quaternion Y component
- qz - Quaternion Z component
Magnetometer
- mag_x - Magnetic field strength in the X direction (micro teslas)
- mag_y - Magnetic field strength in the Y direction (micro teslas)
- mag_z - Magnetic field strength in the Z direction (micro teslas)
Barometric Pressure
- pressure - Atmospheric pressure measurement (milli bar)
- relativeAltitude - Relative altitude measurement (meters)
Gravity Vector (Computed)
- grav_x - Gravitational acceleration in the X direction (meters per second squared)
- grav_y - Gravitational acceleration in the Y direction (meters per second squared)
- grav_z - Gravitational acceleration in the Z direction (meters per second squared)
Frame Convention
A CSV carries no frame tag, so the tool cannot tell from the file which local-level frame its
IMU readings are expressed in. You have to say, and the default is NED (north-east-down),
matching the library and strapdown-sim syn:
| Source | Frame | Vertical specific force at rest | Flag |
|---|---|---|---|
| Sensor Logger export | ENU | +9.8 m/s^2 along up | --enu |
strapdown-sim syn output | NED | -9.8 m/s^2 along down | (default) |
# A Sensor Logger recording
strapdown-sim dr -i recording.csv -o results/ --enu
# Anything generated by `syn` -- NED, so no flag
strapdown-sim syn -o synthetic.csv --duration-s 600 --seed 42
strapdown-sim dr -i synthetic.csv -o results/
The flag applies to dr, ol, cl and pf alike, and the same setting is available as
is_enu = true at the top level of a config file. syn --enu generates ENU data, so
syn --enu and dr --enu pair up the way plain syn and plain dr do.
Getting this wrong is not a small error: mechanizing NED records as ENU adds the gravity model to the sensed specific force instead of cancelling it, and the solution falls at 2 g. So the declaration is checked rather than trusted. Before propagating, the tool rotates the leading records' accelerometer readings into the navigation frame and compares the vertical component against the sign the declared frame requires; if they contradict each other it stops with
InvalidConfiguration { field: "is_enu", reason: "mean vertical specific force over the first
10 record(s) is +9.73 m/s^2, but NED mechanization expects -9.80 m/s^2 at rest: these look
like ENU records. ... Declare the frame that matches the data (pass `--enu`, ...)" }
The check only rejects; it never guesses. It is deliberately generous -- it takes roughly 1.5 g of sustained downward acceleration for a correctly declared file to trip it, so free fall and ordinary manoeuvring are fine -- and it passes anything ambiguous through.
Data Directory Structure
The project organizes data files into several directories based on processing type and GNSS condition:
Input Data
input/: Contains pre-processed input files ready for processing, along with route visualization images. Raw recordings are available upon request but not stored in version control due to size.
Ground Truth
truth/: Contains ground truth data files of processed trajectories used for validation and testing. These files serve as reference data for comparing alternative processing methods and navigation algorithms.
GNSS Degradation Scenarios
The following directories contain processed trajectory data that simulate various degraded GNSS conditions:
degraded/: Simulates degraded GPS conditions with reduced accuracyspoofed/: Simulates GPS spoofing with fixed position offsets to mislead the navigation systemintermittent/: Simulates intermittent GPS with periodic outagescombo/: Simulates a combination of degraded, spoofed, and intermittent GPS conditions
Usage
Loading Data
The strapdown-core library provides functions to load CSV data:
#![allow(unused)] fn main() { use strapdown_core::sim::load_test_data; let data = load_test_data("path/to/data.csv")?; }
Testing with Degraded GNSS
To test alternative processing methods and navigation algorithms under degraded conditions:
- Design your navigation algorithm to leverage the available data in the input files
- Configure your experiment's
AidingConfigto match the specific characteristics of the degraded condition you want to simulate - Process your experiment accordingly
Example Usage
# Run closed-loop simulation with input data
strapdown-sim cl -i data/input/trajectory.csv -o results/output.csv
# Test with degraded GNSS
strapdown-sim cl -i data/degraded/trajectory.csv -o results/degraded_output.csv
Data Collection
Data can be collected using the Sensor Logger mobile application:
- Available for iOS and Android
- Records synchronized IMU and GNSS data
- Exports in CSV format compatible with this project
- Website: https://www.tszheichoi.com/sensorlogger
Notes
- Not all columns need to be present in every file
- Missing values are typically represented as empty strings or
NaN - The simulation functions will skip rows with invalid or missing critical data (e.g., IMU measurements)
- Timestamps should be monotonically increasing
- IMU data is typically recorded at ~100 Hz
- GNSS data is typically recorded at ~1 Hz
Configuration Files
A scenario configuration describes how the aiding sensors behave during a simulation: when their measurements are delivered and, for GNSS, how they are corrupted. It is a single document, and it can be written as YAML, JSON or TOML — the format is chosen by the file extension.
--config parses a whole SimulationConfig, so the aiding scenario is nested under
aiding: and the document must set mode. A bare aiding fragment is rejected with
missing field `mode` rather than half-loaded:
mode: closed-loop # required -- see the mode table below
input: data/input.csv
output: results/output.csv
is_enu: false # NED is the default; Sensor Logger exports need `true`
aiding:
scheduler: # when GNSS fixes arrive
kind: duty_cycle
on_s: 120.0
off_s: 30.0
start_phase_s: 0.0
fault: # how they are corrupted
kind: none
baro_scheduler: # when barometric altitude arrives; 1 Hz if omitted
kind: fixed_interval
interval_s: 1.0
phase_s: 0.0
magnetometer_scheduler: # when a magnetometer heading arrives; 1 Hz if omitted
kind: fixed_interval
interval_s: 1.0
phase_s: 0.0
baro_noise_std_m: 2.23606797749979 # barometer one-sigma, metres; this is the default
seed: 42 # for reproducibility
strapdown-sim --config scenario.yaml
--config supplies the entire run, so a subcommand and its arguments — -i, -o, --enu,
--seed — are ignored when it is present. Set them in the file, as above.
mode does not take the CLI subcommand names. The subcommands are abbreviations; the
config spellings are the SimulationMode variant names in kebab-case, and anything else is
rejected as an unknown variant:
mode: in a config | CLI subcommand |
|---|---|
dead-reckoning | dr |
open-loop | ol (not implemented -- writes no output) |
closed-loop | cl |
particle-filter | pf |
synthetic | syn |
Names must be exact
Every field has a default, so a misspelled kind does not error. The section is silently
dropped and the run proceeds with no degradation at all — a scenario that looks like it ran
and simulated nothing. This is the single most common way to get a wrong result out of the
simulator.
Config files use the names of the MeasurementScheduler and GnssFaultModel variants:
| Section | Valid kind values |
|---|---|
scheduler, baro_scheduler, magnetometer_scheduler | pass_through, fixed_interval, duty_cycle |
fault | none, degraded, slow_bias, hijack, combo |
The CLI flags use different spellings for the same things: --sched passthrough|fixed|duty
and --fault none|degraded|slowbias|hijack. The duty-cycle phase is --duty-phase-s on the
command line and start_phase_s in a config file, while phase_s in a config file belongs to
fixed_interval only.
core/tests/example_configs.rs guards every file in examples/configs/ against this class of
mistake: it parses each one and asserts the result is the scenario the file describes, rather
than a silently-defaulted pass-through.
Schedulers
Every snippet in this section is a fragment of the aiding: block, shown unindented
for readability. Copying one to the root of a config file leaves aiding.scheduler and
aiding.fault at their defaults, and the unknown root key is ignored — a scenario that
looks configured and runs with no degradation at all.
pass_through
Every fix reaches the filter. The baseline for comparing degraded runs.
scheduler:
kind: pass_through
fixed_interval
One fix every interval_s seconds, the rest discarded. Models a reduced update rate.
scheduler:
kind: fixed_interval
interval_s: 10.0
phase_s: 0.0 # offset before the first emitted fix
duty_cycle
Alternating windows of availability and denial. The timeline is start_phase_s of initial
availability, then off_s denied and on_s available, repeating.
scheduler:
kind: duty_cycle
on_s: 120.0 # 2 minutes available
off_s: 30.0 # 30 seconds denied
start_phase_s: 0.0
Every fix inside an ON window is delivered, and none inside an OFF window.
The other two aiding channels
The barometer's noise
baro_noise_std_m is the one-sigma barometric altitude uncertainty in metres, and it is
the only aiding-noise knob a config file has: the Sensor Logger format carries no
pressure-accuracy column, so unlike GNSS — whose noise comes from each record's
horizontal_accuracy — nothing in a log can supply one. Set it to model a good barometer or a
bad one.
It is a standard deviation. The value it replaced lived in a trait impl as diag([5.0]),
which is a variance, so the default here is its square root and the filter sees the same
$R$ it always did. Setting baro_noise_std_m: 5.0 is not the old behaviour — it is five times
looser.
The barometer's bias
A barometer does not read altitude, it reads pressure, and the reference pressure it is converted against drifts — roughly a hectopascal an hour, which is about 8.3 m. A filter that models the reading as unbiased has nowhere to put that drift except into altitude.
estimate_baro_bias gives the filter a state for it. It belongs to the closed_loop
section, not to the aiding config above, because it changes the filter rather than the sensor:
closed_loop:
filter: ukf
estimate_baro_bias: true
It is on by default as of the v1.0 freeze, so this section only needs writing to turn it
off (estimate_baro_bias: false, or --no-estimate-baro-bias on the command line when not
using --config). The output carries baro_bias and
baro_bias_cov columns; without it both are empty, which is how a reader tells "no bias state"
from "bias estimated at zero".
Measured on core/tests/test_data.csv, switching it on moves the vertical channel in all
three Kalman filters at once:
| 3σ containment | vertical bias | vertical RMSE | |
|---|---|---|---|
| off | 0.40 | +0.40 m | 2.58 m |
| on | 0.84 | −0.02 m | 1.41 m |
Containment is the fraction of epochs whose true altitude lies inside the filter's own 3σ band, so its ideal is 0.9973. The three filters converge independently on a bias of about −0.6 m, which is the corroboration a single filter could not give.
The one measured case that gets worse is an AR(1)-degraded GNSS position fault, where the bias
state is unidentifiable — it is aided by a second source that is itself biased, so it absorbs
the GNSS fault instead of the barometer's drift, and vertical RMSE rises about 30%. That is
tracked as issue #410; --no-estimate-baro-bias turns the state off for such a run.
baro_scheduler and magnetometer_scheduler take the same three kinds and the same fields,
each with its own independent clock, so a GNSS outage, a barometer outage and a heading outage
can be configured separately or made to overlap. Neither channel is corrupted: fault governs
GNSS only.
Both default to fixed_interval at 1 Hz, not to pass_through. Before #375 they were not
scheduled at all — one measurement per record, whatever the log's sample rate happened to be.
On a 1 Hz Sensor Logger export that is right by coincidence; on a 50 Hz synthetic trajectory it
delivered fifty pressure readings and fifty derived headings every second, each entering the
filter as an independent fix with full weight. A heading re-read from the same field vector
fifty times is one measurement counted fifty times, and the UKF diverges on it.
Set kind: pass_through to get the old behaviour back, when your log's rate really is the
sensor's rate:
magnetometer_scheduler:
kind: pass_through
There are no CLI flags for these two; they are configurable from a file only.
Fault models
Every snippet in this section is a fragment of the aiding: block, shown unindented
for readability. Copying one to the root of a config file leaves aiding.scheduler and
aiding.fault at their defaults, and the unknown root key is ignored — a scenario that
looks configured and runs with no degradation at all.
Faults corrupt the content of a fix; the scheduler decides whether it arrives at all. The two compose, so a duty cycle can deliver degraded fixes during its ON windows.
# No corruption
fault:
kind: none
# AR(1)-correlated position and velocity error, with inflated reported covariance.
# Models low signal-to-noise or multipath.
fault:
kind: degraded
rho_pos: 0.99 # correlation of the position error, per step
sigma_pos_m: 3.0
rho_vel: 0.95
sigma_vel_mps: 0.3
r_scale: 5.0 # factor the advertised covariance is inflated by
# A slowly drifting offset. Models soft spoofing: the trajectory is nudged
# gradually, and each individual fix looks plausible.
fault:
kind: slow_bias
drift_n_mps: 0.02
drift_e_mps: 0.0
q_bias: 1e-6
rotate_omega_rps: 0.0
# A hard offset during a fixed window. Models hard spoofing.
fault:
kind: hijack
offset_n_m: 50.0
offset_e_m: 0.0
start_s: 120.0
duration_s: 60.0
combo takes a list of the above and applies them in sequence, each feeding into the next.
Reproducibility
seed drives every stochastic part of the fault models. The same seed with the same
configuration produces identical output, which is what makes a reported error metric
meaningful. Vary the seed to generate realizations for Monte Carlo work.
Worked files for all of these live in
examples/configs/.
Logging Guide
This project uses the Rust log crate with env_logger as the backend, providing a Python-like logging experience.
Usage
Command-Line Options
The strapdown-sim executable supports the following logging options:
--log-level <LEVEL>: Set the log level (off, error, warn, info, debug, trace)- Default:
info
- Default:
--log-file <PATH>: Write logs to a file instead of stderr- If not specified, logs are written to stderr
Examples
Basic usage with default settings (info level to stderr):
strapdown-sim cl -i input.csv -o output.csv
Set log level to debug:
strapdown-sim cl -i input.csv -o output.csv --log-level debug
Write logs to a file:
strapdown-sim cl -i input.csv -o output.csv --log-file simulation.log
Combine log level and file output:
strapdown-sim cl -i input.csv -o output.csv --log-level debug --log-file debug.log
Disable logging:
strapdown-sim cl -i input.csv -o output.csv --log-level off
Log Levels
The following log levels are available, from least to most verbose:
- off: No logging output
- error: Only errors that prevent operation
- warn: Warnings about potentially problematic situations
- info: General informational messages (default)
- debug: Detailed information useful for debugging
- trace: Very detailed trace information
Log Format
Log messages are formatted as:
YYYY-MM-DD HH:MM:SS.mmm [LEVEL] - message
Example:
2025-12-01 14:30:45.123 [INFO] - Read 1000 records from data/input.csv
2025-12-01 14:30:45.456 [INFO] - Running particle filter with 100 particles
2025-12-01 14:30:50.789 [INFO] - Results written to output.csv
RUST_LOG is not read
This page used to document RUST_LOG as an override that --log-level took precedence over.
It is not an override and there is nothing to take precedence over: RUST_LOG has no effect
at all.
init_logger (sim/src/common.rs) builds the logger with env_logger::Builder::new() and
builder.filter_level(level). Builder::new() does not consult the environment -- only
from_env and from_default_env do -- and nothing in the crate calls parse_env. Verified:
$ RUST_LOG=off strapdown-sim dr -i input.csv -o output.csv
2026-09-17 23:45:46.856 [INFO] - Running in Dead Reckoning mode with input: input.csv
--log-level is the only control, and because filter_level sets one global level there are
no per-module directives either -- RUST_LOG=strapdown=debug,strapdown_sim=trace has no
equivalent here.
Using Logging in Code
For developers extending the project, use the logging macros from the log crate:
#![allow(unused)] fn main() { use log::{trace, debug, info, warn, error}; // Informational messages info!("Processing {} records", count); // Warnings warn!("Skipping row {} due to parse error", row_num); // Errors error!("Failed to read config file: {}", err); // Debug information debug!("UKF state: {:?}", ukf.get_mean()); // Detailed traces trace!("Entering function with params: {:?}", params); }
Python Logger Comparison
This logging system provides a similar experience to Python's logging module:
| Python | Rust |
|---|---|
logging.info("message") | info!("message") |
logging.warning("message") | warn!("message") |
logging.error("message") | error!("message") |
logging.debug("message") | debug!("message") |
--log-level info | --log-level info |
| Writing to file with FileHandler | --log-file path/to/file.log |
Notes
- Log files are opened in append mode, so multiple runs will append to the same log file
- Timestamps use the local system timezone
- The logger is initialized once at program startup
- Log messages from the core library (
strapdown-core) are also captured and formatted consistently
Kalman Filters
Extended Kalman Filter (EKF)
The Extended Kalman Filter (EKF) is one of the primary navigation filters available in Strapdown-rs. It provides efficient state estimation for the INS through linearization of nonlinear system and measurement models.
Overview
The EKF linearizes the nonlinear navigation equations using Jacobian matrices (first-order Taylor series expansion). While this approximation is less accurate than the UKF's unscented transform for highly nonlinear systems, it is computationally more efficient and works well for most practical navigation scenarios.
Mathematical Foundation
State Vector
The EKF supports two state configurations:
9-State Model (Navigation Only):
- Latitude, longitude, altitude
- North, east, down velocities
- Roll, pitch, yaw angles
15-State Model (Navigation + Biases):
- 9 navigation states (as above)
- Accelerometer biases (3)
- Gyroscope biases (3)
Prediction Step
The prediction step propagates the state and covariance forward using IMU measurements using the strapdown mechanization and computed Jacobian matrices.
Update Step
When GNSS or other measurements are available, the Kalman gain is computed and the state is updated with the measurement innovation.
Features
Advantages
- Computational Efficiency: 3-5x faster than UKF
- Memory Efficient: Stores only mean and covariance
- Well-Understood: Extensive literature and proven track record
- Analytic Jacobians: Uses pre-computed derivatives for accuracy
Limitations
- Linearization Error: Less accurate for highly nonlinear systems
- First-Order Approximation: May miss higher-order effects
- Gaussian Assumption: Cannot handle multimodal distributions
Usage
Basic Initialization
#![allow(unused)] fn main() { use strapdown::kalman::{ExtendedKalmanFilter, InitialState, NavigationFilter}; use nalgebra::{DMatrix, DVector}; // Define initial state let initial_state = InitialState { latitude: 45.0, longitude: -122.0, altitude: 100.0, northward_velocity: 0.0, eastward_velocity: 0.0, vertical_velocity: 0.0, roll: 0.0, pitch: 0.0, yaw: 0.0, in_degrees: true, is_enu: false, // NED, the crate default; set true for ENU data }; // Initial covariance diagonal. // // Latitude and longitude are held in RADIANS and altitude in metres, so the three position // entries are not the same unit and cannot come from one literal. `vec![1e-6; 9]` -- what // this example used to show -- reads as a 6.4 km initial horizontal uncertainty rather than // as a small number, and `1e-9` as a process-noise density is a 201 m one. Both are issue // #308; see **Units on the covariance diagonals** below. Write the metres once, convert where // they are used. let horizontal_std_rad = 10.0 * strapdown::earth::METERS_TO_RADIANS; // a 10 m GNSS fix let mut initial_covariance = vec![ horizontal_std_rad.powi(2), // latitude, rad^2 horizontal_std_rad.powi(2), // longitude, rad^2 10.0_f64.powi(2), // altitude, m^2 ]; initial_covariance.extend([0.25; 3]); // velocity, (m/s)^2 -- a 0.5 m/s fix initial_covariance.extend([1e-4; 3]); // attitude, rad^2 -- ~0.6 deg // Initialize 9-state EKF (no biases) let mut ekf = ExtendedKalmanFilter::new( initial_state, vec![], // No biases for 9-state initial_covariance, // The crate's own default, which is built the same way: one metric constant, converted // once. Nine-state filters take its leading nine entries. DMatrix::from_diagonal(&DVector::from_vec( strapdown::sim::DEFAULT_PROCESS_NOISE_DENSITY[0..9].to_vec(), )), false, // use_biases = false for 9-state ); }
15-State with Bias Estimation
#![allow(unused)] fn main() { // The nine-state diagonal built above, extended with the bias states. The same unit // caveat applies to its position block, and for the same reason. let mut initial_covariance_15 = initial_covariance.clone(); initial_covariance_15.extend([1e-3; 3]); // accel bias, (m/s^2)^2 initial_covariance_15.extend([1e-8; 3]); // gyro bias, (rad/s)^2 // Initialize 15-state EKF with bias estimation let mut ekf = ExtendedKalmanFilter::new( initial_state, vec![0.0; 6], // Initial bias estimates (3 accel + 3 gyro) initial_covariance_15, DMatrix::from_diagonal(&DVector::from_vec( strapdown::sim::DEFAULT_PROCESS_NOISE_DENSITY.to_vec(), )), // Process noise true, // use_biases = true for 15-state ); }
Prediction with IMU Data
#![allow(unused)] fn main() { use strapdown::IMUData; let imu_data = IMUData { timestamp: 1234567890.0, gyro: [0.01, -0.02, 0.03], // rad/s accel: [0.5, -0.2, 9.81], // m/s² }; let dt = 0.01; // Time step in seconds ekf.predict(&imu_data, dt); }
Update with GNSS
#![allow(unused)] fn main() { use strapdown::measurements::GPSPositionMeasurement; let gps_measurement = GPSPositionMeasurement { latitude: 45.00012, longitude: -122.00015, altitude: 101.5, std_dev: [5.0, 5.0, 10.0], // Measurement uncertainty }; ekf.update(&gps_measurement); }
Performance
Computational Complexity
- Typical update rate: 10,000-20,000 updates/second on modern hardware
- 3-5x faster than UKF
- Memory usage: ~2 KB for 15-state
Comparison with UKF
| Aspect | EKF | UKF |
|---|---|---|
| Speed | Faster (3-5x) | Slower |
| Accuracy | Good for mildly nonlinear | Better for highly nonlinear |
| Implementation | Requires Jacobians | No Jacobians needed |
| Memory | Lower | Higher |
| Best For | Real-time systems | Research/offline processing |
See EKF vs UKF Comparison for detailed analysis.
Units on the covariance diagonals
The state vector is not in one unit system, and neither $P_0$ nor $Q$ can be filled from a
single literal. Latitude and longitude are radians; altitude, velocity and the
accelerometer biases are metric; attitude and the gyroscope biases are radians and radians
per second. A horizontal uncertainty written in metres therefore has to pass through
earth::METERS_TO_RADIANS before it can go on the diagonal.
It is worth knowing what the round numbers mean once the conversion is skipped:
| written as a variance | as a horizontal standard deviation |
|---|---|
1e-6 rad² | 6367 m |
1e-9 rad² | 201 m |
(5 m × METERS_TO_DEGREES)² | 286 m (degrees, not radians -- 57.3x too large) |
All three shipped in this crate, in $Q$ and in $P_0$, and are what issue #308 fixed. The symptom is characteristic: with $Q$ that large the innovation covariance $S = HPH^T + R$ is dominated by the filter's own prediction, so the update discards it and lands on each fix, the solution tracks the fix noise one-for-one instead of averaging it down, and innovation gating cannot function because a genuinely bad fix is still inside what the filter believes possible.
sim::DEFAULT_PROCESS_NOISE_DENSITY, sim::DEFAULT_INITIAL_POSITION_UNCERTAINTY_M and the
sim::initialize_* helpers all do the conversion for you; IMUQuality::auto_covariance
derives a whole $P_0$ diagonal from an IMU grade and a reported fix accuracy.
Best Practices
- Start with 9-state unless you need bias estimation
- Tune conservatively: Start with larger uncertainties and reduce
- Monitor innovation: Check measurement residuals for divergence
- Use 15-state for long-duration missions or low-quality IMUs
- Validate with dead reckoning: Compare against the
drsubcommand's results - Write position uncertainties in metres and convert once -- see Units on the covariance diagonals
Next Steps
- Try the UKF for comparison
- Learn about Measurement Models
- See Example: Closed-Loop Simulation
Unscented Kalman Filter (UKF)
EKF vs UKF Comparison
Particle Filters
Overview
This document outlines the design for a particle filter-based Inertial Navigation System (INS) following the architecture established in the UKF implementation (kalman.rs). The design addresses vertical channel instability through two alternative approaches: third-order damping and 2.5D navigation.
Executive Summary: Key Architectural Decisions
1. Custom Forward Propagation Function Required
Critical insight: For 2.5D navigation, we cannot reuse the standard forward() function from lib.rs. A new forward_2_5d() function must be implemented in particle.rs.
Why?
- Standard strapdown mechanization (Groves Eq 5.54) fully integrates vertical acceleration into vertical velocity
- This creates deterministic coupling:
v_v(t+dt) = v_v(t) + ∫[a_z + g - Coriolis] dt - In 2.5D navigation, we want vertical velocity to be a random walk constrained by measurements, not deterministically coupled to accelerometer readings
Solution: forward_2_5d() implements:
- Full strapdown for: attitude, horizontal velocities (v_n, v_e), horizontal position (lat, lon)
- Simplified for: vertical velocity (random walk), altitude (integration of v_v only)
2. Vertical Channel Treatment
Two approaches are provided:
| Approach | States | Vertical Velocity | Complexity | Recommended |
|---|---|---|---|---|
| 2.5D Simplified | 15 | Random walk with large process noise | Low | Yes - Start here |
| Third-Order Damping | 17 | Damped feedback control | High | Optional - if 2.5D insufficient |
3. Implementation Priority
- First: Implement
forward_2_5d()- this is the foundation - Second: Implement particle filter with 2.5D mode
- Later: Optionally add third-order damping if needed
State Representation
Standard 15-State Model
Following the UKF implementation, the baseline particle filter uses a 15-state model:
Navigation states (9):
- Position:
[latitude, longitude, altitude](rad, rad, m) - Velocity:
[v_north, v_east, v_vertical](m/s) - Attitude:
[roll, pitch, yaw](rad) - represented internally as DCM
Bias states (6):
- Accelerometer biases:
[b_ax, b_ay, b_az](m/s²) - Gyroscope biases:
[b_gx, b_gy, b_gz](rad/s)
Extended State for Vertical Channel Damping
For third-order vertical channel damping, add optional states:
- Altitude error estimate:
δh(m) - Altitude error rate:
δh_dot(m/s)
Total state dimension: 15 + 2 = 17 states (when using damping)
Implementation note: Use Particle.other_states: Option<DVector<f64>> for the damping states.
Vertical Channel Approaches
Approach A: Third-Order Vertical Channel Damping
Motivation: The vertical channel in INS is inherently unstable due to coupling between altitude and vertical velocity errors. Third-order damping provides feedback to stabilize this channel.
State augmentation:
x = [lat, lon, h, v_n, v_e, v_v, φ, θ, ψ, b_a, b_g, δh, δh_dot]
└─────────────────────────────────────┘ └─────┘ └────────┘
15 standard states biases damping
Dynamics model:
- Altitude error dynamics:
δh_dot = δv_v - Altitude error rate dynamics:
δh_ddot = -k₁·δh - k₂·δh_dot + w_hk₁,k₂: Damping coefficients (tunable)w_h: Process noise on altitude error
Feedback mechanism:
During propagation (predict step):
- Propagate particles through standard strapdown equations
- Update altitude using damping feedback:
h_corrected = h_propagated - k_fb · δh v_v_corrected = v_v_propagated - k_fb · δh_dot - During measurement update with GPS altitude:
- Innovation:
z - h_expectedupdates bothhandδh - Damping states receive weighted update based on particle importance
- Innovation:
Pros:
- Theoretically rigorous approach based on observability analysis
- Provides smooth vertical channel behavior
- Well-suited for long GNSS outages
Cons:
- Additional states increase computational cost
- Requires careful tuning of damping coefficients
- Increased complexity in implementation
Approach B: 2.5D Navigation (Simplified Vertical Treatment)
Motivation: Treat vertical acceleration as primarily noise-driven rather than deterministic, simplifying the vertical channel dynamics. This is the recommended approach based on past success.
State representation:
x = [lat, lon, h, v_n, v_e, v_v, φ, θ, ψ, b_a, b_g]
└────────────────────────────────────────────┘
Standard 15 states only
Modified dynamics:
- Horizontal navigation: Full 6-DOF strapdown mechanization for position, velocity, and attitude
- Vertical channel: Simplified treatment:
- Altitude propagation:
h(t+dt) = h(t) + v_v(t)·dt - Vertical velocity: Integrate vertical acceleration with high process noise
- Vertical acceleration noise: Model as white noise with large variance
Q_vertical = [Q_h, Q_v_v] where Q_v_v >> Q_v_n, Q_v_e
- Altitude propagation:
Process noise tuning:
- Position (lat/lon):
1e-6(tight) - Altitude:
1e-3to1e-2(loose, allows drift) - Horizontal velocity:
1e-3 - Vertical velocity:
1e-2to1e-1(very loose, absorbs vertical uncertainty) - Biases:
1e-6to1e-8(standard)
Measurement handling:
- GPS altitude measurements directly constrain
hwith measurement noise - Barometric altitude (if available) provides additional vertical constraint
- Vertical velocity measurements (if available) constrain
v_v - During GNSS outages: Altitude drifts according to high process noise
Pros:
- Simpler implementation (no additional states)
- Computationally efficient
- Leverages particle filter's ability to handle non-Gaussian distributions
- Works well with intermittent GPS
- Avoids complex tuning of damping coefficients
Cons:
- Less theoretically rigorous
- Vertical channel accumulates uncertainty faster during outages
- Requires careful process noise tuning to balance stability and responsiveness
Particle Filter Architecture
Core Components
Particle struct (already defined in particle.rs)
#![allow(unused)] fn main() { pub struct Particle { pub nav_state: StrapdownState, // 9 nav states pub accel_bias: DVector<f64>, // 3 accel biases pub gyro_bias: DVector<f64>, // 3 gyro biases pub other_states: Option<DVector<f64>>, // Optional: damping states pub state_size: usize, // Total dimension pub weight: f64, // Importance weight } }
ParticleFilter struct
#![allow(unused)] fn main() { pub struct ParticleFilter { particles: Vec<Particle>, num_particles: usize, process_noise: ProcessNoise, resampling_strategy: ResamplingStrategy, averaging_strategy: AveragingStrategy, effective_particle_threshold: f64, // e.g., 0.5 * num_particles rng: StdRng, // Seeded RNG for reproducibility is_enu: bool, // Vertical channel specific vertical_channel_mode: VerticalChannelMode, // ThirdOrder or Simplified damping_coefficients: Option<(f64, f64)>, // (k1, k2) if using damping } }
ProcessNoise struct
#![allow(unused)] fn main() { pub struct ProcessNoise { pub position_std: Vector3<f64>, // [σ_lat, σ_lon, σ_h] pub velocity_std: Vector3<f64>, // [σ_vn, σ_ve, σ_vv] pub attitude_std: Vector3<f64>, // [σ_φ, σ_θ, σ_ψ] pub accel_bias_std: Vector3<f64>, // [σ_ba_x, σ_ba_y, σ_ba_z] pub gyro_bias_std: Vector3<f64>, // [σ_bg_x, σ_bg_y, σ_bg_z] pub damping_states_std: Option<Vector2<f64>>, // [σ_δh, σ_δh_dot] } }
Enums
#![allow(unused)] fn main() { pub enum VerticalChannelMode { ThirdOrderDamping { k1: f64, k2: f64 }, Simplified, // 2.5D navigation } pub enum ResamplingStrategy { Systematic, Stratified, Residual, Multinomial, } pub enum AveragingStrategy { WeightedMean, // Standard weighted average MaximumWeight, // Use highest weight particle MeanWithTrimming, // Remove outliers then average } }
Core Algorithm
Initialization
#![allow(unused)] fn main() { impl ParticleFilter { pub fn new( initial_state: InitialState, imu_biases: Vec<f64>, covariance_diagonal: Vec<f64>, process_noise: ProcessNoise, num_particles: usize, vertical_mode: VerticalChannelMode, seed: Option<u64>, ) -> Self; } }
Initialization steps:
- Create
num_particlesparticles from initial state - Sample each particle's state from
N(μ₀, P₀)where:μ₀= initial mean stateP₀= diag(covariance_diagonal)
- Initialize all weights to
1.0 / num_particles - Seed RNG for reproducibility
Predict Step
Key architectural decision: For 2.5D navigation, we cannot use the standard forward() function from lib.rs. The standard strapdown mechanization fully integrates vertical acceleration to update vertical velocity (Equation 5.54 from Groves), which creates deterministic coupling we want to avoid in 2.5D.
Why a new forward function is needed:
The standard forward() computes vertical velocity as:
#![allow(unused)] fn main() { // velocity_update() in lib.rs, line 532 velocity + (specific_force + gravity - r * (transport_rate + 2.0 * rotation_rate) * velocity) * dt }
This tightly couples vertical acceleration to vertical velocity. In 2.5D navigation, we want:
- Horizontal dynamics: Full strapdown (deterministic acceleration → velocity → position)
- Vertical dynamics: Decoupled or weakly coupled (vertical velocity as random walk, altitude from velocity integration only)
Solution: Implement forward_2_5d() that modifies vertical channel propagation.
Update Step
#![allow(unused)] fn main() { impl NavigationFilter for ParticleFilter { fn update<M: MeasurementModel + ?Sized>(&mut self, measurement: &M) { // 1. Compute weights based on measurement likelihood for particle in &mut self.particles { let predicted_meas = measurement.get_expected_measurement( &particle.to_state_vector() ); let innovation = measurement.get_vector() - predicted_meas; let meas_cov = measurement.get_noise(); // Likelihood: p(z|x) ∝ exp(-0.5 * innovation^T * R^-1 * innovation) let likelihood = self.compute_likelihood(&innovation, &meas_cov); particle.weight *= likelihood; } // 2. Normalize weights self.normalize_weights(); // 3. Check effective particle count let n_eff = self.effective_particle_count(); // 4. Resample if needed if n_eff < self.effective_particle_threshold { self.resample(); } } } }
Resampling
Systematic resampling is the recommended strategy:
#![allow(unused)] fn main() { fn systematic_resample(&mut self) { let n = self.num_particles; let mut new_particles = Vec::with_capacity(n); // Cumulative sum of weights let cumsum: Vec<f64> = self.particles.iter() .scan(0.0, |sum, p| { *sum += p.weight; Some(*sum) }) .collect(); // Systematic sampling let step = 1.0 / n as f64; let start = self.rng.gen_range(0.0..step); for i in 0..n { let u = start + i as f64 * step; let idx = cumsum.iter().position(|&cs| cs >= u).unwrap_or(n - 1); let mut new_particle = self.particles[idx].clone(); new_particle.weight = 1.0 / n as f64; new_particles.push(new_particle); } self.particles = new_particles; } }
State Estimation
#![allow(unused)] fn main() { fn weighted_mean_estimate(&self) -> DVector<f64> { let mut mean_state = DVector::zeros(self.particles[0].state_size); for particle in &self.particles { let state_vec = particle.to_state_vector(); mean_state += particle.weight * state_vec; } // Special handling for circular quantities (angles) self.wrap_angles(&mut mean_state); mean_state } }
The forward_2_5d() Function
This function implements modified strapdown mechanization for 2.5D navigation. It should be added to particle.rs as a module-level function.
Mathematical Formulation
Full strapdown components (unchanged from Groves Ch 5.4-5.5):
-
Attitude update (Equation 5.46):
C(t+dt) = C(t) * [I + Ω_ib * dt] - [Ω_ie + Ω_el] * C(t) * dt- Full implementation using gyros, transport rate, Earth rate
-
Horizontal velocity update (Equation 5.54, horizontal components only):
v_n(t+dt) = v_n(t) + [f_n + g_n - Coriolis_n - transport_n] * dt v_e(t+dt) = v_e(t) + [f_e + g_e - Coriolis_e - transport_e] * dt- Full mechanization for northward and eastward velocities
-
Horizontal position update (Equation 5.56, lat/lon only):
lat(t+dt) = lat(t) + [v_n / (R_N + h)] * dt lon(t+dt) = lon(t) + [v_e / ((R_E + h) * cos(lat))] * dt
Simplified vertical components (2.5D modification):
-
Vertical velocity: Random walk model (NOT integrated from acceleration)
v_v(t+dt) = v_v(t) + w_v * sqrt(dt)w_v ~ N(0, σ_vv²)- large process noise- No deterministic integration of vertical specific force
- Vertical velocity drifts according to process noise only
-
Altitude update: Simple integration of vertical velocity
h(t+dt) = h(t) + v_v(t) * dt- Uses trapezoidal rule:
h(t+dt) = h(t) + 0.5 * [v_v(t) + v_v(t+dt)] * dt
- Uses trapezoidal rule:
Implementation Details
#![allow(unused)] fn main() { /// Modified forward propagation for 2.5D navigation. /// /// Implements full strapdown mechanization for horizontal navigation (position, /// velocity, attitude) while treating the vertical channel with simplified dynamics. /// Vertical acceleration is NOT integrated into vertical velocity; instead, vertical /// velocity follows a random walk model constrained by measurements and process noise. /// /// # Arguments /// * `state` - Mutable reference to the navigation state /// * `imu_data` - Bias-corrected IMU measurements /// * `dt` - Time step in seconds /// /// # Mathematical Basis /// - Horizontal: Full Groves Equations 5.46, 5.54, 5.56 /// - Vertical: Simplified dynamics, vertical velocity as random walk pub fn forward_2_5d(state: &mut StrapdownState, imu_data: IMUData, dt: f64) { // See full implementation in source code } }
Key Differences from Standard forward()
| Component | Standard forward() | forward_2_5d() |
|---|---|---|
| Attitude | Full mechanization (Eq 5.46) | Same - Full mechanization |
| Specific force transform | Full (Eq 5.47) | Same - Full transform |
| Horizontal velocity | Full mechanization (Eq 5.54) | Same - Full mechanization |
| Vertical velocity | Integrated from vertical accel | NOT integrated - random walk |
| Horizontal position | Full mechanization (Eq 5.56) | Same - Full mechanization |
| Altitude | Integrated from vertical velocity | Same - Simple integration |
Critical insight: The ONLY difference is that v_v(t+dt) ≈ v_v(t) in the propagation step. Changes to vertical velocity come entirely from:
- Process noise injection (large
σ_vv) - Measurement updates (GPS altitude/velocity)
This makes vertical velocity behave like a "free parameter" that is constrained by measurements rather than deterministic dynamics.
Integration with Existing Codebase
Mirroring UKF Structure
The particle filter implements the same NavigationFilter trait as UKF:
#![allow(unused)] fn main() { pub trait NavigationFilter { fn predict(&mut self, imu_data: IMUData, dt: f64); fn update<M: MeasurementModel + ?Sized>(&mut self, measurement: &M); fn get_estimate(&self) -> DVector<f64>; fn get_certainty(&self) -> DMatrix<f64>; // Empirical covariance from particles } }
Simulation Integration
In sim.rs, add particle filter option to closed_loop function:
#![allow(unused)] fn main() { pub enum FilterType { UKF { alpha: f64, beta: f64, kappa: f64 }, ParticleFilter { num_particles: usize, vertical_mode: VerticalChannelMode, resampling: ResamplingStrategy, }, } }
Testing Strategy
Unit Tests
- Test particle initialization from
InitialState - Test process noise injection
- Test resampling algorithms (verify particle diversity)
- Test weight computation and normalization
- Test vertical channel damping (if used)
- Test state estimation with different averaging strategies
Integration Tests
- Compare PF vs UKF on same trajectory
- Test with GNSS dropout scenarios
- Test vertical channel stability (hover, climb, descent scenarios)
- Verify deterministic results with seeded RNG
Vertical Channel Tests
Create tests similar to UKF tests in kalman.rs:
pf_free_fall_motion()- vertical dynamics with no supportpf_hover_motion()- stationary altitudepf_climb_descent()- vertical motion profilepf_altitude_drift_during_outage()- verify controlled drift with 2.5D
Recommended Implementation Order
- ✅ Define
Particlestruct (already done) - Implement
forward_2_5d()function inparticle.rs- Copy
attitude_update()fromlib.rsor make it public - Implement
velocity_update_horizontal()helper - Reuse
position_update()fromlib.rs(make public if needed)
- Copy
- Implement
ProcessNoisestruct - Implement
ParticleFilterstruct with basic initialization - Implement
predict()usingforward_2_5d() - Implement
update()with systematic resampling - Implement
get_estimate()with weighted mean - Write unit tests for each component
- Integration with
sim.rs - Test on real datasets
- (Optional) Implement third-order damping if 2.5D is insufficient
Tuning Guidance
Number of Particles
- Start with 500-1000 particles for smartphone IMU
- Increase to 2000-5000 for high-precision applications
- Monitor computational cost vs. accuracy tradeoff
Process Noise (2.5D)
Conservative (high uncertainty, smooth vertical):
σ_h = 0.1 m,σ_vv = 0.2 m/s
Aggressive (low uncertainty, responsive vertical):
σ_h = 0.01 m,σ_vv = 0.05 m/s
Recommended starting point:
σ_h = 0.05 m,σ_vv = 0.1 m/s
Resampling Threshold
- Effective particle threshold:
0.5 * num_particles - Resample more frequently if seeing particle degeneracy
- Monitor
n_effover time to diagnose issues
Expected Performance
Advantages over UKF
- Better handling of non-Gaussian distributions
- More robust to outliers in measurements
- Can represent multimodal posteriors (e.g., during ambiguous scenarios)
- 2.5D approach naturally handles vertical channel uncertainty
Computational Cost
- PF with N=1000 particles ≈ 10-50× slower than UKF
- Use Rayon for parallel particle propagation if needed
- Profile and optimize hot paths (propagation, resampling)
Memory Requirements
- Each particle: ~200 bytes (15 states × 8 bytes + overhead)
- 1000 particles ≈ 200 KB (negligible for modern systems)
References
- Groves, P. D. (2013). Principles of GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd Edition. Chapters 14-15 (INS error models, vertical channel instability).
- Gustafsson, F., et al. (2002). "Particle filters for positioning, navigation, and tracking." IEEE Transactions on Signal Processing.
- Existing
kalman.rsimplementation for architectural patterns.
Rao-Blackwellized Particle Filter
The RBPF is the marginalized particle filter of Schön, Gustafsson & Nordlund (2005), built as Canciani & Raquet describe it for airborne magnetic-anomaly navigation ("Airborne Magnetic Anomaly Navigation", IEEE TAES 53(1):67-80, 2017). It exists for map-aided navigation. There, a gravity or magnetic map makes the measurement a highly nonlinear, often multimodal function of position, while everything else about the navigation error stays linear.
Structure
An INS nominal trajectory is mechanized with barometer aiding in the mechanization. This is a third-order loop that feeds the difference between the barometer and the INS altitude back into altitude, vertical velocity and a vertical-acceleration correction. The filter estimates the errors in that aided solution:
| partition | states |
|---|---|
| sampled (particles) | latitude and longitude error |
| linear (one Kalman filter, shared covariance) | altitude error; velocity error (3); nav-frame tilt (3); barometer-aiding error δh_a; the loop's vertical-acceleration error δâ; and per map channel, a temporal variation V and a constant offset c |
For one map channel that is the paper's thirteen states, with no IMU bias states. Only horizontal position is sampled. The linear states' conditional covariance depends on nothing particle-specific, so it is held once for the whole cloud.
- Time update, once per measurement epoch. Between measurements only the nominal is
mechanized, while the error-state transition and process noise accumulate. At the next
measurement, the particles are drawn from
N = A^n_l P A^n_lᵀ + Qⁿand their linear states move by what the draw implies about them (the paper's eqs. 30-35). - Measurement update, one path for every measurement. The measurement's Jacobian, carried
onto the linear partition, gives
C. Each particle is weighted by its residual underS = C P Cᵀ + R, the likelihood with the linear states integrated out (eq. 24). Its linear states then take a Kalman step with a gain shared by the cloud (eqs. 26-29). For a map,CselectsV + c, which is the paper's update exactly. - Barometer readings are the loop's input, not measurements. They don't reweight the cloud or start an epoch.
- Feedback. After every update, the weighted-mean error is folded into the nominal and the cloud re-centred. Position, velocity, the loop's states and the map states are added; tilt is composed.
estimate() reports the same layout as the Kalman filters:
[lat, lon, alt, v(3), roll, pitch, yaw, b_a(3), b_g(3)], followed by one total map bias
V + c per channel. The six IMU-bias rows are zero, since the filter carries no bias states.
Configuration
A [particle_filter] section holds the particle count, the initial uncertainties, and the
following:
| key | meaning | default |
|---|---|---|
velocity_process_noise_std_mps, attitude_process_noise_std_rad | VRW and ARW, per √s (eq. 20) | 1e-3, 0.01 |
horizontal_process_noise_std_m | [north, east] random walk on the sampled position, m/√s | [1, 1]; the paper's eq. 19 is [0, 0] (see below) |
baro_loop_time_constant_s | the loop's gains place its three poles at -1/τ | 10 |
baro_error_std_m, baro_error_time_constant_s | the barometer-aiding error as a Gauss-Markov process (σ_b, τ_b) | 8.3 m, 3600 s |
vertical_accel_error_init_std_mps2 | prior on δâ | 0.1 |
effective_sample_threshold | resample below this fraction of N; 1.0 resamples every update, as the paper does | 1.0 |
roughening_factor | post-resample jitter; 0.0 as in the paper | 0.2 |
gravity_variation_std, magnetic_variation_std | steady-state sigma of each channel's V | from [geophysical] *_bias_process_noise_std |
gravity_variation_time_constant_s, magnetic_variation_time_constant_s | correlation time of V | 300 (the paper's) |
The map bias seed and total prior come from [geophysical], as for the Kalman filters. V
starts from its stationary distribution, and c takes the rest of *_bias_init_std. The
paper's magnetic temporal variation is magnetic_variation_std = 5 with
magnetic_variation_time_constant_s = 300.
Horizontal process noise: why the default is 1 m/√s
The paper's zero (eq. 19) makes every epoch's time update a noiseless observation of the velocity and tilt errors. That moves their uncertainty out of the shared covariance and into the particles' spread. Resampling on GNSS-rate fixes at metre level then destroys that spread.
On the reference recording the effect is severe:
- the conditional velocity sigma fell to millimetres per second;
- GNSS velocity fixes stopped correcting anything;
- the solution ran 17 km off.
At 1 m/√s, the setting the conf/ recipes use, the same recording scores 3.2 m horizontal and
0.8 m vertical RMSE against the GNSS track, with 95% 3σ containment. The paper's
navigation-grade INS and magnetometer-only updates never pushed it into that regime.
Sparse GNSS: what this structure cannot recover from
With one GNSS fix every 60 s (conf/rbpf_degraded.toml, rbpf_both.toml) the filter diverges.
On a 9-trajectory subset the median horizontal RMSE is hundreds of kilometres, against about
0.5 km for the EKF. The cause is structural, not a tuning slip. Traced on
2025-06-18_15-09-25:
- Heading drags the solution. Pitch is unobservable between fixes, and a 4° pitch error levels the magnetometer wrongly. At this site's dip, that biases the heading measurement by several degrees. With the recipe's attitude noise, the heading fixes pull yaw 10-25° off, and reweighting on heading shifts the velocity mean through the cloud.
- The first fix lands outside the cloud. The reported uncertainty is conserved: velocity σ grows to 30-55 m/s by t = 60 s. But the mean is about 10σ off, so the weights collapse onto one ancestor.
- Nothing brings velocity back. Only horizontal position is sampled, so a position residual
never enters the Kalman step. Its correlation with velocity and tilt lives only in the
particle spread, which the collapse has just removed. All that is left is
P_vvof about 0.6 m/s, which a velocity residual of about 140 m/s barely moves. The EKF carries that correlation inP: its 0.9 km error at t = 60 s drops to 50 m in one fix.
Ablations that did not rescue it (subset medians):
- the time update per sample: 326 km;
Qⁿ = 3 m/√s: 280 km;effective_sample_threshold = 0.5: 323 km;- a slower barometer loop: 658 km;
- a ZVV pseudo-measurement: 4-7 km;
- the Kalman filters' VRW and ARW densities: 25 km.
The paper's setting never tests this: a navigation-grade INS, map fixes every epoch, and no GNSS.
Departures from Canciani & Raquet
- Closed loop. They ran a navigation-grade INS open loop, and noted feedback "may be required with a less accurate INS". A MEMS nominal needs it.
- Barometer loop. The paper prints the loop's coupling into the error model but omits its feedback on the altitude error, and gives no gains. Both are completed from the standard third-order loop (Titterton & Weston).
- WGS84 discrete-time error Jacobian. It stands in for the spherical-Earth continuous Pinson matrices; the structure is the same.
- Other fixes. GNSS and magnetometer-heading fixes are supported alongside the map.
- Roughening after resampling (
roughening_factor = 0.0removes it). - Process noise across an epoch. The accumulated process noise carries each state's own decay across the epoch, so a Gauss-Markov state stays stationary over long gaps between sparse fixes.
- Configurable horizontal process noise. The default is the paper's zero.
The implementation, with equation references throughout, is core/src/rbpf.rs.
Measurement Models
Geophysical Navigation Overview
Gravity Anomaly Navigation
Magnetic Anomaly Navigation
Data Sources and Maps
Fault Simulation
This chapter is the experiment-design rationale behind conf/: which GNSS conditions are
worth simulating, what the published measurements say those conditions look like, and which
knobs in GnssFaultModel correspond to which.
What jamming actually does
The dominant, repeatedly measured effect of jamming is loss of fix, not graceful degradation. At Jammertest — the open field trial run annually at Andøya by the Norwegian Public Roads Administration, Communications Authority, Defence Research Establishment, Metrology Service and Space Agency — the jamming signal was in most runs strong enough to cause complete GNSS signal loss, and a smartphone roughly ten metres from a jammer lost lock on benign signals entirely. Receivers reacquired when terrain or buildings shadowed them from the jammer and lost lock again on returning to line of sight.
That matters for how a scenario is built. A receiver inside the jammed area does not report a noisier position; it reports nothing. A model that keeps delivering fixes with more error on them describes the fringe of the jammed area, not the inside of it — and it is the inside that an inertial system, or a geophysical aid, exists to carry you through.
The scale is not marginal. IATA recorded a 220% rise in GPS signal-loss events between 2021 and 2024; European flights encountering GNSS interference rose from roughly 200 per day in Q1 2024 to around 900 per day in Q2, and EUROCONTROL logged more than 2,500 jamming events in 2024 alone.
The interference tier
| Tier | What it models | Scheduler | Fault | Configs |
|---|---|---|---|---|
| Degraded | the fringe of a jammed area | fixed_interval | degraded | conf/*_degraded.toml |
| Spoof | a deceptive signal | either | slow_bias / hijack | — |
An earlier version of this experiment matrix also carried a Denial tier
(duty_cycle/none, modeling total loss of fix inside a jammed area) as a config separate
from Degraded. It was consolidated into the single Degraded tier below: keeping one
interference profile, used identically everywhere a geo-aided run is scored, was worth more
than covering both regimes at the cost of doubling the config matrix and the pairing logic
that keeps it honest (see "Choosing a baseline" below).
Degraded
conf/*_degraded.toml: fixes every 5 s, with AR(1)-correlated error on position and velocity
and the advertised accuracies inflated.
Anchoring the magnitude to measurement rather than to a round number: smartphone GNSS is 3–5 m
under good multipath and over 10 m in harsh multipath, with semi-urban CEP95 around 10–12 m.
Under jamming the tracked satellite count falls and GDOP rises, so true error and advertised
accuracy inflate together. sigma_pos_m = 35.0 is about 14× this dataset's 2.58 m median
advertised accuracy — a receiver still producing fixes, but bad ones.
r_scale inflates the advertised 1σ, which the measurement model then squares, so R moves
by r_scale²: the standard 5.0 is a 25× R, not a 5× one.
Spoofing
slow_bias and hijack are implemented and unused in the current experiment matrix. They
differ in detectability by construction: a hijack steps the position with no matching
velocity change, so it produces a large NIS the moment it starts and a chi-squared gate
rejects it. A slow_bias moves position and velocity consistently, which is what keeps it
under the gate — that is the whole design of a soft spoof.
Correlation time and the fix interval
rho_pos and rho_vel are applied once per emitted fix, with no reference to elapsed
time. The error's correlation time is therefore -interval_s / ln(rho) — a property of the
scheduler, not of the error model. At rho_pos = 0.99, a 5 s fix interval is a 498 s
correlation time and a 30 s interval is 2985 s.
So an experiment that sweeps the fix interval is also sweeping the error timescale, and the two cannot be separated in the result.
tau_pos_s and tau_vel_s fix this. When set, the coefficient becomes exp(-dt / tau) for
the actual interval between fixes, and sigma_pos_m is reinterpreted as the steady-state
standard deviation in metres rather than the per-step innovation:
[gnss_degradation.fault]
kind = "degraded"
sigma_pos_m = 35.0 # steady-state wander, metres, whatever the schedule
tau_pos_s = 500.0 # correlation time in seconds
They are optional and additive. Omit them and the per-fix form applies exactly as before, so every configuration written earlier — and every result recorded from one — still means what it meant. Prefer them for anything that varies the schedule.
Note also that without a time constant, sigma_pos_m is the per-step innovation, not the
error you get: an AR(1) settles at sigma / sqrt(1 - rho²), so sigma_pos_m = 3.0 with
rho_pos = 0.99 is 21 m of wander, not 3 m.
Choosing a baseline to compare against
geoperf-all scores each geophysically-aided run against that filter's unaided run, so the
two must carry an identical GNSS profile. If they differ, the "improvement" reported is
the difference between two degradations and has nothing to do with the aid. The
[gnss_degradation] and [health_limits] sections of conf/{ukf,ekf,rbpf}_{grav,mag,both}.toml
are duplicated from conf/*_degraded.toml for exactly this reason, and
core/tests/example_configs.rs covers conf/ so a drift in them fails a test.
conf/*_degraded.toml carries this recalibrated 35 m profile, and it is the only
interference tier in the config matrix: conf/{ukf,ekf,rbpf}_{grav,mag,both}.toml are all
scored against their own filter's conf/{ukf,ekf,rbpf}_degraded.toml, and RBPF's geo-aided
runs are additionally scored against conf/ekf_degraded.toml as the canonical INS baseline
(just geoperf-all/geoperf-rbpf). Keeping a single profile, applied identically everywhere,
is what makes the pairing in core/tests/example_configs.rs a simple filename convention
rather than a per-tier lookup.
Sources
- Jammertest 2024 results (Septentrio)
- Navigating through interference at Jammertest (ESA)
- GNSS jamming and spoofing detection at Jammertest 2025 (u-blox)
- Using mobile phones for participatory detection and localization of a GNSS jammer
- A comparative analysis of the response of GNSS receivers under vertical and horizontal L1/E1 chirp jamming, Sensors 21(4):1446
- A comprehensive analysis of smartphone GNSS range errors in realistic environments, Sensors 23(3):1631
- Improving smartphone GNSS positioning accuracy using contextual information, Sensors 26(11):3346
- EASA and IATA plan to mitigate the risks of GNSS interference
- IATA safety risk assessment: GNSS interference
- EUROCONTROL GNSS interference testing guide v2.0
- RTCA DO-235C, Assessment of radio frequency interference relevant to the GNSS L1 frequency band
- Overbounding the effect of uncertain Gauss-Markov noise in Kalman filtering, NAVIGATION 68(2):259
- GNSS error simulator for farm machinery navigation — the first-order Gauss-Markov form
R(Δt) = σ² e^(-|Δt|/τ)these time constants implement
Dropout Scenarios
Reduced Update Rates
Measurement Corruption
API Reference
earth Module
kalman Module
measurements Module
particles Module
sim Module
strapdown-sim Binary
strapdown-geonav
Example Configurations
Ready-to-run scenario files live in
examples/configs/.
For the schema itself — every field, and the naming pitfall that silently disables a scenario
— see Configuration Files.
--config supplies the whole run, so the subcommand's -i/-o are ignored alongside it —
set input: and output: inside the file instead:
strapdown-sim --config examples/configs/simple_dropout.yaml
Baseline
| File | What it does |
|---|---|
baseline.yaml | No degradation. The reference every other scenario is compared against. |
Availability
| File | What it does |
|---|---|
sched_10s.yaml | One fix every 10 s, uncorrupted. A reduced update rate. |
duty_10on_2off.yaml | 10 s available, 2 s denied, repeating. Brief periodic outages. |
simple_dropout.yaml | 2 minutes available, 30 s denied. Bridges and tall buildings. |
extended_gnss_denied.yaml | 1 minute available, 5 minutes denied. Tunnels and sustained jamming. |
extended_gnss_denied.yaml is the one that shows dead-reckoning quality most clearly: five
minutes is long enough for consumer-MEMS drift to dominate completely. Expect errors in the
hundreds of metres to kilometres, and see
Tutorial: GPS Degradation for why the figure is usually
smaller than double-integrating the sensor's bias would predict.
Accuracy
| File | What it does |
|---|---|
degraded_fullrate.yaml | Full rate, AR(1)-correlated position and velocity error. |
degraded_5s.yaml | Every 5 s and degraded. Scheduling and corruption together. |
Spoofing
| File | What it does |
|---|---|
slowbias.yaml | A slowly drifting offset. Soft spoofing — each fix looks plausible. |
slowbias_rot.yaml | The same, with the drift direction rotating. |
hijack.yaml | An abrupt offset during a fixed window. Hard spoofing. |
Spoofing scenarios are the ones worth pairing with an innovation gate: a hijack produces a
large normalized innovation squared the moment it starts, which a chi-squared gate rejects,
while a slow_bias is specifically designed to stay under that threshold.
Combined
| File | What it does |
|---|---|
combo.yaml | Reduced rate plus degraded accuracy. |
combo_duty_hijack.yaml | Duty-cycled availability plus hard spoofing. |
Other formats
gnss_degradation.json and gnss_degradation.toml are the same schema in the other two
supported formats. The json/ subdirectory is a different thing: those files are
{name, args} CLI-invocation presets, not scenario configs, and they use the command line's
vocabulary rather than the config schema's.
Running the set
Because --config ignores -i/-o, a batch varies the paths in the file:
mkdir -p results .scenarios
for config in examples/configs/*.yaml; do
name=$(basename "$config" .yaml)
{ cat "$config"
echo "input: data/input.csv"
echo "output: results/${name}.csv"
} > ".scenarios/${name}.yaml"
strapdown-sim --config ".scenarios/${name}.yaml"
done
Tutorial: Basic INS Simulation
The runnable source for this tutorial is core/examples/basic_ins.rs:
cargo run -p strapdown-core --example basic_ins
It drives a vehicle due north at a constant 10 m/s on a level road for 60 seconds, with a GNSS fix once a second. The trajectory is deliberately trivial so that every number it prints can be checked by hand.
The loop
Every application built on this crate has the same shape, whether the samples come from a file, a hardware driver, or a generator:
- Build an engine from an initial state.
- For each IMU sample,
predict. - When a GNSS fix arrives,
update_gnss. - Read
nav_solutionwhenever the current estimate is needed.
let mut engine = InsEngine::builder()
// `InsEngineConfig` is `#[non_exhaustive]`, so neither a struct literal nor `..default()`
// compiles outside `strapdown-core`. Start from `default()` and assign.
.with_config({
let mut config = InsEngineConfig::default();
config.is_enu = false;
config
})
.with_initial_state(initial_state)
.build()?;
engine.predict(&ImuSample::from_rates(&imu, dt))?;
let outcome = engine.update_gnss(&fix)?;
let solution = engine.nav_solution();
update_gnss returns an [UpdateOutcome] rather than a bare Ok(()). It carries the
normalized innovation squared and whether the correction was applied. With no innovation gate
installed every fix is accepted; once you call set_innovation_gate, a rejected fix is
reported through this value rather than raised as an error, because a rejection is a normal
event and not a fault.
A gate also needs a way back, and the engine installs one by default. Rejecting a fix leaves
the state where it was, but the filter keeps propagating and keeps accumulating error -- so
without a recovery path the next fix disagrees by more, is rejected harder, and one
rejection quietly turns into dead reckoning for the rest of the run. GateRecovery is what
prevents that: every rejection inflates the filter's uncertainty in the directions that
measurement observed -- so the next fix of the same kind is judged against a covariance that
grew -- and after five consecutive rejections a measurement is applied regardless, on the
reasoning that a belief contradicted five times running is likelier to be wrong than the sensor
contradicting it. set_gate_recovery
tunes it, GateRecovery::none() switches it off, and UpdateOutcome::forced says whether an
accepted fix was accepted on its merits or by that escape.
The sign convention that catches everyone
An accelerometer measures specific force, not acceleration. A vehicle sitting still is not in free fall: the ground pushes up on it, and that is what the sensor reads. In NED the vertical axis points down, so a level, stationary IMU reads:
accel = [0.0, 0.0, -9.81] // m/s², body frame, NED
Negative, on the down axis. It has to be for the mechanization to cancel it: the velocity update adds gravity (positive down in NED) to the sensed increment, and a stationary vehicle's velocity must not change.
In ENU the same physical reading is +9.81 on the up axis. That is what the Sensor Logger
exports in core/tests/test_data.csv look like, which is why that file is loaded as ENU.
Getting this backwards does not fail loudly. The filter concludes the vehicle is upside down: roll converges to 180°, and the vertical channel walks away even while GNSS is holding altitude fixed. If a first integration produces those two symptoms together, check this first.
Expected output
start: NavSolution(t: 0.000 s, 39.9500000 deg, -75.1600000 deg, 12.00 m, ...)
after 60 s: NavSolution(t: 60.000 s, 39.9553996 deg, -75.1600000 deg, 12.14 m, ...)
travelled 600.0 m north; expected 600.0 m (difference 0.00 m)
position uncertainty (1-sigma): 3.01 m north, 2.32 m east, 1.08 m vertical
600 m in 60 s at 10 m/s, and the reported north uncertainty settles near the 3 m accuracy the synthetic fixes advertise, which is what a correctly-tuned filter should do: with no vehicle dynamics to exploit it cannot do better than its aiding source.
Next: Tutorial: GPS Degradation.
Tutorial: GPS Degradation
The runnable source is core/examples/gnss_outage.rs:
cargo run -p strapdown-core --example gnss_outage
The scenario is the one every GNSS-denied study starts from: 60 s tracking normally, 120 s with no fixes at all (a tunnel, an urban canyon, jamming), then aiding returns for 120 s. The vehicle carries a 0.05 m/s² accelerometer bias on its forward axis, representative of a consumer MEMS part after turn-on.
The shape of the error
t (s) error (m) GNSS
60 0.0 ok
70 0.2 denied
100 2.0 denied
140 14.9 denied
180 49.4 denied
190 0.0 ok
Flat while aided, growing while coasting, and back to zero within a single fix of recovery. The growth is quadratic because a constant acceleration error integrates twice into position.
Why the drift is smaller than the textbook figure
Double-integrating the bias over the outage predicts 360 m:
0.5 × 0.05 m/s² × (120 s)² = 360 m
The observed error is 49.4 m, seven times smaller. The reason is worth reading off the filter state rather than guessing at, and the example prints it:
accelerometer bias present: 0.0500 m/s^2 (forward axis)
bias the filter estimated: 0.0001 m/s^2 <- it barely moved
pitch error at outage start: 0.2987 deg
gravity that tilt leaks forward: 0.0511 m/s^2 <- nearly cancels the bias
The filter did not estimate the bias. It absorbed it into attitude instead: it believes the vehicle is pitched nose-up by three tenths of a degree, and the gravity that tilt leaks into the forward axis very nearly cancels the bias.
That is not a defect. A constant forward specific-force bias and a small pitch error produce the same horizontal acceleration signature, so position and velocity aiding cannot separate them — the pair is unobservable from this measurement set alone. The filter found an explanation consistent with every fix it was given, and that explanation happens to coast well.
The practical lesson is not the quadratic. It is that which of the two the filter blames is arbitrary, and it changes how well the solution coasts. Aiding that does separate them — a zero-velocity update, a second antenna, a magnetometer for heading — is what makes dead-reckoning performance predictable rather than lucky.
Turning the knobs
Two constants at the top of the example are worth changing:
OUTAGE_DURATION_S— drift grows with the square of this, so doubling it roughly quadruples the peak error.ACCEL_BIAS_MPS2— set it to zero and the vehicle coasts almost perfectly. Note that the drift does not scale with it directly, for the reason above.
The same scenario from a config file
To run this through the CLI instead of in code, see
Example Configurations; simple_dropout.yaml and
extended_gnss_denied.yaml describe outages of 30 s and 5 minutes respectively.
Tutorial: Particle Filter
Contributing
Building and Testing
Building the Project
Build Dependencies
There are no system libraries to install. libhdf5, libnetcdf, zlib and freetype are compiled from vendored sources that ship as cargo dependencies, so the only requirement beyond Rust is a toolchain to build them with:
- a C/C++ compiler
- cmake 3.26 or newer (more than Ubuntu 22.04 or Debian 12 ship -- see System Requirements)
On Ubuntu/Debian systems:
sudo apt update
sudo apt install -y build-essential cmake
Neither is needed for cargo build -p strapdown-core, which uses no C library by default.
Rust Toolchain
- Pinned to 1.91 by
rust-toolchain.toml; rustup fetches it on the first cargo command - Required components:
clippy,rustfmt(also declared inrust-toolchain.toml)
Building
Build the entire workspace:
cargo build --workspace --all-features
Build a specific crate:
cargo build -p strapdown-core
cargo build -p strapdown-sim
cargo build -p strapdown-geonav
Build with optimizations (release mode):
cargo build --workspace --all-features --release
Installing Binaries
Install the simulation binaries to your system:
# Install strapdown-sim
cargo install --path sim
# ...with geophysical navigation. This is what pulls in libnetcdf, so it needs cmake.
cargo install --path sim --features geonav
Testing
Overview
The strapdown-rs project includes comprehensive unit tests, integration tests, and module-specific tests to validate the correctness of the navigation algorithms.
Running Tests
Run all tests in the workspace:
cargo test --workspace --all-features --verbose
Run tests for a specific crate:
cargo test -p strapdown-core
cargo test -p strapdown-sim
cargo test -p strapdown-geonav
Run with output visible:
cargo test -- --nocapture
Run tests sequentially (single-threaded):
cargo test -- --test-threads=1
Test Organization
Tests are organized into:
- Unit tests: Inline in source files using
#[cfg(test)]modules - Integration tests: Located in
core/tests/integration_tests.rs - Module tests: Specific to individual modules
Integration Tests for INS Filters
The integration tests validate the entire INS filter pipeline using real sensor data. These tests are comprehensive and take longer to run than unit tests.
Test Data
The integration tests use real data collected from the Sensor Logger mobile application:
- IMU measurements (accelerometer and gyroscope) at ~100 Hz
- GNSS position and velocity measurements at ~1 Hz
- Approximately 90 minutes of data with ~5366 samples
- Dataset location:
core/tests/test_data.csv
Error Metrics
Horizontal Position Error
- Metric: Haversine distance between estimated and GNSS positions (meters)
- Formula: Great-circle distance on Earth's surface
- Purpose: Measures planar navigation accuracy
Altitude Error
- Metric: Simple absolute difference (meters)
- Purpose: Measures vertical navigation accuracy
Velocity Error
- Metric: Component-wise absolute differences for north, east, and down velocities (m/s)
- Purpose: Measures velocity estimation accuracy
Test Suite
core/tests/integration_tests.rs holds 23 tests, not the handful this page used to walk
through one by one. That walkthrough transcribed each test's thresholds into prose, and the
prose then drifted from the constants -- it was still quoting a 30 m horizontal limit long after
the code moved to 40 m. So this section names the groups and the constants and points at the
source, rather than copying numbers that nothing keeps honest.
Per-filter closed-loop suites. Each of the four filters gets the same three scenarios:
| full-rate GNSS | degraded GNSS (5 s) | beats dead reckoning | |
|---|---|---|---|
| UKF | test_ukf_closed_loop_on_real_data | test_ukf_with_degraded_gnss | test_ukf_outperforms_dead_reckoning |
| EKF | test_ekf_closed_loop_on_real_data | test_ekf_with_degraded_gnss | test_ekf_outperforms_dead_reckoning |
| ESKF | test_eskf_closed_loop_on_real_data | test_eskf_with_degraded_gnss | test_eskf_outperforms_dead_reckoning |
| RBPF | test_rbpf_closed_loop_on_real_data | test_rbpf_with_degraded_gnss | -- |
Plus: test_dead_reckoning_on_real_data (the unaided baseline),
test_eskf_output_stays_valid_across_full_run, test_eskf_default_initialization_on_real_data,
test_eskf_auto_covariance_initialization_on_real_data, test_eskf_recovers_from_gnss_outage,
test_filter_output_length_matches_input, test_filters_are_deterministic_across_runs,
test_rmse_benchmark_across_filters, test_full_lifecycle_through_ins_engine,
magnetometer_yaw_aiding_source_error_matches_derivation, and
gating_through_the_closed_loop_no_longer_cascades.
Thresholds
The limits live in core/tests/integration_tests.rs as named constants, each with its
derivation in a doc comment. Read those rather than any number on this page:
| constant | what bounds it |
|---|---|
MAX_HORIZONTAL_RMSE_M | empirical, with headroom over the observed run |
MAX_VERTICAL_RMSE_M | empirical |
MAX_LEVEL_ATTITUDE_RMSE_RAD | derived -- gravity observability |
MAX_YAW_RMSE_RAD | derived -- a multiple of MAG_YAW_SOURCE_RMSE_RAD, the magnetometer's own measured error |
DEAD_RECKONING_BASELINE_SAMPLES | derived -- see below |
DEAD_RECKONING_BEAT_FACTOR | derived -- how far a filter must beat the baseline for the comparison to be live |
The distinction matters. An empirical guard answers "is this worse than yesterday?"; a derived
one answers "is this physically possible?". Do not tighten a derived bound to match a measured
number -- if a filter beats it, the bound was wrong, and the derivation is what changes. See
AGENTS.md for the policy.
The dead-reckoning baseline is a window, not the whole run
The *_outperforms_dead_reckoning tests score the comparison over
DEAD_RECKONING_BASELINE_SAMPLES records, not the full 90-minute recording. This page
previously advertised the full-run version -- "dead reckoning RMS error ~7,100 km, improvement
99.99%" -- and that headline is exactly what the truncation exists to kill.
Unaided dead reckoning over the whole log ends millions of metres out. A test asserting
filter_error < dead_reckoning_error against that is vacuous: it passes for any filter that
does not itself diverge, including a badly broken one. Truncating to a window where the baseline
drift is comparable to the filter's error makes the comparison discriminating again, and
DEAD_RECKONING_BEAT_FACTOR then requires a real margin rather than any margin. See #307 and
#299, and the derivation in the module header.
Running Integration Tests
cargo test -p strapdown-core --test integration_tests # all of them
cargo test -p strapdown-core --test integration_tests -- --nocapture # with printed output
cargo test -p strapdown-core --test integration_tests test_ukf_closed_loop_on_real_data -- --nocapture
cargo test -p strapdown-core --test integration_tests -- --test-threads=1 # sequential
Expected Runtime
Deliberately not itemised here. The previous figures covered four tests where there are now 22,
and predated the baseline truncation above, so they were wrong in both directions and nothing
noticed. Nothing in CI measures wall clock today -- that is the open half of #377 -- so treat
any runtime number as an observation you made, not a contract. What is bounded is CI itself:
every job sets timeout-minutes (#379).
Implementation Details
Error Metric Calculation
The compute_error_metrics() function:
- Matches navigation results to GNSS measurements by timestamp
- Skips invalid GNSS data (NaN values)
- Computes error for each matched sample
- Filters out non-finite errors
- Calculates mean, max, and RMS statistics
Filter Initialization
Tests use realistic initialization based on first GNSS measurement:
- Position: First GNSS lat/lon/alt
- Velocity: Computed from GNSS speed and bearing
- Attitude: From phone orientation sensors
- Covariances: Conservative initial uncertainties
- Process noise: Tuned for MEMS IMU characteristics
Event Stream Generation
Tests use the build_event_stream() function to create a sequence of IMU propagation and GNSS update events from the raw data, with configurable scheduling and fault injection.
Future Test Enhancements
Potential improvements for the test suite:
-
Additional test scenarios-- done.core/tests/perf_baseline.rscovers GNSS outages (real_outage_60s,syn_outage_60s, bothDutyCycle), measurement corruption (real_degraded, an AR(1) fault model) and a synthetic cruise profile alongside the real recording. -
More filters-- done. The EKF is implemented (core/src/kalman.rs) and has the same three-scenario integration suite as the others; the RBPF hastest_rbpf_closed_loop_on_real_dataandtest_rbpf_with_degraded_gnss, and its own row in the performance baseline. -
Performance benchmarks: accuracy regression is done --
core/tests/perf_baseline.rsgates every filter against a checked-in baseline in both directions, and Performance Baselines has the current numbers. What remains is the wall-clock half, which the accuracy gate deliberately does not measure:- Execution time metrics
- Memory usage tracking
- Scalability tests
-
Shorter test datasets:
- Create focused test datasets for faster CI/CD
- Keep full dataset for comprehensive validation
Linting and Formatting
Running Clippy
Clippy provides lint checks for Rust code:
cargo clippy --workspace --all-features
Address warnings and errors before committing.
Running rustfmt
Format code with rustfmt:
cargo fmt --all
Check formatting without making changes:
cargo fmt --all -- --check
Documentation
Building API Documentation
Generate documentation for all crates:
cargo doc --workspace --all-features --no-deps
Open the documentation in a browser:
cargo doc --workspace --all-features --no-deps --open
Building the Book
This user guide is built using mdBook. Install mdBook:
cargo install mdbook
Build the book:
cd book
mdbook build
Serve the book locally for development:
cd book
mdbook serve
Then open http://localhost:3000 in your browser.
Continuous Integration
The project uses GitHub Actions for continuous integration. See .github/workflows/ for workflow definitions:
rust.yml: the blocking gate. Three jobs --minimal(strapdown-core, no default features, on Linux/macOS/Windows),full(fmt, clippy-D warnings, and the whole suite with all features, Linux), andvendored(the macOS/Windows canary for the source builds of netCDF and HDF5)deploy-book.yml: builds and deploys this documentation to GitHub Pagespublish.yml: verifies, then publishes the crates to crates.io. Takes adry_runinputdraft-pdf.yml: renders the JOSS paper, on thejossbranchcopilot-setup-steps.yml: toolchain provisioning only
Every job sets timeout-minutes (#379), so a hang fails rather than running to GitHub's
360-minute default.
References
- Groves, P. D. (2013). Principles of GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd ed.
- Sensor Logger app: https://www.tszheichoi.com/sensorlogger
Performance Baselines
"Performance" on this page means navigation accuracy, not wall-clock speed. How far the solution was from truth, and whether the filter's covariance knew it. Execution time, memory and scalability are tracked nowhere yet; see Building and Testing for what remains.
Every number below is recorded in core/tests/perf_baseline.json and checked on every CI run
by core/tests/perf_baseline.rs. The check fails in both directions:
- a metric more than 10% worse than its baseline is a regression, and CI goes red;
- a metric more than 25% better is also a failure, and the message asks for the baseline to be re-blessed. An improvement nobody records is one the next change can silently undo.
Each scenario also records the number of aligned samples it was scored over and the number of channel-samples dropped as non-finite, both compared for exact equality before any metric is. The second is zero everywhere and is recorded because dropping a sample does not lower the first, but does make the metric it was dropped from look better.
Re-bless with UPDATE_PERF_BASELINE=1 cargo test -p strapdown-core --test perf_baseline, then
commit the diff; cargo perf runs the suite and prints the table without writing anything.
CONTRIBUTING.md has the
workflow.
Read these seven caveats first
The numbers are meaningless without them. All but the fifth and seventh are properties of the measurement rather than defects in the harness; the fifth is a defect in the gate, and the seventh was a defect in the UKF that is now fixed.
-
On the real-data scenarios, "truth" is the GNSS fix -- which is also the filters' aiding source. They measure agreement with the aid, not independent accuracy, and cannot fall below the receiver's own 3.81 m horizontal noise however good the filter is. The
syn_*scenarios are scored against an exact synthetic trajectory and have no such floor, which is why their horizontal figures are five times smaller. -
On a full-rate real-data row, the filter is scored against a fix it has already been given. This used to read differently: every horizontal figure carried a one-step propagation offset, because
sim::run_closed_looppushed each row after applying the next event, so the row labelled $t_k$ held a state already propagated to $t_{k+1}$. At 1 Hz and 21.19 m/s that was 21.2 m of along-track error -- very nearly the whole of the ~23.5 m thereal_cleanrows then showed, and the reason all three filters landed within 0.2 m of each other. #367 fixed it, and every horizontal metric improved at once.What that uncovered is caveat 1 with nothing left diluting it. A row at $t_k$ now contains $t_k$'s GNSS update, so on a
PassThroughschedule these columns measure how completely a filter absorbs its own aiding.That absorption used to be total:
real_clean__ukfread 0.014 m andreal_clean__ekf0.0001 m, far below the receiver's own 3.81 m. Not accuracy — a Kalman gain of about 1, caused by an absolute covariance floor applied to a latitude variance in radians². #373 fixed it, and the three filters now read 4.78, 5.22 and 5.12 m — above the receiver's own noise rather than reproducing it, and in agreement with the ESKF, which had floored its covariance relatively since #266.The real-data rows that still measure navigation are the ones where the filter has to predict between fixes --
real_sparse_5s,real_outage_60s,real_degraded-- together with thesyn_*scenarios and their exact independent truth. -
The synthetic scenarios now carry a real magnetic field, and the yaw column says which filters can use it.
generate_syntheticevaluates the World Magnetic Model at each epoch's position and date and rotates it into the body frame through the truth attitude (#369); before that it wrote nothing, and heading was observable only through the GNSS velocity fix. With a 1 Hz heading aid all three filters now hold a fraction of a degree of yaw onsyn_cruise_1hz-- the EKF 0.28, the ESKF 0.18, the UKF 0.16, the last down from 108.3 deg without a magnetometer at all.syn_outage_60s__ukfused to be where that stopped, and the stated reason was wrong. With the field added that row read 72.68 deg of yaw, 809 m horizontal and a NEES of 44.67, against the ESKF's 0.255 deg and 24.0 m on the identical stream, and this page cited that pair as evidence for #371. It was not evidence for anything: the three filter constructors shipped initial gyro-bias variances five orders of magnitude apart --1e-3for the UKF and the EKF against1e-8for the ESKF -- so the two filters were never handed the same prior.1e-3is a gyro-bias sigma of 1.81 deg/s against the 0.028 deg/s a consumer-grade part actually has.Sizing all three from the IMU grade takes that row to 0.222 deg of yaw, 27.4 m horizontal, NEES 4.21 and 3-sigma horizontal containment 0.997 against an ideal of 0.9973 -- past the ESKF's own numbers on the same stream, so the UKF is not incapable of carrying an attitude through a coast.
syn_outage_60s__eskfmoved the other way and for the same reason: its1e-8was about 5x tighter than the truth it was modelling, and correcting it costs 24.0 -> 26.3 m of position and 0.255 -> 0.292 deg of yaw while taking its NEES 4.64 -> 3.87 and its vertical containment 0.866 -> 0.925. Less accurate, more honest.#371 was a real defect -- a linear mean over sigma-point Euler triples sends 31 rotations inside a 0.24 deg cone to a point 34 deg outside it -- but it is not what this row was measuring. Fixing it, so the UKF perturbs, averages, correlates and injects attitude on SO(3), moved
syn_outage_60s__ukfyaw by 0.007 deg (0.222 to 0.230) and improved its position (27.39 to 27.17 m). That near-nil result on the rows with exact truth is the finding. The two__ekfrows did not move at all for the prior change either, which is also a finding rather than a null result: the EKF's bias states are structurally inert (#394), so its prior cannot be falsified by any measurement.The
real_*UKF rows are the exception, and the yaw column there cannot adjudicate it.real_clean__ukfyaw went 22.72 to 25.83 deg andreal_degraded__ukf22.18 to 26.01 when the chart was fixed, which reads as a 14-17% regression. Three measurements say what it actually is. The UKF's disagreement with the ESKF -- the other filter here that does its attitude arithmetic on a manifold, and which this change does not touch -- fell from 8.40 to 2.57 deg RMS on the same recording. The recording's own magnetometer, levelled by its own roll and pitch, disagrees with its yaw column by 16.8 deg RMS after removing every constant offset, which is the floor for any filter aided by it, so the 3 deg the UKF moved is inside what that column can resolve. And on the synthetic rows, where truth is exact, the change is neutral. Caveat 1 again, in the attitude channel. -
The consistency columns mean different things on the two sources. On the synthetic scenarios
npeslands between 3.9 and 12.1 against an ideal of 3.0, which is a real measurement of whether a filter believes the right thing. On the real-data scenarios it reaches the hundreds, because caveat 2's offset enters the numerator while the covariance in the denominator models none of it. Those values are kept as a drift detector, not read as a consistency verdict.The synthetic figures roughly doubled when #375 scheduled the barometer, and the vertical columns say why: what a normalised statistic now sees is a systematic altitude bias of -0.6 to -2.0 m that nothing models. That is #372, measured rather than masked.
-
The baseline is blessed on one platform and gated on three.
rust.ymlruns this suite on Linux, macOS and Windows, and nothing in the gate knows that -- there is no cross-platform tolerance floor. For most rows it does not matter: the same commit agrees to about 1% across platforms. It matters where a metric is a tail statistic of something unstable.real_rbpf_slice__rbpf'shorizontal_cep95_mmeasured 34.02 m on Linux against 24.33 m on Windows -- a 28.5% spread against a 25% improve band -- so a Linux bless failed the Windows leg of a run that was behaving identically. That metric is ungated with the numbers recorded in its note; the general problem is #386.
The spread is now measured, on all three legs of one run (at the old ukf_alpha), by
the advisory Cross-platform accuracy spread job. The largest entries:
| spread | scenario / metric | macOS | Linux | Windows |
|---|---|---|---|---|
| 10.79% | real_rbpf_slice__rbpf/horizontal_cep50_m | 1.82705 | 1.82705 | 1.64916 |
| 8.61% | real_rbpf_slice__rbpf/horizontal_cep95_m | 29.2597 | 29.2597 | 26.9394 |
| 4.70% | syn_outage_60s__ukf/horizontal_cep50_m | 1.64422 | 1.62557 | 1.57045 |
| 2.87% | syn_outage_60s__ukf/roll_rmse_deg | 0.27204 | 0.27475 | 0.26708 |
| 2.39% | syn_outage_60s__ukf/yaw_rmse_deg | 0.22314 | 0.22687 | 0.22158 |
Two readings fall out. The 28.5% this caveat quotes above is now 8.61%, so #385's fix did shrink the spread it was blamed for -- that was the missing measurement. And it is Windows against the other two, not three-way scatter: macOS and Linux agree to eleven significant figures on the RBPF rows, which is what a libm differing by an ulp looks like rather than anything about the navigation.
The UKF rows there are caveat 7 seen a second way -- same rows, same order, about half the magnitude of the one-ulp response recorded there. So caveat 7's fix predicted those rows would collapse and the RBPF rows would not.
That prediction was recorded before the run, and the run confirmed it. Same job, same
three platforms, on the commit that raised ukf_alpha:
| scenario / metric | before | after |
|---|---|---|
syn_outage_60s__ukf/horizontal_cep50_m | 4.70% | 0% |
syn_outage_60s__ukf/roll_rmse_deg | 2.87% | 0% |
syn_outage_60s__ukf/yaw_rmse_deg | 2.39% | 0% |
syn_outage_60s__ukf/attitude_geodesic_rmse_deg | 2.06% | 0% |
syn_outage_60s__ukf/velocity_horizontal_rmse_mps | 1.76% | 0% |
real_rbpf_slice__rbpf/horizontal_cep50_m | 10.79% | 10.79% |
real_rbpf_slice__rbpf/horizontal_cep95_m | 8.61% | 8.61% |
Every syn_*__ukf row now agrees across all three platforms to seven or eight significant
figures, and every RBPF row is unchanged to the digit.
So what is left of #386 is a single scenario — and it is not floating-point noise.
real_rbpf_slice__rbpf holds every entry above 0%, and nothing else in the suite exceeds
it. The obvious next move is a tolerance, global or per-metric. Both are wrong, because
the number is not understood.
Three one-ulp perturbations, each applied on every call — which is what a libm differing in its last bit actually is:
| perturbation | worst of 236 metrics |
|---|---|
| every Gaussian draw in the RBPF | 1.28e-6 % |
every particle weight, after exp | 3.09e-7 % |
| one WGS84 constant (caveat 7's ensemble) | 1.0e-6 % |
The RBPF moves by about one part in 10⁸ under any of them, and horizontal_cep50_m — the
metric carrying the 10.79% spread — is the most responsive at 1.28e-6 %. The RBPF is not
an amplifier, the opposite of the UKF in caveat 7, so the residual spread cannot be a
last-bit effect.
The platform pattern agrees. macOS and Linux match to eight significant figures on that metric, across different CPU architectures, while Windows — same architecture as Linux — sits 10% away. That rules out CPU architecture, SIMD width and runtime feature dispatch, all of which would separate ARM from x86 rather than Windows from everyone.
A 10% discrepancy on one platform and one scenario, demonstrably larger than an ulp, is a
defect to diagnose rather than a band to widen; fitting a tolerance to it would be
#288 again. Diagnose it by emitting
the sorted error series behind horizontal_cep50_m from each leg — the PERF_EMIT_MEASURED
plumbing is most of the way there — and by checking that test_data.csv parses identically
on Windows before blaming the filter.
Two parts of that are now closed. The baseline records the operating system it was
blessed on (blessed_on), and the improve-side failure no longer tells a contributor their
baseline is stale when the run is simply on a different platform -- it names both platforms
and says to check the blessing one first. Re-blessing on the wrong leg was the specific
damage the old message caused.
The tolerance floor is deliberately not set yet, because nobody has measured it. Every
matrix leg now writes its raw numbers with PERF_EMIT_MEASURED and uploads them, and the
advisory Cross-platform accuracy spread job prints the per-metric spread across all three.
Setting a floor before reading that output would be gating on a guess, which is the mistake
#386 exists to correct. Read the job's table, then set the floor from it.
Note also that the 28.5% figure above predates #385. That row's tail was the epochs where the particle cloud collapsed, and it no longer collapses, so the spread may already be much smaller. It has not been re-measured. Until then, check a Windows run before calling a re-bless done. 7. A one-ulp change to anything upstream used to move these numbers by percents. This is #399, and both halves of its recorded diagnosis turned out to be wrong, which is worth keeping.
The issue's experiment was to add a pub fn to linearize.rs that nothing calls and
re-bless: 59 metrics moved, one by 4.86%. Its proposed mechanism was the innovation gate --
a continuous statistic turned into a discrete accept/reject, one flipped decision in 15,000
steps separating two trajectories.
The gate was refuted by counting. Gating is opt-in and this suite never installs one, so
across a full run all 137,876 calls to GatePolicy::decide took the "no gate" branch.
There was no accept/reject decision to flip. Nor was there any other discrete branch: all
98,786 sigma-point factorizations took plain Cholesky with no jitter and no eigenvalue
fallback, no IMU-bias clamp bound in 394,740 opportunities, and the Euler-rate inverse was
admitted on all 106,518.
The codegen story did not reproduce either. The dead pub fn, with its symbol confirmed
present in the compiled rlib, leaves all 236 metrics bit-identical. Rust emits IEEE-754
semantics without fast-math, so LLVM may not reassociate or contract floating-point;
changing what surrounds a function cannot change what it computes.
What does reproduce is the amplification, from the data side, and it was the UKF's.
Perturbing earth::EQUATORIAL_RADIUS by one ulp moved 170 of 236 metrics, the worst by
9.77% against a 10% band. Over six independent one-ulp perturbations of WGS84 constants:
| estimator | worst response to one ulp |
|---|---|
| UKF | 9.77% |
| RBPF | 1.0e-6 % |
| dead reckoning | 1.7e-10 % |
| EKF | 3.8e-11 % |
| ESKF | 3.3e-11 % |
Eleven orders of magnitude on the same scenarios, which rules out "a 60 s coast compounds a
last bit": syn_outage_60s__eskf coasts identically and sits at 3.3e-11 %. The UKF is the
only estimator here that forms a weighted sigma-point mean, and at the alpha = 1e-3 this
crate shipped, a 16-state filter formed it as -999,999 x_0 + sum 31,250 x_i -- six of
f64's sixteen significant digits spent on cancellation, every step. End to end, one ulp of
initial latitude moved a 180 s solution's final position by 1.76 m.
Fixed by raising ukf_alpha to 0.1, which takes that to 0.18 mm and the worst metric
response to 0.0003%, and improves accuracy slightly on the way. An equilibrated Cholesky
landed with it and is worth a further factor of two. core/tests/ukf_conditioning.rs guards
both ends, and the numbers above are in that file and in ClosedLoopConfig::ukf_alpha.
Use a control build when a bless surprises you. If a re-bless moves rows the change
cannot reach, compile the new code without calling it, bless that, and diff the real
change against the control rather than against the parent commit. On
#394 that reduced "seven scenarios
moved" to the two the change can actually touch. It is only valid if it neutralises every
call site -- a first attempt at #398's control missed the UKF's transport and reported half
the moved metrics. This is now insurance rather than a necessity, but it is the habit that
found every mis-attribution on this project so far.
6. The syn_* rows run at 50 Hz and the real_* rows at 1 Hz, and the aiding sensors no
longer follow that. Until
#375 the barometer and the
magnetometer were emitted once per record, outside the scheduler, so their update rate was
the log's: 1 Hz on test_data.csv and 50 Hz on the synthetic trajectories, where fifty
pressure readings a second each entered the filter as an independent fix. Both channels are
now scheduled at 1 Hz by default. Every real_* row came through bit-identical -- they were
already at 1 Hz -- and all five aided syn_* rows moved, almost entirely in the vertical
channel: horizontal RMSE on syn_cruise_1hz moved by under half a percent. Read the
synthetic vertical columns as a 1 Hz barometer's, not a 50 Hz one's, and the yaw
columns as a 1 Hz magnetometer's.
The metrics
| metric | unit | definition | ideal |
|---|---|---|---|
horizontal_rmse_m | m | RMS great-circle distance from truth | 0 |
horizontal_cep50_m | m | median radial error -- the empirical, non-parametric CEP, not the Rayleigh form. Nearest-rank, so it is a value some epoch actually had | 0 |
horizontal_cep95_m | m | 95th percentile radial error, same convention | 0 |
horizontal_max_m | m | largest single-sample radial error | 0 |
vertical_rmse_m | m | RMS altitude error | 0 |
vertical_bias_m | m | signed mean altitude error -- catches a slow one-sided drift while it is still small | 0 |
velocity_horizontal_rmse_mps | m/s | RMS north/east velocity error | 0 |
velocity_vertical_rmse_mps | m/s | RMS vertical velocity error | 0 |
roll_rmse_deg, pitch_rmse_deg | deg | RMS per-axis error, wrapped onto $[-\pi, \pi]$ | 0 |
yaw_rmse_deg | deg | RMS heading error, wrapped | 0 |
attitude_geodesic_rmse_deg | deg | RMS rotation angle of $R_{est}^{-1} R_{true}$ -- the only attitude error independent of the Euler sequence | 0 |
nees_position | -- | mean normalized estimation error squared over the full 3x3 position block, $\frac{1}{N}\sum_k e_k^\top P_k^{-1} e_k$ | 3.0 |
npes_position | -- | the same, computed as though $P$ were diagonal: $\overline{\epsilon} = \frac{1}{N}\sum_k \left(\frac{e_{lat}^2}{P_{lat}} + \frac{e_{lon}^2}{P_{lon}} + \frac{e_{alt}^2}{P_{alt}}\right)$ | 3.0 |
containment_3sigma_horizontal | fraction | share of latitude and longitude samples inside $\pm 3\sigma$ | 0.9973 |
containment_3sigma_vertical | fraction | share of altitude samples inside $\pm 3\sigma$ | 0.9973 |
nees_position is the real statistic. It became computable in
#376, which stopped NavigationResult
discarding the position block's off-diagonal terms; before that the crate could only report the
diagonal-only npes_position, which equals the NEES when the position states are uncorrelated
and is optimistic when they are not.
Both are kept. npes_position stays so the baseline it has accumulated remains comparable
across that change, not as a claim about consistency.
How much did the diagonal-only form actually understate? Less than #376 feared, and precisely where you would expect:
| scenario | npes | nees | ratio |
|---|---|---|---|
| every row with GNSS every epoch | — | — | 1.00–1.01 |
real_outage_60s__eskf | 73.43 | 83.04 | 1.13 |
syn_outage_60s__ukf | 6.54 | 6.82 | 1.04 |
Where the filter is aided every epoch the two agree to within 1%, so the historical npes
numbers were not misleading. The gap opens on the outage rows -- syn_outage_60s__ukf read
15.12 against 18.45, a ratio of 1.22, until #375 brought that coast back under control -- and the mechanism is the
obvious one: during a free-inertial coast the position error accumulates along a correlated
direction with no fix to break it up, and a diagonal-only statistic cannot see that. The
contrived worst case is far larger — at a latitude-longitude correlation of 0.9 the two read
2.0 and 20.0 — so the modest ratios here are a measurement about these trajectories, not a
general reassurance.
Read either beside the containment row, never alone -- together they separate the two failure modes either one alone hides:
| containment | nees_position | diagnosis |
|---|---|---|
| ~1.0 | far below 3 | over-conservative: the filter is right but does not believe it |
| below 0.99 | far above 3 | over-confident -- the dangerous one |
| ~0.9973 | ~3 | consistent |
There is no Wasserstein-2 metric, though the original issue floated one. Against a Dirac at
truth it reduces to squared error plus total variance, which is strictly less information than
reporting the error and the consistency separately. Against the empirical error distribution it
is not a distance between samples of anything, because errors along one trajectory are strongly
autocorrelated. The CEP columns carry the "the shape of the error distribution moved" signal
instead, and a human reading a failure message can interpret them. The reasoning is recorded in
full in the strapdown::metrics module documentation.
Scenarios
real is core/tests/test_data.csv: 5,366 samples at 1 Hz over 89 minutes, ENU, from a
consumer MEMS IMU. syn is sim::generate_synthetic: 300 s at 50 Hz, NED, 50 m/s off-axis
cruise, consumer-grade IMU, seed 42, scored against its own exact truth.
| id | data | GNSS | estimators |
|---|---|---|---|
real_clean | real | every epoch | UKF, EKF, ESKF |
real_sparse_5s | real | every 5 s | UKF, ESKF |
real_outage_60s | real | 120 s on, 60 s off | ESKF |
real_degraded | real | every epoch, AR(1) position and velocity fault | UKF, ESKF |
syn_cruise_1hz | syn | every 1 s | UKF, EKF, ESKF |
syn_outage_60s | syn | 60 s on, 60 s off | UKF, ESKF |
syn_dead_reckoning | syn, first 120 s | none | unaided mechanization |
real_rbpf_slice | real, first 1,200 samples | every epoch | RBPF, 500 particles |
Two scenarios are deliberately absent. Full-run dead reckoning ends millions of metres out
by way of an altitude at which the r_e + altitude denominator in earth::transport_rate is
within a few percent of zero, where finiteness is a floating-point accident that has already
differed between Windows and Linux; the 120 s synthetic window is the safe version of the same
measurement. A second particle-filter scenario is affordable in principle and not in CI
time: the RBPF is the only estimator here whose cost is measured in seconds.
The whole suite runs in about 5.5 seconds on Linux and is not gated behind a feature or an
#[ignore], so it runs on all three CI platforms. That is deliberate: the three legs are a
free portability canary for the accuracy numbers themselves, which this project has been bitten
by before.
Current values
Measured on ubuntu-latest, rustc 1.91. Read-only with cargo perf; re-blessed with
UPDATE_PERF_BASELINE=1 cargo test -p strapdown-core --test perf_baseline.
The three tables below are generated from core/tests/perf_baseline.json by the same test
that gates it, and the gate fails if they drift from it. They used to be maintained here by
hand, which meant every re-bless was a command plus 45 transcribed rows, with nothing checking
the transcription -- and a wrong number on this page is harder to notice than a wrong number in
the gated file, because nothing asserts it (#380). Edit baseline-tables.md and the next run
will tell you off; change the numbers by re-blessing.
Position and velocity
| scenario | samples | horiz RMSE (m) | CEP50 (m) | CEP95 (m) | horiz max (m) | vert RMSE (m) | vert bias (m) | horiz vel RMSE (m/s) | vert vel RMSE (m/s) |
|---|---|---|---|---|---|---|---|---|---|
real_clean__ukf | 5,366 | 5.146 | 4.207 | 8.641 | 24.918 | 1.418 | -0.020 | 1.130 | 0.516 |
real_clean__ekf | 5,366 | 5.151 | 4.201 | 8.636 | 25.348 | 1.387 | -0.025 | 1.130 | 0.534 |
real_clean__eskf | 5,366 | 5.129 | 4.206 | 8.610 | 24.722 | 1.409 | -0.025 | 1.107 | 0.526 |
real_sparse_5s__ukf | 5,366 | 9.468 | 6.054 | 15.284 | 127.082 | 2.174 | -0.214 | 2.846 | 0.635 |
real_sparse_5s__eskf | 5,366 | 9.470 | 6.011 | 15.179 | 129.550 | 2.216 | -0.227 | 2.838 | 0.657 |
real_outage_60s__eskf | 5,366 | 257.380 | 4.618 | 645.701 | 2,166.400 | 1.834 | -0.236 | 10.750 | 0.604 |
real_degraded__ukf | 5,366 | 50.700 | 40.636 | 88.053 | 140.311 | 13.878 | 0.438 | 2.487 | 0.787 |
real_degraded__eskf | 5,366 | 50.702 | 40.635 | 88.051 | 140.315 | 13.864 | 0.433 | 2.469 | 0.794 |
syn_cruise_1hz__ukf | 15,000 | 2.600 | 2.214 | 4.354 | 6.784 | 2.279 | -0.685 | 0.581 | 0.304 |
syn_cruise_1hz__ekf | 15,000 | 2.600 | 2.213 | 4.355 | 6.785 | 2.279 | -0.686 | 0.581 | 0.304 |
syn_cruise_1hz__eskf | 15,000 | 2.600 | 2.179 | 4.397 | 6.785 | 2.154 | -0.549 | 0.559 | 0.262 |
syn_outage_60s__ukf | 15,000 | 24.024 | 1.379 | 48.123 | 125.428 | 2.045 | -0.039 | 1.290 | 0.284 |
syn_outage_60s__eskf | 15,000 | 23.444 | 1.406 | 55.373 | 112.796 | 1.872 | -0.229 | 1.346 | 0.234 |
syn_dead_reckoning | 6,000 | 316.860 | 110.128 | 715.856 | 835.634 | 86.591 | -64.255 | 9.363 | 1.896 |
real_rbpf_slice__rbpf | 1,200 | 3.235 | 0.579 | 6.004 | 27.672 | 0.795 | -0.131 | 1.231 | 0.388 |
Attitude
| scenario | samples | roll RMSE (deg) | pitch RMSE (deg) | yaw RMSE (deg) | geodesic RMSE (deg) |
|---|---|---|---|---|---|
real_clean__ukf | 5,366 | 3.421 | 2.850 | 24.539 | 24.925 |
real_clean__ekf | 5,366 | 3.334 | 2.904 | 22.313 | 22.730 |
real_clean__eskf | 5,366 | 3.311 | 2.979 | 24.204 | 24.592 |
real_sparse_5s__ukf | 5,366 | 3.789 | 3.157 | 19.013 | 19.645 |
real_sparse_5s__eskf | 5,366 | 3.763 | 3.223 | 18.931 | 19.569 |
real_outage_60s__eskf | 5,366 | 3.561 | 3.264 | 21.704 | 22.219 |
real_degraded__ukf | 5,366 | 3.497 | 3.005 | 24.300 | 24.725 |
real_degraded__eskf | 5,366 | 3.417 | 3.107 | 24.167 | 24.593 |
syn_cruise_1hz__ukf | 15,000 | 0.298 | 0.251 | 0.156 | 0.420 |
syn_cruise_1hz__ekf | 15,000 | 0.298 | 0.251 | 0.155 | 0.420 |
syn_cruise_1hz__eskf | 15,000 | 0.291 | 0.254 | 0.155 | 0.416 |
syn_outage_60s__ukf | 15,000 | 0.231 | 0.206 | 0.189 | 0.363 |
syn_outage_60s__eskf | 15,000 | 0.222 | 0.249 | 0.248 | 0.416 |
syn_dead_reckoning | 6,000 | 0.468 | 1.106 | 0.494 | 1.301 |
real_rbpf_slice__rbpf | 1,200 | 3.080 | 3.107 | 21.847 | 22.252 |
Consistency
| scenario | samples | nees (ideal 3.0) | npes, diagonal only | 3-sigma horiz (ideal 0.9973) | 3-sigma vert (ideal 0.9973) |
|---|---|---|---|---|---|
real_clean__ukf | 5,366 | 51.473 | 51.349 | 0.514 | 0.836 |
real_clean__ekf | 5,366 | 51.271 | 51.139 | 0.515 | 0.839 |
real_clean__eskf | 5,366 | 51.310 | 51.197 | 0.515 | 0.836 |
real_sparse_5s__ukf | 5,366 | 31.474 | 31.099 | 0.631 | 0.853 |
real_sparse_5s__eskf | 5,366 | 31.980 | 31.473 | 0.634 | 0.858 |
real_outage_60s__eskf | 5,366 | 42.922 | 40.595 | 0.614 | 0.853 |
real_degraded__ukf | 5,366 | 5,474.700 | 5,473.870 | 0.074 | 0.093 |
real_degraded__eskf | 5,366 | 5,474.380 | 5,473.580 | 0.074 | 0.094 |
syn_cruise_1hz__ukf | 15,000 | 4.774 | 4.776 | 1.000 | 0.913 |
syn_cruise_1hz__ekf | 15,000 | 4.774 | 4.776 | 1.000 | 0.913 |
syn_cruise_1hz__eskf | 15,000 | 4.573 | 4.575 | 1.000 | 0.928 |
syn_outage_60s__ukf | 15,000 | 2.341 | 2.341 | 0.999 | 0.966 |
syn_outage_60s__eskf | 15,000 | 2.228 | 2.229 | 0.999 | 0.963 |
syn_dead_reckoning | 6,000 | -- | -- | -- | -- |
real_rbpf_slice__rbpf | 1,200 | 18.121 | 10.327 | 0.951 | 0.951 |
What the table is saying
- The
real_cleanhorizontal column measures agreement with the aid, not accuracy. All three filters now read 4.78, 5.22 and 5.12 m against a reference whose own noise is 3.81 m, so none of them is below its own reference any more — #373 fixed the absolute covariance floor that had the UKF and EKF reproducing the fix they were handed (0.014 m and 0.0001 m). What remains is caveat 1's circularity, which no fix to the filters can remove: the score is against the aiding source itself. syn_cruise_1hzis where the filters actually separate, and no one of them wins. The ESKF leads on vertical error, the EKF on horizontal, and all three now hold a fraction of a degree of yaw — the UKF's, once the worst column in the table at 108.3 deg with no magnetometer and 2.01 deg with one but a mis-sized bias prior, is now the best of the three. These rows are scored against exact truth, so nothing here is circular.- The outage rows are dominated by attitude, not by position. 60 s of free inertial turns a fraction of a degree of tilt error into tens of metres. Both filters now hold about a quarter of a degree of yaw through the coast and land within a couple of metres of each other, around 26–27 m. The UKF used to reach 809 m on this row; that was its initial gyro-bias prior, not its attitude algebra, and the story is in caveat 3.
- The two consistency columns disagree with each other on real data, and that is the finding. Horizontal three-sigma containment sits near 0.50 across the three filters and vertical near 0.40, where better than half the altitude errors fall outside three sigma. Neither is the old flat 1.000 — #373 removed the absolute covariance floor that made the horizontal figure unfalsifiable, so what is left in both columns is a real measurement of over-confidence rather than an artefact. The vertical half is #372.
- The ESKF's numbers moved furthest when #367 landed, and in the right direction:
horizontal containment 0.161 to 0.497,
npes1,249 to 68. That is the offset leaving the numerator. It is still not consistent -- 0.497 against an ideal of 0.9973 -- and what remains is a real finding rather than an artefact. real_rbpf_slice__rbpf's consistency metrics are not gated. When this was writtennpesread 5.7e25 and the realneeslarger still, because the particle cloud collapsed to a horizontal sigma of nanometres on 17 of 1,200 epochs, which a mean of $e^2/P$ cannot survive (#385). Roughening removed the collapse, and the restructure after Canciani & Raquet (2026-09-25) tooknpesto 10.3 and both channels' 3-sigma containment to 0.95: the filter is now close to consistent. The metrics stay ungated until the gate has a cross-platform tolerance (#386).- Several numbers here therefore record known defects rather than good behaviour, which is what a two-sided gate is for. Each is annotated in the baseline file. When one is fixed the improvement side trips and asks for the diff that records it.
The vertical process-noise retune
These numbers already include one deliberate change the gate was built to catch, and it is worth reading as a worked example of the workflow.
DEFAULT_PROCESS_NOISE_DENSITY's altitude entry was 1e-4 -- a 1 cm standard deviation,
a tenth of the horizontal channel's 0.1 m, an asymmetry nothing ever justified. Issue #308 had
fixed the units of the horizontal entries and deliberately left this one alone, recording
that whether 1e-4 was the right tuning was a separate question. The consistency columns
answered it: at 1e-4 three-sigma altitude containment was 0.88 on the synthetic trajectory
and 0.44 on the recording, against an ideal of 0.9973. More than half the altitude errors on
real data fell outside the uncertainty the filter reported for them.
Three entries could have been blamed. Sweeping each alone over four decades, only the altitude
position entry moves containment the right way -- the vertical-velocity entry is flat and costs
a vertical-velocity RMSE growing from 0.34 to 4.37 m/s, and the accelerometer-bias entry makes
containment worse while taking the real 60 s-outage horizontal RMSE from 291 m to 443 m. The
entry is now POSITION_PROCESS_NOISE_M_PER_ROOT_S², which makes the position block isotropic in
standard deviation and lands on the knee of the measured curve.
What it bought, and what it cost:
| before | after | |
|---|---|---|
syn_cruise_1hz__eskf 3-sigma vertical | 0.880 | 0.933 |
syn_cruise_1hz__eskf npes (ideal 3.0) | 5.653 | 4.795 |
syn_outage_60s__eskf horizontal RMSE | 137.66 m | 20.22 m |
syn_outage_60s__eskf pitch RMSE | 1.500 deg | 0.205 deg |
syn_cruise_1hz__eskf vertical RMSE | 0.665 m | 0.836 m |
syn_outage_60s__ukf horizontal RMSE | 221.67 m | 266.68 m |
The vertical RMSE cost is the honest price: the old value bought that number by under-reporting
its own error, and on the reference recording it is not paid at all -- vertical RMSE there
improved slightly. (These synthetic figures have since moved again, and further, under #375 --
see caveat 6. The numbers in this table are the ones that change measured, kept as its
record.) The syn_outage_60s__ukf row is recorded but not endorsed: across the sweep
its horizontal RMSE runs 222, 174, 267, 344 m while its yaw runs 105, 98, 21, 34 deg, so
position degrades exactly where attitude improves fivefold. That row is
#371 coasting an outage, and it does
not respond monotonically to this or any other knob.
Architecture
Project Structure
Frequently Asked Questions
General Questions
What is Strapdown-rs?
Strapdown-rs is a Rust library for implementing strapdown inertial navigation systems (INS). It provides core functionality for processing IMU data to estimate position, velocity, and orientation.
Who should use Strapdown-rs?
Strapdown-rs is designed for:
- Researchers working on navigation systems
- Engineers developing autonomous systems
- Students learning about inertial navigation
- Anyone needing a high-performance INS implementation in Rust
What coordinate frames are supported?
The library primarily uses the North-East-Down (NED) local-level frame. The 9-state vector includes:
- Position: latitude, longitude, altitude
- Velocity: northward, eastward, downward
- Attitude: roll, pitch, yaw
Is this production-ready?
Strapdown-rs is primarily intended for research and development. While the code is well-tested and prioritizes correctness, it is still under active development as part of ongoing PhD research.
Installation and Setup
What are the system requirements?
See the System Requirements page for detailed information. In summary:
- Rust 1.91 or later
- A C/C++ compiler and cmake 3.26+, but only for features that use a C library
- No system libraries to install at build time
- libfontconfig at run time, if you use
strapdown-sim's plotting (a default feature). It is loaded on demand, so a machine without it builds fine and fails at the first plot - Supported on Linux, macOS, and Windows
Do I need to install HDF5 and NetCDF?
No. They are compiled from vendored sources that ship as ordinary cargo dependencies, along with zlib and freetype, so nothing is searched for on your machine. What you need instead is a C/C++ compiler and cmake 3.26 or newer to build them with.
They are used for:
- HDF5: binary data storage for
TestDataRecordandNavigationResult - NetCDF: geophysical map data for the geonav features
Neither is on by default in strapdown-core, so if you only need the core INS functionality
you need no C toolchain either: cargo build -p strapdown-core uses none.
How do I install on Windows?
Install the MSVC toolchain from Visual Studio Build Tools plus cmake. vcpkg is no longer needed, since nothing is looked up on the system. WSL2 also works if you prefer a Linux environment.
See Installation for details.
Usage Questions
What data format does strapdown-sim accept?
The simulation expects CSV files with IMU and GNSS data following the Sensor Logger app format. See Input Data Format for details.
Which filter should I use: EKF, UKF, or Particle Filter?
- EKF: Fastest, works well for mildly nonlinear systems
- UKF: Better accuracy for highly nonlinear systems, 2-3x slower than EKF
- Particle Filter: Best for non-Gaussian distributions and multimodal scenarios
See Filter Comparison for detailed analysis.
Can I use my own sensor data?
Yes! You'll need to convert your data to the expected CSV format. The library is designed to work with standard IMU (gyroscope and accelerometer) and GNSS (position) measurements.
How do I simulate GNSS outages?
Use the GNSS fault simulation features in the configuration file:
[gnss]
dropout_probability = 0.1 # 10% chance of dropout
reduced_update_rate = 0.5 # Half the normal rate
See GNSS Degradation Scenarios for more options.
Performance Questions
How fast is Strapdown-rs?
Performance varies by filter type and configuration:
- EKF: ~10,000-20,000 updates/second
- UKF: ~3,000-5,000 updates/second
- Particle Filter: Depends on particle count (100 particles: ~500 updates/second)
These are approximate figures on modern hardware and will vary based on your system.
Can I run simulations in parallel?
The current implementation processes data sequentially as navigation is inherently a sequential process. However, the particle filter implementation can utilize multiple cores for particle processing.
How much memory does it use?
Memory usage is modest:
- Core library: ~10-50 MB
- Simulations: Depends on data size and filter configuration
- Particle filters: Linear with particle count
Development Questions
How can I contribute?
We welcome contributions! Please see the Contributing Guide and reach out to the project maintainer before starting major work.
Where is the API documentation?
Full API documentation is available at docs.rs/strapdown-core. This book focuses on high-level concepts and usage patterns.
Can I use this in my commercial project?
Yes! Strapdown-rs is licensed under the MIT License, which allows commercial use. See the LICENSE file for details.
How do I cite this work?
If you use Strapdown-rs in your research, please cite the JOSS paper:
Troubleshooting
I'm getting build errors from HDF5 or NetCDF
These are built from source, so failures are cmake or compiler errors rather than linker errors. The two common ones:
CMake 3.26 or higher is required-- your cmake is older than the bundled HDF5 needs. Ubuntu 22.04 (3.22) and Debian 12 (3.25) both hit this.- A confusing cmake failure inside the netCDF build -- check
echo "${HDF5_DIR:-unset}". If it is set, the HDF5 build switches to looking for a system library even though a vendored build was requested, and netCDF is then given the wrong headers.
See Installation Troubleshooting for more help.
My simulation produces NaN values
Common causes:
- Invalid initial conditions
- IMU data with unrealistic values
- Numerical instability in filter
Check your input data and initial state. Enable debug logging with --log-level debug to investigate.
The particle filter is very slow
This is expected with high particle counts. Consider:
- Reducing the number of particles
- Using the RBPF (Rao-Blackwellized) variant
- Using EKF/UKF instead if appropriate
Where can I get help?
- Check this FAQ and the user guide
- Search existing GitHub Issues
- Open a new issue if you've found a bug
- Contact the maintainer for research collaborations
Additional Questions?
If your question isn't answered here, please:
- Check the User Guide
- Review the API Reference
- Open an issue on GitHub