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.

Crates.io Documentation License

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:

  1. A source library of strapdown INS algorithms and utilities (strapdown-core)
  2. A simulation program for simulating INS performance in various GNSS conditions (strapdown-sim)
  3. 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

Citation

If you use Strapdown-rs in your research, please cite:

JOSS Paper

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-core on its own needs none of this -- cargo add strapdown-core and a Rust toolchain are enough unless you turn on its hdf5 or netcdf features.

Installation Methods

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.toml for environment management. It was removed once the C libraries began building from source, since there was nothing left for it to provide; cargo build is 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.

  1. CMake 3.26 or higher is required -- your cmake is too old. See the Build Dependencies section above.

  2. Confusing cmake failures inside the netCDF build -- check whether HDF5_DIR is 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.

  3. cargo install is 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

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
  • 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 with snap install cmake --classic. Ubuntu 24.04 and Fedora 40+ are new enough as shipped.

Runtime

  • libfontconfig: needed at run time by strapdown-sim's plotting feature, 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

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

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:

  1. Core Concepts: Understanding strapdown INS, coordinate frames, and state representation
  2. Running Simulations: How to use the strapdown-sim binary
  3. Data Formats: Preparing and formatting your input data
  4. Configuration: Setting up simulations with TOML config files
  5. Logging and Debugging: Monitoring simulation progress

Quick Navigation

For Beginners

If you're new to strapdown INS or this library:

  1. Start with Core Concepts to understand the fundamentals
  2. Learn about Coordinate Frames used in the library
  3. Review State Representation to understand the 9-state and 15-state models
  4. Try the Quick Start tutorial

For Experienced Users

If you're familiar with INS and want to jump in:

  1. Check Input Data Format to prepare your data
  2. Review Configuration Files for advanced options
  3. Explore Running Simulations for different modes
  4. 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

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 -- ParticleFilterType has this one variant, and particle.rs is 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

  1. Collect or prepare IMU/GNSS data in CSV format
  2. Create a configuration file specifying simulation parameters
  3. Run the simulation using strapdown-sim
  4. Analyze results from the output CSV
  5. 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

Next Steps

Choose your path:

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:MM is 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:

SourceFrameVertical specific force at restFlag
Sensor Logger exportENU+9.8 m/s^2 along up--enu
strapdown-sim syn outputNED-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 accuracy
  • spoofed/: Simulates GPS spoofing with fixed position offsets to mislead the navigation system
  • intermittent/: Simulates intermittent GPS with periodic outages
  • combo/: 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:

  1. Design your navigation algorithm to leverage the available data in the input files
  2. Configure your experiment's AidingConfig to match the specific characteristics of the degraded condition you want to simulate
  3. 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 configCLI subcommand
dead-reckoningdr
open-loopol (not implemented -- writes no output)
closed-loopcl
particle-filterpf
syntheticsyn

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:

SectionValid kind values
scheduler, baro_scheduler, magnetometer_schedulerpass_through, fixed_interval, duty_cycle
faultnone, 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σ containmentvertical biasvertical RMSE
off0.40+0.40 m2.58 m
on0.84−0.02 m1.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
  • --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:

PythonRust
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

  1. Computational Efficiency: 3-5x faster than UKF
  2. Memory Efficient: Stores only mean and covariance
  3. Well-Understood: Extensive literature and proven track record
  4. Analytic Jacobians: Uses pre-computed derivatives for accuracy

Limitations

  1. Linearization Error: Less accurate for highly nonlinear systems
  2. First-Order Approximation: May miss higher-order effects
  3. 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

AspectEKFUKF
SpeedFaster (3-5x)Slower
AccuracyGood for mildly nonlinearBetter for highly nonlinear
ImplementationRequires JacobiansNo Jacobians needed
MemoryLowerHigher
Best ForReal-time systemsResearch/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 varianceas 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

  1. Start with 9-state unless you need bias estimation
  2. Tune conservatively: Start with larger uncertainties and reduce
  3. Monitor innovation: Check measurement residuals for divergence
  4. Use 15-state for long-duration missions or low-quality IMUs
  5. Validate with dead reckoning: Compare against the dr subcommand's results
  6. Write position uncertainties in metres and convert once -- see Units on the covariance diagonals

Next Steps

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:

ApproachStatesVertical VelocityComplexityRecommended
2.5D Simplified15Random walk with large process noiseLowYes - Start here
Third-Order Damping17Damped feedback controlHighOptional - if 2.5D insufficient

3. Implementation Priority

  1. First: Implement forward_2_5d() - this is the foundation
  2. Second: Implement particle filter with 2.5D mode
  3. 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_h
    • k₁, k₂: Damping coefficients (tunable)
    • w_h: Process noise on altitude error

Feedback mechanism: During propagation (predict step):

  1. Propagate particles through standard strapdown equations
  2. Update altitude using damping feedback:
    h_corrected = h_propagated - k_fb · δh
    v_v_corrected = v_v_propagated - k_fb · δh_dot
    
  3. During measurement update with GPS altitude:
    • Innovation: z - h_expected updates both h and δh
    • Damping states receive weighted update based on particle importance

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
      

Process noise tuning:

  • Position (lat/lon): 1e-6 (tight)
  • Altitude: 1e-3 to 1e-2 (loose, allows drift)
  • Horizontal velocity: 1e-3
  • Vertical velocity: 1e-2 to 1e-1 (very loose, absorbs vertical uncertainty)
  • Biases: 1e-6 to 1e-8 (standard)

Measurement handling:

  • GPS altitude measurements directly constrain h with 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:

  1. Create num_particles particles from initial state
  2. Sample each particle's state from N(μ₀, P₀) where:
    • μ₀ = initial mean state
    • P₀ = diag(covariance_diagonal)
  3. Initialize all weights to 1.0 / num_particles
  4. 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):

  1. 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
  2. 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
  3. 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):

  1. 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
  2. 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

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()

ComponentStandard forward()forward_2_5d()
AttitudeFull mechanization (Eq 5.46)Same - Full mechanization
Specific force transformFull (Eq 5.47)Same - Full transform
Horizontal velocityFull mechanization (Eq 5.54)Same - Full mechanization
Vertical velocityIntegrated from vertical accelNOT integrated - random walk
Horizontal positionFull mechanization (Eq 5.56)Same - Full mechanization
AltitudeIntegrated from vertical velocitySame - 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:

  1. Process noise injection (large σ_vv)
  2. 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 support
  • pf_hover_motion() - stationary altitude
  • pf_climb_descent() - vertical motion profile
  • pf_altitude_drift_during_outage() - verify controlled drift with 2.5D
  1. ✅ Define Particle struct (already done)
  2. Implement forward_2_5d() function in particle.rs
    • Copy attitude_update() from lib.rs or make it public
    • Implement velocity_update_horizontal() helper
    • Reuse position_update() from lib.rs (make public if needed)
  3. Implement ProcessNoise struct
  4. Implement ParticleFilter struct with basic initialization
  5. Implement predict() using forward_2_5d()
  6. Implement update() with systematic resampling
  7. Implement get_estimate() with weighted mean
  8. Write unit tests for each component
  9. Integration with sim.rs
  10. Test on real datasets
  11. (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_eff over 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.rs implementation 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:

partitionstates
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 under S = 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, C selects V + 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:

keymeaningdefault
velocity_process_noise_std_mps, attitude_process_noise_std_radVRW 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_sthe loop's gains place its three poles at -1/τ10
baro_error_std_m, baro_error_time_constant_sthe barometer-aiding error as a Gauss-Markov process (σ_b, τ_b)8.3 m, 3600 s
vertical_accel_error_init_std_mps2prior on δâ0.1
effective_sample_thresholdresample below this fraction of N; 1.0 resamples every update, as the paper does1.0
roughening_factorpost-resample jitter; 0.0 as in the paper0.2
gravity_variation_std, magnetic_variation_stdsteady-state sigma of each channel's Vfrom [geophysical] *_bias_process_noise_std
gravity_variation_time_constant_s, magnetic_variation_time_constant_scorrelation time of V300 (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:

  1. 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.
  2. 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.
  3. 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_vv of about 0.6 m/s, which a velocity residual of about 140 m/s barely moves. The EKF carries that correlation in P: 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

  1. 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.
  2. 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).
  3. WGS84 discrete-time error Jacobian. It stands in for the spherical-Earth continuous Pinson matrices; the structure is the same.
  4. Other fixes. GNSS and magnetometer-heading fixes are supported alongside the map.
  5. Roughening after resampling (roughening_factor = 0.0 removes it).
  6. 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.
  7. 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

TierWhat it modelsSchedulerFaultConfigs
Degradedthe fringe of a jammed areafixed_intervaldegradedconf/*_degraded.toml
Spoofa deceptive signaleitherslow_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

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

FileWhat it does
baseline.yamlNo degradation. The reference every other scenario is compared against.

Availability

FileWhat it does
sched_10s.yamlOne fix every 10 s, uncorrupted. A reduced update rate.
duty_10on_2off.yaml10 s available, 2 s denied, repeating. Brief periodic outages.
simple_dropout.yaml2 minutes available, 30 s denied. Bridges and tall buildings.
extended_gnss_denied.yaml1 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

FileWhat it does
degraded_fullrate.yamlFull rate, AR(1)-correlated position and velocity error.
degraded_5s.yamlEvery 5 s and degraded. Scheduling and corruption together.

Spoofing

FileWhat it does
slowbias.yamlA slowly drifting offset. Soft spoofing — each fix looks plausible.
slowbias_rot.yamlThe same, with the drift direction rotating.
hijack.yamlAn 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

FileWhat it does
combo.yamlReduced rate plus degraded accuracy.
combo_duty_hijack.yamlDuty-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:

  1. Build an engine from an initial state.
  2. For each IMU sample, predict.
  3. When a GNSS fix arrives, update_gnss.
  4. Read nav_solution whenever 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 in rust-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 GNSSdegraded GNSS (5 s)beats dead reckoning
UKFtest_ukf_closed_loop_on_real_datatest_ukf_with_degraded_gnsstest_ukf_outperforms_dead_reckoning
EKFtest_ekf_closed_loop_on_real_datatest_ekf_with_degraded_gnsstest_ekf_outperforms_dead_reckoning
ESKFtest_eskf_closed_loop_on_real_datatest_eskf_with_degraded_gnsstest_eskf_outperforms_dead_reckoning
RBPFtest_rbpf_closed_loop_on_real_datatest_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:

constantwhat bounds it
MAX_HORIZONTAL_RMSE_Mempirical, with headroom over the observed run
MAX_VERTICAL_RMSE_Mempirical
MAX_LEVEL_ATTITUDE_RMSE_RADderived -- gravity observability
MAX_YAW_RMSE_RADderived -- a multiple of MAG_YAW_SOURCE_RMSE_RAD, the magnetometer's own measured error
DEAD_RECKONING_BASELINE_SAMPLESderived -- see below
DEAD_RECKONING_BEAT_FACTORderived -- 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:

  1. Matches navigation results to GNSS measurements by timestamp
  2. Skips invalid GNSS data (NaN values)
  3. Computes error for each matched sample
  4. Filters out non-finite errors
  5. 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:

  1. Additional test scenarios -- done. core/tests/perf_baseline.rs covers GNSS outages (real_outage_60s, syn_outage_60s, both DutyCycle), measurement corruption (real_degraded, an AR(1) fault model) and a synthetic cruise profile alongside the real recording.

  2. More filters -- done. The EKF is implemented (core/src/kalman.rs) and has the same three-scenario integration suite as the others; the RBPF has test_rbpf_closed_loop_on_real_data and test_rbpf_with_degraded_gnss, and its own row in the performance baseline.

  3. Performance benchmarks: accuracy regression is done -- core/tests/perf_baseline.rs gates 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
  4. 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), and vendored (the macOS/Windows canary for the source builds of netCDF and HDF5)
  • deploy-book.yml: builds and deploys this documentation to GitHub Pages
  • publish.yml: verifies, then publishes the crates to crates.io. Takes a dry_run input
  • draft-pdf.yml: renders the JOSS paper, on the joss branch
  • copilot-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.

  1. 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.

  2. 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_loop pushed 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 the real_clean rows 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 PassThrough schedule these columns measure how completely a filter absorbs its own aiding.

    That absorption used to be total: real_clean__ukf read 0.014 m and real_clean__ekf 0.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 the syn_* scenarios and their exact independent truth.

  3. The synthetic scenarios now carry a real magnetic field, and the yaw column says which filters can use it. generate_synthetic evaluates 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 on syn_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__ukf used 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-3 for the UKF and the EKF against 1e-8 for the ESKF -- so the two filters were never handed the same prior. 1e-3 is 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__eskf moved the other way and for the same reason: its 1e-8 was 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__ukf yaw 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 __ekf rows 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__ukf yaw went 22.72 to 25.83 deg and real_degraded__ukf 22.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.

  4. The consistency columns mean different things on the two sources. On the synthetic scenarios npes lands 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.

  5. The baseline is blessed on one platform and gated on three. rust.yml runs 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's horizontal_cep95_m measured 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:

spreadscenario / metricmacOSLinuxWindows
10.79%real_rbpf_slice__rbpf/horizontal_cep50_m1.827051.827051.64916
8.61%real_rbpf_slice__rbpf/horizontal_cep95_m29.259729.259726.9394
4.70%syn_outage_60s__ukf/horizontal_cep50_m1.644221.625571.57045
2.87%syn_outage_60s__ukf/roll_rmse_deg0.272040.274750.26708
2.39%syn_outage_60s__ukf/yaw_rmse_deg0.223140.226870.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 / metricbeforeafter
syn_outage_60s__ukf/horizontal_cep50_m4.70%0%
syn_outage_60s__ukf/roll_rmse_deg2.87%0%
syn_outage_60s__ukf/yaw_rmse_deg2.39%0%
syn_outage_60s__ukf/attitude_geodesic_rmse_deg2.06%0%
syn_outage_60s__ukf/velocity_horizontal_rmse_mps1.76%0%
real_rbpf_slice__rbpf/horizontal_cep50_m10.79%10.79%
real_rbpf_slice__rbpf/horizontal_cep95_m8.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:

perturbationworst of 236 metrics
every Gaussian draw in the RBPF1.28e-6 %
every particle weight, after exp3.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:

estimatorworst response to one ulp
UKF9.77%
RBPF1.0e-6 %
dead reckoning1.7e-10 %
EKF3.8e-11 %
ESKF3.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

metricunitdefinitionideal
horizontal_rmse_mmRMS great-circle distance from truth0
horizontal_cep50_mmmedian radial error -- the empirical, non-parametric CEP, not the Rayleigh form. Nearest-rank, so it is a value some epoch actually had0
horizontal_cep95_mm95th percentile radial error, same convention0
horizontal_max_mmlargest single-sample radial error0
vertical_rmse_mmRMS altitude error0
vertical_bias_mmsigned mean altitude error -- catches a slow one-sided drift while it is still small0
velocity_horizontal_rmse_mpsm/sRMS north/east velocity error0
velocity_vertical_rmse_mpsm/sRMS vertical velocity error0
roll_rmse_deg, pitch_rmse_degdegRMS per-axis error, wrapped onto $[-\pi, \pi]$0
yaw_rmse_degdegRMS heading error, wrapped0
attitude_geodesic_rmse_degdegRMS rotation angle of $R_{est}^{-1} R_{true}$ -- the only attitude error independent of the Euler sequence0
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_horizontalfractionshare of latitude and longitude samples inside $\pm 3\sigma$0.9973
containment_3sigma_verticalfractionshare 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:

scenarionpesneesratio
every row with GNSS every epoch——1.00–1.01
real_outage_60s__eskf73.4383.041.13
syn_outage_60s__ukf6.546.821.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:

containmentnees_positiondiagnosis
~1.0far below 3over-conservative: the filter is right but does not believe it
below 0.99far above 3over-confident -- the dangerous one
~0.9973~3consistent

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.

iddataGNSSestimators
real_cleanrealevery epochUKF, EKF, ESKF
real_sparse_5srealevery 5 sUKF, ESKF
real_outage_60sreal120 s on, 60 s offESKF
real_degradedrealevery epoch, AR(1) position and velocity faultUKF, ESKF
syn_cruise_1hzsynevery 1 sUKF, EKF, ESKF
syn_outage_60ssyn60 s on, 60 s offUKF, ESKF
syn_dead_reckoningsyn, first 120 snoneunaided mechanization
real_rbpf_slicereal, first 1,200 samplesevery epochRBPF, 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

scenariosampleshoriz 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__ukf5,3665.1464.2078.64124.9181.418-0.0201.1300.516
real_clean__ekf5,3665.1514.2018.63625.3481.387-0.0251.1300.534
real_clean__eskf5,3665.1294.2068.61024.7221.409-0.0251.1070.526
real_sparse_5s__ukf5,3669.4686.05415.284127.0822.174-0.2142.8460.635
real_sparse_5s__eskf5,3669.4706.01115.179129.5502.216-0.2272.8380.657
real_outage_60s__eskf5,366257.3804.618645.7012,166.4001.834-0.23610.7500.604
real_degraded__ukf5,36650.70040.63688.053140.31113.8780.4382.4870.787
real_degraded__eskf5,36650.70240.63588.051140.31513.8640.4332.4690.794
syn_cruise_1hz__ukf15,0002.6002.2144.3546.7842.279-0.6850.5810.304
syn_cruise_1hz__ekf15,0002.6002.2134.3556.7852.279-0.6860.5810.304
syn_cruise_1hz__eskf15,0002.6002.1794.3976.7852.154-0.5490.5590.262
syn_outage_60s__ukf15,00024.0241.37948.123125.4282.045-0.0391.2900.284
syn_outage_60s__eskf15,00023.4441.40655.373112.7961.872-0.2291.3460.234
syn_dead_reckoning6,000316.860110.128715.856835.63486.591-64.2559.3631.896
real_rbpf_slice__rbpf1,2003.2350.5796.00427.6720.795-0.1311.2310.388

Attitude

scenariosamplesroll RMSE (deg)pitch RMSE (deg)yaw RMSE (deg)geodesic RMSE (deg)
real_clean__ukf5,3663.4212.85024.53924.925
real_clean__ekf5,3663.3342.90422.31322.730
real_clean__eskf5,3663.3112.97924.20424.592
real_sparse_5s__ukf5,3663.7893.15719.01319.645
real_sparse_5s__eskf5,3663.7633.22318.93119.569
real_outage_60s__eskf5,3663.5613.26421.70422.219
real_degraded__ukf5,3663.4973.00524.30024.725
real_degraded__eskf5,3663.4173.10724.16724.593
syn_cruise_1hz__ukf15,0000.2980.2510.1560.420
syn_cruise_1hz__ekf15,0000.2980.2510.1550.420
syn_cruise_1hz__eskf15,0000.2910.2540.1550.416
syn_outage_60s__ukf15,0000.2310.2060.1890.363
syn_outage_60s__eskf15,0000.2220.2490.2480.416
syn_dead_reckoning6,0000.4681.1060.4941.301
real_rbpf_slice__rbpf1,2003.0803.10721.84722.252

Consistency

scenariosamplesnees (ideal 3.0)npes, diagonal only3-sigma horiz (ideal 0.9973)3-sigma vert (ideal 0.9973)
real_clean__ukf5,36651.47351.3490.5140.836
real_clean__ekf5,36651.27151.1390.5150.839
real_clean__eskf5,36651.31051.1970.5150.836
real_sparse_5s__ukf5,36631.47431.0990.6310.853
real_sparse_5s__eskf5,36631.98031.4730.6340.858
real_outage_60s__eskf5,36642.92240.5950.6140.853
real_degraded__ukf5,3665,474.7005,473.8700.0740.093
real_degraded__eskf5,3665,474.3805,473.5800.0740.094
syn_cruise_1hz__ukf15,0004.7744.7761.0000.913
syn_cruise_1hz__ekf15,0004.7744.7761.0000.913
syn_cruise_1hz__eskf15,0004.5734.5751.0000.928
syn_outage_60s__ukf15,0002.3412.3410.9990.966
syn_outage_60s__eskf15,0002.2282.2290.9990.963
syn_dead_reckoning6,000--------
real_rbpf_slice__rbpf1,20018.12110.3270.9510.951

What the table is saying

  • The real_clean horizontal 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_1hz is 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, npes 1,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 written npes read 5.7e25 and the real nees larger 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) took npes to 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:

beforeafter
syn_cruise_1hz__eskf 3-sigma vertical0.8800.933
syn_cruise_1hz__eskf npes (ideal 3.0)5.6534.795
syn_outage_60s__eskf horizontal RMSE137.66 m20.22 m
syn_outage_60s__eskf pitch RMSE1.500 deg0.205 deg
syn_cruise_1hz__eskf vertical RMSE0.665 m0.836 m
syn_outage_60s__ukf horizontal RMSE221.67 m266.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 TestDataRecord and NavigationResult
  • 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:

JOSS

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:

  1. 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.
  2. 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:

  1. Check the User Guide
  2. Review the API Reference
  3. Open an issue on GitHub

Publications

External Links

Glossary