This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Strapdown-rs is an open-source Rust implementation of strapdown inertial navigation system (INS) algorithms. This repository implements the Free Core version of the product, providing researchers, students, and developers with a lightweight but reproducible simulation tool for GNSS-denied scenarios. The architecture is designed with extensibility in mind to support future commercial professional versions.
Product Philosophy:
- Free Core (this repo): Open-source academic/research tool focused on reproducible simulations, basic sensor models, and GNSS degradation scenarios
- Professional/Enterprise (future): Commercial versions with advanced GNSS modeling, synthetic data generation, GUIs, and hardware integration
- Experimental features: Geophysical navigation capabilities (gravity/magnetic anomaly aiding) remain in this repo as experimental features for research purposes
This is a Cargo workspace with three main crates:
The core library implementing strapdown INS algorithms and simulation framework:
- lib.rs: Library entry point and 9-state strapdown mechanization in local-level frame (NED). Implements forward propagation equations from Groves textbook (Chapter 5.4-5.5)
- earth.rs: WGS84 Earth ellipsoid model and geodetic calculations
- kalman.rs: Kalman-style navigation filters including Unscented Kalman Filter (UKF) for nonlinear state estimation
- particle.rs: Particle-filter building blocks (the
Particletrait, resampling and averaging strategies); not a filter on its own - rbpf.rs: The Rao-Blackwellized particle filter, after Canciani & Raquet (2017) -- the one concrete particle filter
- measurements.rs: Measurement models (GPS position/velocity, barometric altitude, pseudorange, carrier phase) implementing the
MeasurementModeltrait - messages.rs: Event stream handling for GNSS scheduling and fault injection scenarios
- sim.rs: Simulation utilities, CSV data loading (Sensor Logger format), dead reckoning and closed-loop functions
- linalg.rs: Linear algebra utilities for matrix operations
Key state representation: 9-state vector [lat, lon, alt, v_n, v_e, v_d, roll, pitch, yaw] in NED frame
Design principles for Free Core:
- Deterministic simulations with reproducible results (seeded RNG)
- Configuration-driven (YAML/JSON configs for scenarios)
- Extensible architecture for future professional features
- Clean separation between core algorithms and simulation framework
Command-line tool for running INS simulations with GNSS degradation:
- Modes:
dr(dead reckoning),cl(closed loop -- ESKF by default, UKF and EKF by--filter),pf(particle filter),syn(synthetic trajectory).olexists but is not implemented: it writes no output - GNSS fault simulation: dropouts, reduced update rates, measurement corruption, bias injection
- Input: CSV files with IMU and GNSS measurements (Sensor Logger format)
- Output: the CLI writes CSV only. Every path through
strapdown-simends inNavigationResult::to_csv, andOUTPUT_FILE_EXTENSIONSinsim/src/common.rsis["csv"], so any other extension is rejected rather than filled with CSV.to_hdf5,to_netcdfandto_mcapexist as library writers onNavigationResult, reachable from Rust but not from this binary. There is no Parquet writer at all - Configuration: TOML/YAML/JSON scenario files or command-line arguments
- Built-in logging: Use
--log-leveland--log-fileflags (see LOGGING.md for details)
Free Core scope: Basic GNSS degradation (outages, noise, reduced availability) Future Pro scope: Advanced faults (spoofing, jamming, multipath, terrain masking)
Experimental geophysical navigation module (research-grade):
- Loads NetCDF geophysical maps (gravity/magnetic anomaly grids)
- Integrates geophysical measurements with INS/GNSS filters
- Provides alternative PNT in GNSS-denied environments
- Built-in logging: Use
--log-leveland--log-fileflags (see LOGGING.md for details) - Status: Experimental feature for research, may be commercialized in future roadmap
Post-processing and experiment tooling, exposed as the analyze CLI:
- preprocess.py: rebuilds
data/inputfrom the Sensor Logger exports indata/raw-- resampling, splitting recordings at IMU dropouts, and downloading the_gravity.nc/_magnetic.ncmaps beside each trajectory.just preprocessruns it at 10 Hz, which gives 10 Hz inertial propagation against the ~1 Hz the GNSS was actually recorded at (the GNSS columns stay NaN in nine rows out of ten; do not interpolate them up).data/inputis the one directory every consumer reads -- all 21conf/*.toml,geo-stats,postprocessandgeoperf-*-- so change the rate, not the path.just preprocess-1hzis the 1 Hz variant, which is the rate every result before the geophysical fixes used - geostats.py: characterises the geophysical measurements against those maps -- per-field
bias, measurement noise and signal-to-noise, plus the de-correlation length that sets
geo_interval_s. Its anomaly models mirrorcore/src/earth.rsandgeonav/src/lib.rsterm for term, andself_check()asserts they still agree; a statistic computed from a differently computed anomaly would describe a quantity the filter never sees.--apply-to conf(just geo-adopt) writes the measured bias, noise and bias prior into all 18 geophysical configs, line by line so the comments survive, rewriting only keys already present so a*_grav.tomlstays gravity-only.geo_frequency_sis held back behind--apply-interval: the noise figures are measurements, but moving the interval from 1 s to one measurement per de-correlation length changes what the experiment asks of the aid.core/tests/example_configs.rsasserts all 18 carry the same values, so a partial adoption fails the build rather than producing a comparison across two different R - compare.py / plotting.py: error statistics, LaTeX tables and map figures
Run it with uv run analyze <subcommand> from the repository root.
# Build entire workspace in release mode
cargo build --workspace --release
# Run all tests
cargo test --workspace
# Run tests for specific crate
cargo test --package strapdown-core
cargo test --package strapdown-sim
# Run specific test
cargo test --package strapdown-core test_name
# Run with code coverage (needs `cargo install cargo-tarpaulin` first)
cargo coverage
# Or: cargo tarpaulin --workspace --timeout 600# Lint the wide configuration (warnings are errors)
cargo lint
# ...and the narrow one. CI lints BOTH, and `cargo lint` alone is not CI parity: a call site
# that exists only under --all-features can orphan an import that --no-default-features then
# reports as dead. Run both before pushing.
cargo lint-min
cargo test-min
# Apply the clippy fixes it can
cargo lint-fix
# Format code
cargo fmt --all
# Check formatting without writing (CI parity)
cargo fmt-check# Dead reckoning. The subcommand comes first: `-i` before it is rejected.
# `dr` is the dead-reckoning command. `ol` is a separate, **unimplemented** subcommand:
# `run_open_loop` validates its paths, writes nothing and prints "Open-loop mode is not yet
# fully implemented". Do not reach for `ol` expecting a result.
./target/release/strapdown-sim dr -i input.csv -o output.csv
# Closed-loop with a duty-cycled GNSS outage and AR(1) degradation.
# `--sched` is passthrough|fixed|duty and `--fault` is none|degraded|slowbias|hijack.
./target/release/strapdown-sim cl -i input.csv -o output.csv \
--seed 42 \
--sched duty --on-s 100.0 --off-s 50.0 \
--fault degraded --sigma-pos-m 10.0
# Geophysical navigation (the geonav-sim binary was folded into strapdown-sim;
# build with `cargo build --release -p strapdown-sim --features geonav`)
./target/release/strapdown-sim cl -i data/input/input.csv -o output.csv \
--geo --gravity-resolution one-minuteSample datasets are no longer vendored in this repository. Test fixtures live in
core/tests/; generate your own trajectories with the syn subcommand:
./target/release/strapdown-sim syn -o synthetic.csv --duration-s 600 --seed 42The Free Core implementation must achieve the following capabilities:
-
Reproducible Simulations:
- Researchers can configure scenarios (input data, filter config, GNSS settings) and get identical results with same random seed
- Particle filter results are statistically consistent across runs
- Deterministic behavior for scientific reproducibility
-
Dataset Support:
- Import third-party datasets (KITTI, nuScenes, Carla, MEMS-Nav, etc.) via converter tools
- Pre-processed hosted datasets that work out-of-the-box
- CLI/interactive tools for format conversion
-
GNSS Degradation Modeling (Basic):
- Complete outages (dropouts with configurable start/duration)
- Increased noise levels
- Reduced satellite availability
- Measurement corruption and bias injection
-
Configuration-Driven:
- TOML/YAML/JSON scenario files describing: trajectory/data path, sensor parameters, noise models, degradation events, filter configuration, output format
- Command-line interface with config file support and argument overrides
-
Output Formats:
- CSV, HDF5, NetCDF and MCAP export for analysis in Python/MATLAB/R. These are
NavigationResultmethods, not CLI output modes:strapdown-simwrites CSV and rejects every other extension, so reaching the other three means callingto_hdf5,to_netcdforto_mcapfrom Rust - Parquet is deliberately not supported at either layer:
sim/src/common.rsrefuses.parquetrather than naming a file Parquet and filling it with CSV - Navigation solution time series with position, velocity, attitude estimates
- CSV, HDF5, NetCDF and MCAP export for analysis in Python/MATLAB/R. These are
-
Python Integration:
- Python bindings for core functionality (future work)
- Enables integration with ML/AI workflows and analysis pipelines
- Naming conventions:
- Variables and functions:
snake_case - Types (structs, enums, traits):
CamelCase - Constants:
SCREAMING_SNAKE_CASE - Names should be descriptive (avoid abbreviations and mathematical symbols)
- Variables and functions:
- Function design:
- Keep functions focused on a single task
- Break up long functions (>25 statements) into smaller private helper functions
- Each function should have a clear purpose reflected in its name
- Documentation:
- Use Rust doc comments extensively (
///and//!) - Include examples in documentation
- Reference Groves textbook equations by section/equation number where applicable
- Use Rust doc comments extensively (
- Testing:
- Write unit tests for each module
- Include integration tests for full system behavior
- Test edge cases and error handling
- Use
assert_approx_eqfor floating-point comparisons
- Primary reference: "Principles of GNSS, Inertial, and Multisensor Integrated Navigation Systems, 2nd Edition" by Paul D. Groves
- Equations reference Groves by section/equation number
- Variables named by quantity (not mathematical symbols) for clarity
- Navigation frame: local-level frame (latitude, longitude, altitude)
- Default convention is North-East-Down (NED), matching Groves; ENU is an explicit opt-in
- Users control via
is_enuboolean flags and sign conventions;StrapdownState::to_nedandto_enuconvert an existing state between the two - Vertical velocity: positive down in NED, positive up in ENU
altitudeis height above the ellipsoid -- positive up -- in both frames, valid over [-11,000m, 30,000m]. It is not a "down" coordinate in NEDTestDataRecordcarries no frame tag, so every entry point that loads one takes the frame from its caller:sim::dead_reckoning(records, is_enu)andis_enuonUkfConfig/EkfConfig/EskfConfig, all defaulting to NED. On the CLI that is--enu(andis_enuin a config file); Sensor Logger exports need it,synoutput does not- Declaring the wrong frame is rejected, not guessed at:
sim::check_declared_framerotates the leading records' specific force into the navigation frame and requires its sign to match (+g on ENU up, -g on NED down), returningStrapdownError::InvalidConfigurationnaming the flag to pass. Mechanizing NED records as ENU adds the gravity model instead of cancelling it and falls at 2 g
- Attitude representation: Direction cosine matrices (DCM), Euler angles (XYZ rotation)
- Position: WGS84 geodetic (lat/lon in degrees, altitude in meters)
- Velocities: Local-level frame (m/s)
- IMU data: Specific force (m/s²) and angular rate (rad/s) in body frame, NOT preprocessed
- Raw IMU output includes gravitational acceleration
- Strapdown equations handle gravity removal during propagation
-
Common interface: All filters implement the
NavigationFiltertrait defined inkalman.rs- Required methods:
predict()andupdate() - Unified state representation via
StrapdownState
- Required methods:
-
Forward propagation: Uses
StrapdownState::propagate()with strapdown equations (Chapter 5.4-5.5) -
Measurement models: Implement the
MeasurementModeltrait for update step- Trait provides
predict_measurement()andinnovation_covariance()methods - Implemented models: GPS position, GPS velocity, barometric altitude, pseudorange, carrier phase
- Trait provides
-
UKF implementation:
- Uses unscented transform with sigma points for nonlinear state estimation
- Handles full 9-state navigation solution
-
Particle filter implementation (
rbpf.rs): Canciani & Raquet's marginalized particle filter (IEEE TAES 53(1), 2017), error-state and closed-loop- Particles sample horizontal position error only (
δlat, δlon); the paper's linear states -- altitude, velocity, nav-frame tilt, the barometer-aiding errorδh_a, the barometer loop's vertical-acceleration errorδâ, and each map bias asV(Gauss-Markov) +c(constant) -- are one Kalman filter whose covariance all particles share. No IMU bias states: adding them made the RBPF diverge on degraded-GNSS runs - Barometer aiding is a third-order loop in the mechanization; barometer readings feed the loop and are not measurement updates
- Time update once per measurement epoch (transition and noise accumulate between); one
measurement update for every other sensor (
C = H T, weights underC P Cᵀ + R) -
horizontal_process_noise_std_m:RbpfConfig(the library) defaults to the paper's zero (eq. 19), which diverges with GNSS-rate fixes on MEMS data.strapdown-simand its[particle_filter]section default to 1 m/√s (sim::DEFAULT_PF_HORIZONTAL_PROCESS_NOISE_STD_M), as theconf/recipes set - Reports the Kalman filters' layout,
[9 nav, b_a, b_g, map biases], with zero bias rows;core/src/rbpf.rs's module docs list the departures from the paper
- Particles sample horizontal position error only (
-
Process noise:
sim::DEFAULT_PROCESS_NOISE_DENSITYis a spectral density -- a variance per second. Each filter forms$Q_k = q,\Delta t$ ; it was a per-step variance with nodtanywhere until #374, which made effective Q a function of sample rate (a 50x spread across the 1 Hz, 10 Hz and 50 Hz data this repo uses) - Design for extensibility: Core algorithms separate from simulation framework to enable future professional features
Input CSV must contain timestamped sensor measurements:
- Timestamps: ISO UTC format with timezone
- IMU: acc_x/y/z (m/s²), gyro_x/y/z (rad/s), mag_x/y/z (µT)
- GNSS: latitude, longitude, altitude, speed, bearing
- Orientation: roll, pitch, yaw (degrees) or quaternions
Current dataset format: Sensor Logger app format
Future dataset support (via converters): KITTI, nuScenes, Carla, MEMS-Nav
- Extensive unit tests across core modules (use
cargo test --workspaceto run all) - Tests use
assert_approx_eqmacro for floating-point comparisons - Integration tests in
core/tests/integration_tests.rsverify full simulation pipeline - Deterministic tests verify reproducibility with seeded RNG
- Coverage reports generated with
cargo tarpaulin
Plain cargo, no environment manager. Pixi was removed in #335; git clone && cargo build
is the whole setup.
rust-toolchain.tomlpins the toolchain (1.91) and fetches it on the first cargo command..cargo/config.tomlcarries the env vars and thelint/lint-fix/fmt-check/docs/coveragealiases thatpixi.toml's[tasks]used to- Beyond Rust: a C/C++ compiler and cmake >= 3.26, needed only for features that touch a C
library. libhdf5, libnetcdf, zlib and freetype are compiled from vendored crates.io sources,
so no system library is searched for. Ubuntu 22.04 (cmake 3.22) and Debian 12 (3.25) are too
old.
cargo build -p strapdown-corealone needs no C toolchain at all HDF5_DIRmust stay unset --hdf5-metno-sysprefers a system library whenever it is set, even withstaticon, and the vendored netCDF build then fails against those headers- libfontconfig is a runtime dependency of
strapdown-sim'splottingfeature (dlopened), not a build-time one - The repository is a two-language monorepo: three Rust crates in a Cargo workspace, and
the Python
analysispackage in a uv workspace declared by the rootpyproject.toml. The two share the directory and nothing else --analysis/is not a Cargo member and the crates are not uv members.analysis/was untracked betweenc5f72c6and the monorepo change; the notebooks underexamples/are still untracked - Python setup is
uv syncfrom the repository root. That builds theanalysismember and puts itsanalyzeCLI onuv run, which is why the justfile recipes sayuv run analyze ...and notuv run --project analysis ... - pygmt is imported inside the functions that use it, never at module scope. It
dlopens the GMT C library on import, so a module-scope import makes importinganalysisat all fail wherever GMT is absent -- which took down every subcommand, including the ones that touch no maps, and would take down CI..github/workflows/python.ymldeliberately installs no GMT, so it is what keeps this true
Python. uv run ruff check, uv run ruff format --check and uv run pytest -q from the
repository root, which is what .github/workflows/python.yml runs and what just check-python
wraps. Configuration is [tool.ruff] in the root pyproject.toml; target-version there and
requires-python in analysis/pyproject.toml must agree.
Rust. Lints are enforced at deny, workspace-wide, not warn-level. cargo lint and one
of the blocking CI jobs run cargo clippy --workspace --all-targets --all-features -- -D warnings.
That is only half the gate. A second blocking job runs
cargo clippy -p strapdown-core --all-targets --no-default-features -- -D warnings, and the
two configurations disagree: an item used only behind a feature gate is live in one and dead
in the other. cargo lint-min is that second configuration, and cargo test-min its test
counterpart; run both alongside cargo lint before pushing.
clippy::pedantic,clippy::nursery,missing_docs,missing_debug_implementations,unreachable_pub,rust_2018_idioms- Zero-panic policy in library code:
unwrap_used,expect_used,panicare denied. Return aStrapdownError(core/src/error.rs). Tests are exempt viaclippy.toml - Every
pubitem -- including struct fields and enum variants -- needs a doc comment, and a fallible function needs an# Errorssection - Relax a lint once in
[workspace.lints.clippy]with a reason, never with a scattered call-site#[allow] - The 1.91 pin lives in five places that must agree:
rust-toolchain.toml,rust-versioninCargo.toml,msrvinclippy.toml, and thedtolnay/rust-toolchain@1.91pins inrust.ymlandpublish.yml-- becausepedantic/nurserygain lints every release
See AGENTS.md for the full policy and the reasoning behind each existing exception.
When developing Free Core features, consider extensibility for future professional products:
Pro features (out of scope for this repo):
- Synthetic data generation (vehicle dynamics, waypoint following, realistic sensor models)
- Advanced GNSS modeling (satellites, pseudoranges, DOP, spoofing, jamming, multipath, terrain masking)
- Batch runner for parameter sweeps
- GUI (Bevy-based scenario editor, 3D visualization)
- Professional reports (PDF/HTML with plots and metrics)
- Advanced data adapters (ROS bag, RINEX, standard nav formats)
Design principles for extensibility:
- Trait-based abstractions for sensors, filters, and measurement models
- Configuration schema that can be extended without breaking changes
- Clean separation between algorithms (strapdown-core) and application logic (strapdown-sim)
- Plugin architecture for adding new sensors/measurement models in pro versions
- Main branch for PRs:
main - Create feature branches for development
- Current focus: Implementing Free Core specification features for reproducible research simulations
- When asked to execute on an issue or new high-level feature request, ask the user if this should be completed on the current branch or to create a new branch
ghmust be 2.71 or newer. Older clients request theprojectCardsfield GitHub removed with Projects (classic), sogh pr view/gh issue viewexit 1 andgh pr editexits 1 while leaving the body unchanged.gh apiand the--jsonforms are unaffected. No Debian or Ubuntu package is new enough -- install from cli.github.com. See #365