use std::env;
use std::error::Error;
use std::f64::consts::{FRAC_PI_2, TAU};
use std::fs::{self, File};
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
fn gravity() -> f64 {
let (a, f, e2) = (6_378_137.0, 1.0 / 298.257_223_563, 6.694_379_990_141e-3);
let (gamma_e, k, m) = (9.780_325_335_9, 1.931_852_652_458e-3, 3.449_786_506_841e-3);
let sin2 = SITE[0].to_radians().sin().powi(2);
let h = SITE[2];
let surface = gamma_e * (1.0 + k * sin2) / (1.0 - e2 * sin2).sqrt();
surface * (1.0 - 2.0 / a * (1.0 + f + m - 2.0 * f * sin2) * h + 3.0 / (a * a) * h * h)
}
const SITE: [f64; 3] = [40.1164, -88.3697, 200.0];
const DECLINATION: f64 = -0.06;
const INCLINATION: f64 = 1.107;
const FIELD: f64 = 0.49;
const OPENING: f64 = 2.0;
struct SplitMix64(u64);
impl SplitMix64 {
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
((self.next_u64() >> 11) as f64 + 1.0) / 9_007_199_254_740_992.0
}
}
struct Noise {
rng: SplitMix64,
spare: Option<f64>,
}
impl Noise {
fn new(seed: u64, channel: u64) -> Self {
Self {
rng: SplitMix64(seed ^ channel.wrapping_mul(0x9E37_79B9_7F4A_7C15)),
spare: None,
}
}
fn sample(&mut self) -> f64 {
if let Some(spare) = self.spare.take() {
return spare;
}
let radius = (-2.0 * self.rng.next_f64().ln()).sqrt();
let angle = TAU * self.rng.next_f64();
self.spare = Some(radius * angle.sin());
radius * angle.cos()
}
fn vector(&mut self, sigma: f64) -> [f64; 3] {
self.vector_with([sigma; 3])
}
fn vector_with(&mut self, sigma: [f64; 3]) -> [f64; 3] {
[
sigma[0] * self.sample(),
sigma[1] * self.sample(),
sigma[2] * self.sample(),
]
}
}
mod channel {
pub const GYRO: u64 = 1;
pub const ACCEL: u64 = 2;
pub const GYRO_WALK: u64 = 3;
pub const ACCEL_WALK: u64 = 4;
pub const GNSS_POSITION: u64 = 5;
pub const GNSS_VELOCITY: u64 = 6;
pub const BARO: u64 = 7;
pub const MAG: u64 = 8;
pub const GNSS_HEADING: u64 = 9;
}
#[derive(Clone, Copy)]
struct Window {
start: f64,
end: f64,
}
impl Window {
const ALWAYS: Self = Self {
start: 0.0,
end: f64::INFINITY,
};
const NEVER: Self = Self {
start: f64::INFINITY,
end: f64::INFINITY,
};
fn contains(&self, t: f64) -> bool {
t >= self.start && t < self.end
}
}
#[derive(Clone, Copy)]
struct Wave {
offset: f64,
rate: f64,
amplitude: f64,
period: f64,
phase: f64,
}
const STILL: Wave = Wave {
offset: 0.0,
rate: 0.0,
amplitude: 0.0,
period: 1.0,
phase: 0.0,
};
impl Wave {
fn motion(&self, u: f64) -> (f64, f64, f64) {
let omega = TAU / self.period;
let angle = omega * u + self.phase;
(
self.rate * u + self.amplitude * angle.sin(),
self.rate + self.amplitude * omega * angle.cos(),
-self.amplitude * omega * omega * angle.sin(),
)
}
}
#[derive(Clone, Copy)]
struct Trajectory {
hold: f64,
ramp: f64,
north: Wave,
east: Wave,
down: Wave,
roll: Wave,
pitch: Wave,
yaw: Wave,
sideslip: Option<Wave>,
}
struct Truth {
position: [f64; 3],
velocity: [f64; 3],
acceleration: [f64; 3],
euler: [f64; 3],
euler_rate: [f64; 3],
}
impl Trajectory {
fn at(&self, t: f64) -> Truth {
let u = t - self.hold;
let (tau, dtau, ddtau) = self.release(u);
let axis = |wave: &Wave| {
let (g, dg, ddg) = wave.motion(tau);
(wave.offset + g, dg * dtau, ddg * dtau * dtau + dg * ddtau)
};
let (pos_n, vel_n, acc_n) = axis(&self.north);
let (pos_e, vel_e, acc_e) = axis(&self.east);
let (pos_d, vel_d, acc_d) = axis(&self.down);
let (roll, roll_rate, _) = axis(&self.roll);
let (pitch, pitch_rate, _) = axis(&self.pitch);
let (yaw, yaw_rate) = match &self.sideslip {
None => {
let (yaw, yaw_rate, _) = axis(&self.yaw);
(yaw, yaw_rate)
}
Some(sideslip) => {
let (_, n1, n2) = self.north.motion(tau);
let (_, e1, e2) = self.east.motion(tau);
let course = e1.atan2(n1);
let course_rate = (n1 * e2 - e1 * n2) / (n1 * n1 + e1 * e1) * dtau;
let (beta, beta_rate, _) = axis(sideslip);
(course + beta, course_rate + beta_rate)
}
};
Truth {
position: [pos_n, pos_e, pos_d],
velocity: [vel_n, vel_e, vel_d],
acceleration: [acc_n, acc_e, acc_d],
euler: [roll, pitch, yaw],
euler_rate: [roll_rate, pitch_rate, yaw_rate],
}
}
fn release(&self, u: f64) -> (f64, f64, f64) {
if u <= 0.0 {
return (0.0, 0.0, 0.0);
}
if self.ramp <= 0.0 {
return (u, 1.0, 0.0);
}
if u >= self.ramp {
return (u - 0.5 * self.ramp, 1.0, 0.0);
}
let x = u / self.ramp;
(
self.ramp * x * x * x * x * (2.5 + x * (-3.0 + x)),
x * x * x * (10.0 + x * (-15.0 + 6.0 * x)),
30.0 * x * x * (1.0 - x) * (1.0 - x) / self.ramp,
)
}
}
fn body_to_nav(euler: [f64; 3]) -> [[f64; 3]; 3] {
let (sr, cr) = euler[0].sin_cos();
let (sp, cp) = euler[1].sin_cos();
let (sy, cy) = euler[2].sin_cos();
[
[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
[sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
[-sp, cp * sr, cp * cr],
]
}
fn resolve_in_body(c: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[
c[0][0] * v[0] + c[1][0] * v[1] + c[2][0] * v[2],
c[0][1] * v[0] + c[1][1] * v[1] + c[2][1] * v[2],
c[0][2] * v[0] + c[1][2] * v[1] + c[2][2] * v[2],
]
}
fn specific_force(truth: &Truth) -> [f64; 3] {
let a = truth.acceleration;
resolve_in_body(body_to_nav(truth.euler), [a[0], a[1], a[2] - gravity()])
}
fn body_rate(truth: &Truth) -> [f64; 3] {
let [roll, pitch, _] = truth.euler;
let [roll_rate, pitch_rate, yaw_rate] = truth.euler_rate;
let (sr, cr) = roll.sin_cos();
let (sp, cp) = pitch.sin_cos();
[
roll_rate - yaw_rate * sp,
pitch_rate * cr + yaw_rate * cp * sr,
-pitch_rate * sr + yaw_rate * cp * cr,
]
}
fn magnetic_field(disturbance: f64) -> [f64; 3] {
let (sd, cd) = (DECLINATION + disturbance).sin_cos();
let (si, ci) = INCLINATION.sin_cos();
[FIELD * ci * cd, FIELD * ci * sd, FIELD * si]
}
#[derive(Clone, Copy)]
struct ImuErrors {
gyro_white: f64,
accel_white: f64,
gyro_walk: f64,
accel_walk: f64,
gyro_bias: [f64; 3],
accel_bias: [f64; 3],
}
const IMU: ImuErrors = ImuErrors {
gyro_white: 2.6e-4,
accel_white: 2.0e-3,
gyro_walk: 1.0e-5,
accel_walk: 1.0e-4,
gyro_bias: [0.002_1, -0.003_4, 0.001_2],
accel_bias: [0.043, -0.062, 0.027],
};
const HARSH_IMU: ImuErrors = ImuErrors {
gyro_white: 2.6e-3,
accel_white: 2.0e-2,
gyro_walk: 2.0e-4,
accel_walk: 2.0e-3,
gyro_bias: [0.006_3, -0.010_2, 0.003_6],
accel_bias: [0.129, -0.186, 0.081],
};
struct ImuReading {
gyro: [f64; 3],
accel: [f64; 3],
gyro_bias: [f64; 3],
accel_bias: [f64; 3],
}
struct Imu {
sigma_gyro: f64,
sigma_accel: f64,
step_gyro: f64,
step_accel: f64,
gyro_bias: [f64; 3],
accel_bias: [f64; 3],
white_gyro: Noise,
white_accel: Noise,
walk_gyro: Noise,
walk_accel: Noise,
}
impl Imu {
fn new(errors: ImuErrors, seed: u64, dt: f64) -> Self {
Self {
sigma_gyro: errors.gyro_white / dt.sqrt(),
sigma_accel: errors.accel_white / dt.sqrt(),
step_gyro: errors.gyro_walk * dt.sqrt(),
step_accel: errors.accel_walk * dt.sqrt(),
gyro_bias: errors.gyro_bias,
accel_bias: errors.accel_bias,
white_gyro: Noise::new(seed, channel::GYRO),
white_accel: Noise::new(seed, channel::ACCEL),
walk_gyro: Noise::new(seed, channel::GYRO_WALK),
walk_accel: Noise::new(seed, channel::ACCEL_WALK),
}
}
fn sample(&mut self, truth: &Truth) -> ImuReading {
let reading = ImuReading {
gyro: add(
add(body_rate(truth), self.gyro_bias),
self.white_gyro.vector(self.sigma_gyro),
),
accel: add(
add(specific_force(truth), self.accel_bias),
self.white_accel.vector(self.sigma_accel),
),
gyro_bias: self.gyro_bias,
accel_bias: self.accel_bias,
};
self.gyro_bias = add(self.gyro_bias, self.walk_gyro.vector(self.step_gyro));
self.accel_bias = add(self.accel_bias, self.walk_accel.vector(self.step_accel));
reading
}
}
#[derive(Clone, Copy)]
struct GnssErrors {
period: f64,
sigma_horizontal: f64,
sigma_vertical: f64,
sigma_velocity: f64,
tau_horizontal: f64,
tau_vertical: f64,
tau_velocity: f64,
latency: f64,
available: Window,
outage: Option<Window>,
sigma_heading: Option<f64>,
antenna: [f64; 3],
}
const GNSS: GnssErrors = GnssErrors {
period: 0.2,
sigma_horizontal: 0.9,
sigma_vertical: 1.8,
sigma_velocity: 0.15,
tau_horizontal: 0.0,
tau_vertical: 0.0,
tau_velocity: 0.0,
latency: 0.0,
available: Window::ALWAYS,
outage: None,
sigma_heading: None,
antenna: [0.0; 3],
};
struct Fix {
taken: f64,
position: [f64; 3],
velocity: [f64; 3],
position_variance: [f64; 3],
velocity_variance: [f64; 3],
heading: Option<(f64, f64)>,
}
struct GaussMarkov<const N: usize> {
phi: [f64; N],
last: Option<[f64; N]>,
}
impl<const N: usize> GaussMarkov<N> {
fn new(period: f64, tau: [f64; N]) -> Self {
Self {
phi: tau.map(|tau| {
if tau > 0.0 {
(-period / tau).exp()
} else {
0.0
}
}),
last: None,
}
}
fn next(&mut self, white: [f64; N]) -> [f64; N] {
let error = match self.last {
None => white,
Some(last) => std::array::from_fn(|i| {
let phi = self.phi[i];
phi * last[i] + (1.0 - phi * phi).sqrt() * white[i]
}),
};
self.last = Some(error);
error
}
}
struct Gnss {
errors: GnssErrors,
position: Noise,
velocity: Noise,
position_error: GaussMarkov<3>,
velocity_error: GaussMarkov<3>,
heading: Noise,
}
impl Gnss {
fn new(errors: GnssErrors, seed: u64) -> Self {
Self {
errors,
position: Noise::new(seed, channel::GNSS_POSITION),
velocity: Noise::new(seed, channel::GNSS_VELOCITY),
position_error: GaussMarkov::new(
errors.period,
[
errors.tau_horizontal,
errors.tau_horizontal,
errors.tau_vertical,
],
),
velocity_error: GaussMarkov::new(errors.period, [errors.tau_velocity; 3]),
heading: Noise::new(seed, channel::GNSS_HEADING),
}
}
fn fix(&mut self, t: f64, flight: &Trajectory) -> Option<Fix> {
let sigma = [
self.errors.sigma_horizontal,
self.errors.sigma_horizontal,
self.errors.sigma_vertical,
];
let white = self.position.vector_with(sigma);
let position_noise = self.position_error.next(white);
let velocity_noise = self
.velocity_error
.next(self.velocity.vector(self.errors.sigma_velocity));
let heading_noise = self
.errors
.sigma_heading
.map(|sigma| (self.heading.vector(sigma)[0], sigma));
if !self.errors.available.contains(t) {
return None;
}
if self.errors.outage.is_some_and(|outage| outage.contains(t)) {
return None;
}
let described = t - self.errors.latency;
if described < 0.0 {
return None;
}
let was = flight.at(described);
let c = body_to_nav(was.euler);
let r = self.errors.antenna;
let [w0, w1, w2] = body_rate(&was);
let swept = [
w1 * r[2] - w2 * r[1],
w2 * r[0] - w0 * r[2],
w0 * r[1] - w1 * r[0],
];
let navigation =
|v: [f64; 3]| std::array::from_fn(|i| c[i][0] * v[0] + c[i][1] * v[1] + c[i][2] * v[2]);
let antenna = add(was.position, navigation(r));
let moving = add(was.velocity, navigation(swept));
Some(Fix {
taken: described,
position: add(antenna, position_noise),
velocity: add(moving, velocity_noise),
position_variance: sigma.map(|sigma| sigma * sigma),
velocity_variance: [self.errors.sigma_velocity.powi(2); 3],
heading: heading_noise.map(|(noise, sigma)| {
let heading = was.euler[2] + noise;
(heading.sin().atan2(heading.cos()), sigma * sigma)
}),
})
}
}
#[derive(Clone, Copy)]
struct BaroErrors {
period: f64,
sigma: f64,
drift: f64,
reference: f64,
tau: f64,
available: Window,
}
const BARO: BaroErrors = BaroErrors {
period: 0.05,
sigma: 0.35,
drift: 0.0,
reference: 112.0,
tau: 0.0,
available: Window::ALWAYS,
};
struct Altitude {
meters: f64,
variance: f64,
}
struct Baro {
errors: BaroErrors,
noise: Noise,
error: GaussMarkov<1>,
}
impl Baro {
fn new(errors: BaroErrors, seed: u64) -> Self {
Self {
errors,
noise: Noise::new(seed, channel::BARO),
error: GaussMarkov::new(errors.period, [errors.tau]),
}
}
fn sample(&mut self, t: f64, truth: &Truth) -> Option<Altitude> {
let [noise] = self.error.next([self.errors.sigma * self.noise.sample()]);
if !self.errors.available.contains(t) {
return None;
}
Some(Altitude {
meters: self.errors.reference + self.errors.drift * t - truth.position[2] + noise,
variance: self.errors.sigma.powi(2),
})
}
}
#[derive(Clone, Copy)]
struct Disturbance {
window: Window,
rotation: f64,
}
#[derive(Clone, Copy)]
struct MagErrors {
period: f64,
sigma_field: f64,
tau: f64,
available: Window,
disturbance: Option<Disturbance>,
}
const MAG: MagErrors = MagErrors {
period: 0.05,
sigma_field: 0.012,
tau: 0.0,
available: Window::ALWAYS,
disturbance: None,
};
struct Field {
body: [f64; 3],
heading_variance: f64,
}
struct Mag {
errors: MagErrors,
noise: Noise,
error: GaussMarkov<3>,
}
impl Mag {
fn new(errors: MagErrors, seed: u64) -> Self {
Self {
errors,
noise: Noise::new(seed, channel::MAG),
error: GaussMarkov::new(errors.period, [errors.tau; 3]),
}
}
fn sample(&mut self, t: f64, truth: &Truth) -> Option<Field> {
let noise = self.error.next(self.noise.vector(self.errors.sigma_field));
if !self.errors.available.contains(t) {
return None;
}
let turned = self
.errors
.disturbance
.filter(|d| d.window.contains(t))
.map_or(0.0, |d| d.rotation);
Some(Field {
body: add(
resolve_in_body(body_to_nav(truth.euler), magnetic_field(turned)),
noise,
),
heading_variance: self.heading_variance(),
})
}
fn heading_variance(&self) -> f64 {
let horizontal = FIELD * INCLINATION.cos();
(self.errors.sigma_field / horizontal).powi(2)
}
}
struct Scenario {
name: &'static str,
covers: &'static str,
seed: u64,
duration: f64,
imu_rate: f64,
trajectory: Trajectory,
imu: ImuErrors,
gnss: GnssErrors,
baro: BaroErrors,
mag: MagErrors,
dropout: Option<Window>,
course: Option<f64>,
stationary: Option<f64>,
}
fn at_rest() -> Trajectory {
Trajectory {
hold: 0.0,
ramp: 0.0,
north: STILL,
east: STILL,
down: STILL,
roll: STILL,
pitch: STILL,
yaw: Wave {
offset: 0.6,
..STILL
},
sideslip: None,
}
}
fn hover() -> Trajectory {
Trajectory {
hold: 5.0,
ramp: 4.0,
north: Wave {
amplitude: 3.0,
period: 40.0,
..STILL
},
east: Wave { rate: 0.3, ..STILL },
down: Wave {
amplitude: -2.0,
period: 60.0,
..STILL
},
roll: Wave {
amplitude: 0.03,
period: 25.0,
..STILL
},
pitch: Wave {
amplitude: 0.04,
period: 35.0,
..STILL
},
yaw: Wave {
offset: 0.6,
amplitude: 0.1,
period: 50.0,
..STILL
},
sideslip: None,
}
}
fn circuit() -> Trajectory {
Trajectory {
hold: 5.0,
ramp: 4.0,
north: Wave {
amplitude: 120.0,
period: 60.0,
..STILL
},
east: Wave {
amplitude: 80.0,
period: 30.0,
..STILL
},
down: Wave {
amplitude: -20.0,
period: 90.0,
..STILL
},
roll: Wave {
amplitude: 0.25,
period: 30.0,
..STILL
},
pitch: Wave {
amplitude: 0.12,
period: 20.0,
..STILL
},
yaw: Wave {
offset: 0.6,
amplitude: 0.9,
period: 60.0,
..STILL
},
sideslip: None,
}
}
fn banked_turn() -> Trajectory {
Trajectory {
hold: 0.0,
ramp: 0.0,
north: Wave {
amplitude: 30.0,
period: 15.7,
..STILL
},
east: Wave {
offset: 30.0,
amplitude: -30.0,
period: 15.7,
phase: FRAC_PI_2,
..STILL
},
down: Wave {
rate: -1.5,
..STILL
},
roll: Wave {
offset: 0.455,
..STILL
},
pitch: STILL,
yaw: Wave {
offset: 0.6,
rate: 0.4,
..STILL
},
sideslip: None,
}
}
fn short_hop() -> Trajectory {
Trajectory {
hold: 2.0,
ramp: 2.0,
north: Wave {
amplitude: 20.0,
period: 20.0,
..STILL
},
east: Wave {
amplitude: 8.0,
period: 12.0,
..STILL
},
down: Wave {
amplitude: -5.0,
period: 24.0,
..STILL
},
roll: Wave {
amplitude: 0.12,
period: 12.0,
..STILL
},
pitch: Wave {
amplitude: 0.08,
period: 9.0,
..STILL
},
yaw: Wave {
offset: 0.6,
amplitude: 0.4,
period: 20.0,
..STILL
},
sideslip: None,
}
}
fn shuttle() -> Trajectory {
Trajectory {
hold: 5.0,
ramp: 4.0,
north: Wave {
amplitude: 3.2,
period: 8.0,
..STILL
},
east: Wave {
amplitude: 6.0,
period: 13.0,
..STILL
},
down: Wave {
offset: 0.0,
amplitude: -3.0,
period: 45.0,
..STILL
},
roll: Wave {
amplitude: 0.06,
period: 13.0,
..STILL
},
pitch: Wave {
amplitude: 0.08,
period: 8.0,
..STILL
},
yaw: Wave {
offset: 2.2,
rate: 0.05,
amplitude: 0.8,
period: 30.0,
..STILL
},
sideslip: None,
}
}
fn orbit() -> Trajectory {
const RADIUS: f64 = 150.0;
const PERIOD: f64 = TAU * RADIUS / 18.0;
const START: f64 = 0.6;
Trajectory {
hold: 5.0,
ramp: 4.0,
north: Wave {
offset: -RADIUS * 0.564_642_473, amplitude: RADIUS,
period: PERIOD,
phase: START,
..STILL
},
east: Wave {
offset: RADIUS * 0.825_335_615, amplitude: -RADIUS,
period: PERIOD,
phase: START + FRAC_PI_2,
..STILL
},
down: Wave {
amplitude: -15.0,
period: 90.0,
..STILL
},
roll: Wave {
amplitude: 0.1,
period: 30.0,
..STILL
},
pitch: Wave {
amplitude: 0.05,
period: 20.0,
..STILL
},
yaw: STILL,
sideslip: Some(Wave {
offset: 0.02,
amplitude: 0.035,
period: 25.0,
..STILL
}),
}
}
fn scenarios() -> Vec<Scenario> {
const PAIRED: u64 = 2;
let base = Scenario {
name: "",
covers: "",
seed: PAIRED,
duration: 185.0,
imu_rate: 200.0,
trajectory: circuit(),
imu: IMU,
gnss: GNSS,
baro: BARO,
mag: MAG,
dropout: None,
course: None,
stationary: None,
};
vec![
Scenario {
name: "static",
covers: "60 s on the ground: bias observability, and the at-rest specific force",
seed: 1,
duration: 60.0,
trajectory: at_rest(),
..base
},
Scenario {
name: "mission",
covers: "5 s static, then a 180 s circuit with turns and climbs: the baseline",
..base
},
Scenario {
name: "harsh_imu",
covers: "the baseline flown on a badly isolated IMU: ten times the white noise and \
twenty times the bias walk of every other scenario",
imu: HARSH_IMU,
..base
},
Scenario {
name: "bench",
covers: "120 s still with no GNSS, told it is still: the stationary claim of (29″) holding tilt",
seed: 9,
duration: 120.0,
trajectory: at_rest(),
gnss: GnssErrors {
available: Window::NEVER,
..GNSS
},
stationary: Some(0.1),
..base
},
Scenario {
name: "hover_outage",
covers: "90 s without GNSS in a drifting hover: the position hold of (28″) against a \
vehicle that leaves it, and the return",
seed: 10,
duration: 150.0,
trajectory: hover(),
gnss: GnssErrors {
outage: Some(Window {
start: 20.0,
end: 110.0,
}),
..GNSS
},
..base
},
Scenario {
name: "gnss_outage",
covers: "20 s without GNSS: dead-reckoning growth against the reported sigma",
gnss: GnssErrors {
outage: Some(Window {
start: 60.0,
end: 80.0,
}),
..GNSS
},
..base
},
Scenario {
name: "long_outage",
covers: "60 s without GNSS on the circuit: the position hold of (28″) engaged on a \
vehicle that flies on",
gnss: GnssErrors {
outage: Some(Window {
start: 60.0,
end: 120.0,
}),
..GNSS
},
..base
},
Scenario {
name: "moving_start",
covers: "airborne from t=0 in a banked turn: coarse alignment, Fusion::Reset, a \
barometric reference read from the estimate, and time to aligned",
seed: 4,
duration: 90.0,
trajectory: banked_turn(),
..base
},
Scenario {
name: "baro_drift",
covers: "the baseline with the barometric reference drifting 0.02 m/s: what a \
reference that is not followed costs in vertical position",
baro: BaroErrors {
drift: 0.02,
..BARO
},
..base
},
Scenario {
name: "gnss_latency",
covers: "the baseline with fixes 150 ms stale: the cost of fusing GNSS against the \
current state",
gnss: GnssErrors {
latency: 0.15,
..GNSS
},
..base
},
Scenario {
name: "correlated",
covers: "the baseline with every aiding error correlated in time, slower than the filter \
assumes: GNSS position 23 s and 106 s, velocity 0.43 s, barometer 1.3 s, \
magnetometer 3.3 s",
gnss: GnssErrors {
tau_horizontal: 23.0,
tau_vertical: 106.0,
tau_velocity: 0.43,
..GNSS
},
baro: BaroErrors { tau: 1.3, ..BARO },
mag: MagErrors { tau: 3.3, ..MAG },
..base
},
Scenario {
name: "mag_disturbance",
covers: "10 s of a 30 degree magnetic heading error: whether the heading gate rejects \
it, and what it costs if it does not",
mag: MagErrors {
disturbance: Some(Disturbance {
window: Window {
start: 60.0,
end: 70.0,
},
rotation: 30.0_f64.to_radians(),
}),
..MAG
},
..base
},
Scenario {
name: "gnss_heading",
covers: "the baseline with a dual-antenna GNSS heading beside the magnetometer: \
fuse_gnss_heading, and two heading sources gated against one estimate",
gnss: GnssErrors {
sigma_heading: Some(0.01),
..GNSS
},
..base
},
Scenario {
name: "lever_arm",
covers: "the baseline with the GNSS antenna on a mast 1 m from the IMU: the lever \
arm of (28') and (29'), and the only scenario whose fixes are not the IMU's",
gnss: GnssErrors {
antenna: [0.5, 0.3, -0.8],
..GNSS
},
..base
},
Scenario {
name: "no_mag",
covers: "a fixed-wing with no magnetometer, still and then orbiting: heading from \
fuse_course alone, refused until the vehicle moves",
seed: 3,
duration: 120.0,
trajectory: orbit(),
mag: MagErrors {
available: Window::NEVER,
..MAG
},
course: Some(0.05),
..base
},
Scenario {
name: "multirotor_no_mag",
covers: "a multirotor with no magnetometer, still and then shuttling with its nose \
turning: heading from the yaw estimator alone",
seed: 7,
duration: 120.0,
trajectory: shuttle(),
mag: MagErrors {
available: Window::NEVER,
..MAG
},
..base
},
Scenario {
name: "yaw_fault",
covers: "a multirotor whose magnetometer is 80 degrees wrong from the start: the yaw \
estimator replacing a heading GNSS keeps turning down",
seed: 11,
duration: 120.0,
trajectory: shuttle(),
mag: MagErrors {
disturbance: Some(Disturbance {
window: Window {
start: 0.0,
end: f64::INFINITY,
},
rotation: 80.0_f64.to_radians(),
}),
..MAG
},
..base
},
Scenario {
name: "logging_dropout",
covers: "1.2 s of the log lost at speed in a turn: the gap coasted by (22')",
dropout: Some(Window {
start: 60.0,
end: 61.2,
}),
..base
},
Scenario {
name: "flight",
covers: "sources appearing and dropping out mid-log at 50 Hz: an unobserved initial \
heading, then aiding lost one source at a time",
seed: 8,
duration: 14.0,
imu_rate: 50.0,
trajectory: short_hop(),
gnss: GnssErrors {
available: Window {
start: 2.0,
end: 6.0,
},
..GNSS
},
baro: BaroErrors {
period: 0.1,
available: Window {
start: 0.0,
end: 8.5,
},
..BARO
},
mag: MagErrors {
period: 0.1,
available: Window {
start: 3.5,
end: 8.5,
},
..MAG
},
..base
},
]
}
fn main() {
if let Err(e) = run() {
eprintln!("simulate: {e}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn Error>> {
let (positional, overrides) = split_options(env::args().skip(1))?;
let mut positional = positional.into_iter();
let wanted = positional.next().unwrap_or_else(|| "all".to_string());
let out_dir = positional
.next()
.map_or_else(default_out_dir, PathBuf::from);
let mut selected = scenarios();
selected.retain(|scenario| wanted == "all" || wanted == scenario.name);
if selected.is_empty() {
let names: Vec<&str> = scenarios().iter().map(|scenario| scenario.name).collect();
return Err(format!("unknown scenario `{wanted}`; have {}", names.join(", ")).into());
}
for scenario in &mut selected {
overrides.apply(scenario);
}
fs::create_dir_all(&out_dir)?;
println!("fusion-nav simulator — seeded flights with analytic truth\n");
for scenario in &selected {
generate(scenario, &out_dir, &overrides)?.print(scenario);
}
Ok(())
}
#[derive(Default)]
struct Overrides {
seed: Option<u64>,
speed: Option<f64>,
latency: Option<f64>,
}
impl Overrides {
fn apply(&self, scenario: &mut Scenario) {
if let Some(seed) = self.seed {
scenario.seed = seed;
}
if let Some(k) = self.speed {
let trajectory = &mut scenario.trajectory;
for wave in [
&mut trajectory.north,
&mut trajectory.east,
&mut trajectory.down,
] {
wave.rate *= k;
wave.amplitude *= k;
}
}
if let Some(latency) = self.latency {
scenario.gnss.latency = latency;
}
}
fn note(&self) -> Option<String> {
let mut cells = Vec::new();
if let Some(k) = self.speed {
cells.push(format!("speed x{k}"));
}
if let Some(latency) = self.latency {
cells.push(format!("gnss latency {latency} s"));
}
(!cells.is_empty()).then(|| format!("# Overridden for a sweep: {}", cells.join(", ")))
}
}
fn split_options(args: impl Iterator<Item = String>) -> Result<(Vec<String>, Overrides), String> {
let mut positional = Vec::new();
let mut overrides = Overrides::default();
let mut args = args;
while let Some(arg) = args.next() {
let mut value = |what: &str| args.next().ok_or_else(|| format!("{arg} needs {what}"));
match arg.as_str() {
"--seed" => {
let v = value("a value")?;
overrides.seed = Some(
v.parse()
.map_err(|_| format!("--seed `{v}` is not an unsigned integer"))?,
);
}
"--speed" | "--latency" => {
let v = value("a number")?;
let number: f64 = v
.parse()
.ok()
.filter(|n: &f64| n.is_finite() && *n >= 0.0)
.ok_or_else(|| format!("{arg} `{v}` is not a number at least zero"))?;
if arg == "--speed" {
overrides.speed = Some(number);
} else {
overrides.latency = Some(number);
}
}
_ => positional.push(arg),
}
}
Ok((positional, overrides))
}
fn generate(
scenario: &Scenario,
out_dir: &Path,
overrides: &Overrides,
) -> Result<Report, Box<dyn Error>> {
let log_path = out_dir.join(format!("{}.csv", scenario.name));
let truth_path = out_dir.join(format!("{}.truth.csv", scenario.name));
let mut log = Log::new(BufWriter::new(File::create(&log_path)?));
let mut truth = BufWriter::new(File::create(&truth_path)?);
let note = overrides.note();
write_log_header(&mut log.out, scenario, note.as_deref())?;
write_truth_header(&mut truth, scenario, note.as_deref())?;
let dt = 1.0 / scenario.imu_rate;
let epochs = (scenario.duration * scenario.imu_rate).round() as usize;
let gnss_every = ticks(scenario.gnss.period, dt, "gnss")?;
let baro_every = ticks(scenario.baro.period, dt, "baro")?;
let mag_every = ticks(scenario.mag.period, dt, "mag")?;
let mut imu = Imu::new(scenario.imu, scenario.seed, dt);
let mut gnss = Gnss::new(scenario.gnss, scenario.seed);
let mut baro = Baro::new(scenario.baro, scenario.seed);
let mut mag = Mag::new(scenario.mag, scenario.seed);
let mut report = Report::new(log_path, truth_path, epochs);
for epoch in 0..epochs {
let t = epoch as f64 * dt;
let state = scenario.trajectory.at(t);
let logged = !scenario.dropout.is_some_and(|dropout| dropout.contains(t));
let reading = imu.sample(&state);
let [gx, gy, gz] = reading.gyro;
let [ax, ay, az] = reading.accel;
if logged {
log.row(t, "imu", &[gx, gy, gz, ax, ay, az], &[], None)?;
}
write_truth_row(&mut truth, t, &state, &reading)?;
report.epoch(t, &state, &reading);
if epoch % gnss_every == 0
&& let Some(fix) = gnss.fix(t, &scenario.trajectory)
&& logged
{
let taken = Some(fix.taken);
log.row(t, "gnss_pos", &fix.position, &fix.position_variance, taken)?;
log.row(t, "gnss_vel", &fix.velocity, &fix.velocity_variance, taken)?;
if let Some((heading, variance)) = fix.heading {
log.row(t, "gnss_yaw", &[heading], &[variance], taken)?;
}
}
if epoch % gnss_every == 0
&& let Some(sigma) = scenario.stationary
&& state.velocity == [0.0; 3]
&& state.acceleration == [0.0; 3]
&& state.euler_rate == [0.0; 3]
&& logged
{
log.row(t, "stationary", &[], &[sigma * sigma; 3], None)?;
}
if epoch % baro_every == 0
&& let Some(altitude) = baro.sample(t, &state)
&& logged
{
log.row(t, "baro", &[altitude.meters], &[altitude.variance], None)?;
}
if epoch % mag_every == 0
&& let Some(field) = mag.sample(t, &state)
&& logged
{
log.row(t, "mag", &field.body, &[field.heading_variance], None)?;
}
}
log.out.flush()?;
truth.flush()?;
report.rows = log.rows;
Ok(report)
}
fn ticks(period: f64, dt: f64, source: &str) -> Result<usize, String> {
let ticks = period / dt;
if ticks < 1.0 || (ticks - ticks.round()).abs() > 1e-9 {
return Err(format!(
"{source} period {period} s is not a whole number of {dt} s IMU steps"
));
}
Ok(ticks.round() as usize)
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn norm(v: [f64; 3]) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
struct Report {
log: PathBuf,
truth: PathBuf,
epochs: usize,
rows: usize,
opening_force: [f64; 3],
opening_samples: u32,
opening_gyro: f64,
opening_deviation: f64,
peak_speed: f64,
}
impl Report {
fn new(log: PathBuf, truth: PathBuf, epochs: usize) -> Self {
Self {
log,
truth,
epochs,
rows: 0,
opening_force: [0.0; 3],
opening_samples: 0,
opening_gyro: 0.0,
opening_deviation: 0.0,
peak_speed: 0.0,
}
}
fn epoch(&mut self, t: f64, truth: &Truth, reading: &ImuReading) {
self.peak_speed = self.peak_speed.max(norm(truth.velocity));
if t < OPENING {
self.opening_force = add(self.opening_force, reading.accel);
self.opening_samples += 1;
self.opening_gyro = self.opening_gyro.max(norm(reading.gyro));
self.opening_deviation = self
.opening_deviation
.max((norm(reading.accel) - gravity()).abs());
}
}
fn print(&self, scenario: &Scenario) {
let samples = f64::from(self.opening_samples.max(1));
let mean = self.opening_force.map(|sum| sum / samples);
println!("{} — {}", scenario.name, scenario.covers);
println!(
" {} rows, {} IMU epochs over {:.0} s at {:.0} Hz, seed {}",
self.rows, self.epochs, scenario.duration, scenario.imu_rate, scenario.seed
);
println!(" log {}", self.log.display());
println!(" truth {}", self.truth.display());
println!(
" opening {OPENING:.0} s: mean f_b ({:.4}, {:.4}, {:.4}) m/s^2, peak gyro {:.4} \
rad/s, peak |f|-g {:.4} m/s^2",
mean[0], mean[1], mean[2], self.opening_gyro, self.opening_deviation,
);
println!(" peak speed {:.2} m/s\n", self.peak_speed);
}
}
struct Log<W: Write> {
out: W,
rows: usize,
}
impl<W: Write> Log<W> {
fn new(out: W) -> Self {
Self { out, rows: 0 }
}
fn row(
&mut self,
t: f64,
source: &str,
values: &[f64],
variances: &[f64],
taken: Option<f64>,
) -> io::Result<()> {
write!(self.out, "{t:.4},{source}")?;
for column in 0..6 {
match values.get(column) {
Some(value) => write!(self.out, ",{value:.6}")?,
None => write!(self.out, ",")?,
}
}
for column in 0..3 {
match variances.get(column) {
Some(variance) => write!(self.out, ",{variance:.6}")?,
None => write!(self.out, ",")?,
}
}
match taken {
Some(taken) => write!(self.out, ",{taken:.4}")?,
None => write!(self.out, ",")?,
}
writeln!(self.out)?;
self.rows += 1;
Ok(())
}
}
fn write_truth_row(
out: &mut impl Write,
t: f64,
truth: &Truth,
reading: &ImuReading,
) -> io::Result<()> {
write!(out, "{t:.4}")?;
for value in truth
.position
.iter()
.chain(&truth.velocity)
.chain(&truth.euler)
.chain(&reading.accel_bias)
.chain(&reading.gyro_bias)
{
write!(out, ",{value:.6}")?;
}
writeln!(out)
}
fn write_log_header(
out: &mut impl Write,
scenario: &Scenario,
note: Option<&str>,
) -> io::Result<()> {
writeln!(
out,
"# fusion-nav simulated flight - scenario `{name}`, seed {seed}\n\
# {covers}\n\
#\n\
# Navigation origin {latitude:.9} {longitude:.9} {height:.3} (lat deg, lon deg, height m; the simulated site)\n\
# Magnetic declination {declination:.6} rad\n\
# GNSS antenna {ax:.3} {ay:.3} {az:.3} m (forward, right, down from the IMU)\n\
{course}\
#\n\
# Generated by `cargo run --example simulate -- {name} <dir>`; truth in\n\
# `{name}.truth.csv`. Do not edit, regenerate. Six decimals throughout, three orders\n\
# below the smallest sigma in the scenario table.\n\
#\n\
# One row per measurement, sorted by time. Blank cells are not applicable.\n\
# imu v0..v2 gyro rad/s v3..v5 accel m/s^2\n\
# gnss_pos v0..v2 NED position m var0..var2 m^2\n\
# gnss_vel v0..v2 NED velocity m/s var0..var2 m^2/s^2\n\
# baro v0 altitude m (up) var0 m^2\n\
# mag v0..v2 field, calibrated var0 heading rad^2\n\
# gnss_yaw v0 heading rad, 2 ant var0 rad^2\n\
# stationary a claim to be stationary var0..var2 m^2/s^2\n\
# t_meas_s when a fix was taken, t_s less the receiver's latency",
name = scenario.name,
seed = scenario.seed,
covers = scenario.covers,
declination = DECLINATION,
ax = scenario.gnss.antenna[0],
ay = scenario.gnss.antenna[1],
az = scenario.gnss.antenna[2],
latitude = SITE[0],
longitude = SITE[1],
height = SITE[2],
course = scenario.course.map_or_else(String::new, |sigma| format!(
"# Course sideslip {sigma:.6} rad\n"
)),
)?;
if let Some(note) = note {
writeln!(out, "{note}")?;
}
writeln!(out, "t_s,source,v0,v1,v2,v3,v4,v5,var0,var1,var2,t_meas_s")
}
fn write_truth_header(
out: &mut impl Write,
scenario: &Scenario,
note: Option<&str>,
) -> io::Result<()> {
writeln!(
out,
"# fusion-nav truth for `{}.csv`, seed {}\n\
#\n\
# The analytic trajectory the log was generated from, at the IMU rate: closed-form\n\
# position and Euler angles, exact derivatives, and the biases actually applied to that\n\
# sample. Angles are radians, ZYX, in the NED/FRD convention the crate fixes.",
scenario.name, scenario.seed,
)?;
if let Some(note) = note {
writeln!(out, "{note}")?;
}
writeln!(
out,
"t_s,pos_n,pos_e,pos_d,vel_n,vel_e,vel_d,roll,pitch,yaw,ba_x,ba_y,ba_z,bg_x,bg_y,bg_z"
)
}
fn default_out_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("target/sim")
}