Skip to content

Commit 689d78a

Browse files
authored
Merge pull request #86 from jbrodovsky:jbrodovsky/issue84
Add serialization for GnssDegradationConfiguration and related types
2 parents 1aff9df + d0995a1 commit 689d78a

18 files changed

Lines changed: 922 additions & 151 deletions

Cargo.lock

Lines changed: 508 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ rand_distr = "0.5.1"
3434
serde = { version = "1.0.219", features = ["derive"] }
3535
world_magnetic_model = "0.4.0"
3636
anyhow = "1.0.99"
37+
serde_json = "1.0"
38+
serde_yaml = "0.9"
39+
toml = "0.7"
3740

3841
[dev-dependencies]
3942
assert_approx_eq = "1.1.0"
43+
tempfile = "3.5"
4044

core/src/earth.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -381,17 +381,12 @@ pub fn meters_ned_to_dlat_dlon(lat_rad: f64, alt_m: f64, d_n: f64, d_e: f64) ->
381381
/// );
382382
/// println!("Distance: {:.1} km", d / 1000.0);
383383
/// ```
384-
pub fn haversine_distance(
385-
lat1_rad: f64,
386-
lon1_rad: f64,
387-
lat2_rad: f64,
388-
lon2_rad: f64,
389-
) -> f64 {
384+
pub fn haversine_distance(lat1_rad: f64, lon1_rad: f64, lat2_rad: f64, lon2_rad: f64) -> f64 {
390385
let dlat = lat2_rad - lat1_rad;
391386
let dlon = lon2_rad - lon1_rad;
392387

393-
let a = (dlat / 2.0).sin().powi(2)
394-
+ lat1_rad.cos() * lat2_rad.cos() * (dlon / 2.0).sin().powi(2);
388+
let a =
389+
(dlat / 2.0).sin().powi(2) + lat1_rad.cos() * lat2_rad.cos() * (dlon / 2.0).sin().powi(2);
395390

396391
let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
397392

@@ -982,7 +977,12 @@ mod tests {
982977
let d = haversine_distance(0.0, 0.0, 0.0, 90_f64.to_radians());
983978
let expected = std::f64::consts::FRAC_PI_2 * 6_371_000.0; // R * π/2
984979
let err = (d - expected).abs();
985-
assert!(err < 1e-6 * expected, "Error too large: got {}, expected {}", d, expected);
980+
assert!(
981+
err < 1e-6 * expected,
982+
"Error too large: got {}, expected {}",
983+
d,
984+
expected
985+
);
986986
}
987987

988988
#[test]
@@ -991,7 +991,12 @@ mod tests {
991991
let d = haversine_distance(0.0, 0.0, 0.0, 180_f64.to_radians());
992992
let expected = std::f64::consts::PI * 6_371_000.0; // R * π
993993
let err = (d - expected).abs();
994-
assert!(err < 1e-6 * expected, "Error too large: got {}, expected {}", d, expected);
994+
assert!(
995+
err < 1e-6 * expected,
996+
"Error too large: got {}, expected {}",
997+
d,
998+
expected
999+
);
9951000
}
9961001

9971002
#[test]
@@ -1004,6 +1009,10 @@ mod tests {
10041009

10051010
let d = haversine_distance(nyc_lat, nyc_lon, lon_lat, lon_lon);
10061011
// Real-world great-circle distance ~5567 km
1007-
assert!((d / KM - 5567.0).abs() < 50.0, "NYC-LON distance off: {} km", d / KM);
1012+
assert!(
1013+
(d / KM - 5567.0).abs() < 50.0,
1014+
"NYC-LON distance off: {} km",
1015+
d / KM
1016+
);
10081017
}
10091018
}

core/src/main.rs

Lines changed: 0 additions & 3 deletions
This file was deleted.

core/src/messages.rs

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ use chrono::{DateTime, Utc};
33
use nalgebra::Vector3;
44
use rand::SeedableRng;
55
use rand_distr::{Distribution, Normal};
6+
use serde::{Deserialize, Serialize};
7+
use std::fs::File;
8+
use std::io::{self, Read, Write};
9+
use std::path::Path;
610

711
use crate::IMUData;
8-
use crate::earth::{meters_ned_to_dlat_dlon, METERS_TO_DEGREES};
12+
use crate::earth::meters_ned_to_dlat_dlon;
913
use crate::filter::{
1014
GPSPositionAndVelocityMeasurement, GravityAnomalyMeasurement, MagneticAnomalyMeasurement,
1115
RelativeAltitudeMeasurement,
@@ -45,7 +49,8 @@ use crate::sim::TestDataRecord;
4549
/// // Alternate 5 s ON, 15 s OFF, starting in ON state at t=0
4650
/// let sched = GnssScheduler::DutyCycle { on_s: 5.0, off_s: 15.0, start_phase_s: 0.0 };
4751
/// ```
48-
#[derive(Clone, Debug)]
52+
#[derive(Clone, Debug, Serialize, Deserialize)]
53+
#[serde(tag = "kind", rename_all = "snake_case")]
4954
pub enum GnssScheduler {
5055
/// Pass every GNSS fix through to the filter with no rate reduction.
5156
///
@@ -81,6 +86,66 @@ pub enum GnssScheduler {
8186
start_phase_s: f64,
8287
},
8388
}
89+
90+
#[cfg(test)]
91+
mod serialization_tests {
92+
use super::*;
93+
use tempfile::NamedTempFile;
94+
95+
fn sample_cfg() -> GnssDegradationConfig {
96+
GnssDegradationConfig {
97+
scheduler: GnssScheduler::PassThrough,
98+
fault: GnssFaultModel::Degraded {
99+
rho_pos: 0.99,
100+
sigma_pos_m: 3.0,
101+
rho_vel: 0.95,
102+
sigma_vel_mps: 0.3,
103+
r_scale: 5.0,
104+
},
105+
seed: 42,
106+
}
107+
}
108+
109+
#[test]
110+
fn json_roundtrip() {
111+
let cfg = sample_cfg();
112+
let f = NamedTempFile::new().unwrap();
113+
let path = f.path().with_extension("json");
114+
cfg.to_json(&path).unwrap();
115+
let loaded = GnssDegradationConfig::from_json(&path).unwrap();
116+
assert_eq!(cfg.seed, loaded.seed);
117+
}
118+
119+
#[test]
120+
fn yaml_roundtrip() {
121+
let cfg = sample_cfg();
122+
let f = NamedTempFile::new().unwrap();
123+
let path = f.path().with_extension("yaml");
124+
cfg.to_yaml(&path).unwrap();
125+
let loaded = GnssDegradationConfig::from_yaml(&path).unwrap();
126+
assert_eq!(cfg.seed, loaded.seed);
127+
}
128+
129+
#[test]
130+
fn toml_roundtrip() {
131+
let cfg = sample_cfg();
132+
let f = NamedTempFile::new().unwrap();
133+
let path = f.path().with_extension("toml");
134+
cfg.to_toml(&path).unwrap();
135+
let loaded = GnssDegradationConfig::from_toml(&path).unwrap();
136+
assert_eq!(cfg.seed, loaded.seed);
137+
}
138+
139+
#[test]
140+
fn generic_dispatch_roundtrip() {
141+
let cfg = sample_cfg();
142+
let f = NamedTempFile::new().unwrap();
143+
let path = f.path().with_extension("json");
144+
cfg.to_file(&path).unwrap();
145+
let loaded = GnssDegradationConfig::from_file(&path).unwrap();
146+
assert_eq!(cfg.seed, loaded.seed);
147+
}
148+
}
84149
/// Models how GNSS measurement *content* is corrupted before it reaches the filter.
85150
///
86151
/// This is complementary to [`GnssScheduler`], which decides *when* GNSS
@@ -144,7 +209,8 @@ pub enum GnssScheduler {
144209
/// GnssFaultModel::Hijack { offset_n_m: 50.0, offset_e_m: 0.0, start_s: 120.0, duration_s: 60.0 },
145210
/// ]);
146211
/// ```
147-
#[derive(Clone, Debug)]
212+
#[derive(Clone, Debug, Serialize, Deserialize)]
213+
#[serde(tag = "kind", rename_all = "snake_case")]
148214
pub enum GnssFaultModel {
149215
/// No corruption; GNSS fixes are passed through unchanged.
150216
None,
@@ -236,7 +302,7 @@ pub enum GnssFaultModel {
236302
/// seed: 42,
237303
/// };
238304
/// ```
239-
#[derive(Clone, Debug)]
305+
#[derive(Clone, Debug, Serialize, Deserialize)]
240306
pub struct GnssDegradationConfig {
241307
/// Scheduler that determines when GNSS measurements are emitted
242308
/// (e.g., pass-through, fixed interval, or duty-cycled).
@@ -253,6 +319,74 @@ pub struct GnssDegradationConfig {
253319
pub seed: u64,
254320
}
255321

322+
impl GnssDegradationConfig {
323+
/// Write the configuration to a JSON file (pretty-printed).
324+
pub fn to_json<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
325+
let file = File::create(path)?;
326+
serde_json::to_writer_pretty(file, self).map_err(|e| io::Error::new(io::ErrorKind::Other, e))
327+
}
328+
329+
/// Read the configuration from a JSON file.
330+
pub fn from_json<P: AsRef<Path>>(path: P) -> io::Result<Self> {
331+
let file = File::open(path)?;
332+
serde_json::from_reader(file).map_err(|e| io::Error::new(io::ErrorKind::Other, e))
333+
}
334+
/// Write the configuration as YAML.
335+
pub fn to_yaml<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
336+
let mut file = File::create(path)?;
337+
let s = serde_yaml::to_string(self).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
338+
file.write_all(s.as_bytes())
339+
}
340+
341+
/// Read the configuration from YAML.
342+
pub fn from_yaml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
343+
let file = File::open(path)?;
344+
serde_yaml::from_reader(file).map_err(|e| io::Error::new(io::ErrorKind::Other, e))
345+
}
346+
/// Write the configuration as TOML.
347+
pub fn to_toml<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
348+
let mut file = File::create(path)?;
349+
let s = toml::to_string(self).map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
350+
file.write_all(s.as_bytes())
351+
}
352+
/// Read the configuration from TOML.
353+
pub fn from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
354+
let mut s = String::new();
355+
let mut file = File::open(path)?;
356+
file.read_to_string(&mut s)?;
357+
toml::from_str(&s).map_err(|e| io::Error::new(io::ErrorKind::Other, e))
358+
}
359+
/// Generic write: choose format by file extension (.json/.yaml/.yml/.toml)
360+
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
361+
let p = path.as_ref();
362+
let ext = p
363+
.extension()
364+
.and_then(|s| s.to_str())
365+
.map(|s| s.to_lowercase());
366+
match ext.as_deref() {
367+
Some("json") => self.to_json(p),
368+
Some("yaml") | Some("yml") => self.to_yaml(p),
369+
Some("toml") => self.to_toml(p),
370+
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, "unsupported file extension")),
371+
}
372+
}
373+
374+
/// Generic read: choose format by file extension (.json/.yaml/.yml/.toml)
375+
pub fn from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
376+
let p = path.as_ref();
377+
let ext = p
378+
.extension()
379+
.and_then(|s| s.to_str())
380+
.map(|s| s.to_lowercase());
381+
match ext.as_deref() {
382+
Some("json") => Self::from_json(p),
383+
Some("yaml") | Some("yml") => Self::from_yaml(p),
384+
Some("toml") => Self::from_toml(p),
385+
_ => Err(io::Error::new(io::ErrorKind::InvalidInput, "unsupported file extension")),
386+
}
387+
}
388+
}
389+
256390
/// A simulation event delivered to the filter in time order.
257391
///
258392
/// Events represent sensor updates or other observations that occur during
@@ -473,7 +607,7 @@ impl FaultState {
473607
///
474608
/// ```ignore
475609
/// use rand::SeedableRng;
476-
///
610+
///
477611
/// let mut x = 0.0;
478612
/// let mut rng = SeedableRng::seed_from_u64(42);
479613
/// for _ in 0..5 {
@@ -713,7 +847,7 @@ fn apply_fault(
713847
}
714848
}
715849
// --------------------------- public API ---------------------------
716-
/// Build a time-ordered event stream from recorded data and a GNSS degradation
850+
/// Build a time-ordered event stream from recorded data and a GNSS degradation
717851
/// configuration.
718852
///
719853
/// This function converts raw `records` into a vector of [`Event`]s suitable
@@ -886,8 +1020,8 @@ pub fn build_event_stream(records: &[TestDataRecord], cfg: &GnssDegradationConfi
8861020
#[cfg(test)]
8871021
mod tests {
8881022
use super::*;
889-
use chrono::{TimeZone, Utc};
8901023
use assert_approx_eq::assert_approx_eq;
1024+
use chrono::{TimeZone, Utc};
8911025

8921026
fn create_test_records(count: usize, interval_secs: f64) -> Vec<TestDataRecord> {
8931027
let base_time = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
@@ -994,7 +1128,11 @@ mod tests {
9941128
#[test]
9951129
fn test_duty_cycle_scheduler() {
9961130
let records = create_test_records(60, 1.0); // 60 records, 1s apart, 60 seconds total
997-
assert!(records.len() == 60, "{}", format!("Expected 60 records, found: {}", records.len()));
1131+
assert!(
1132+
records.len() == 60,
1133+
"{}",
1134+
format!("Expected 60 records, found: {}", records.len())
1135+
);
9981136

9991137
let config = GnssDegradationConfig {
10001138
scheduler: GnssScheduler::DutyCycle {
@@ -1005,7 +1143,7 @@ mod tests {
10051143
fault: GnssFaultModel::None,
10061144
seed: 42,
10071145
};
1008-
//
1146+
//
10091147
let events = build_event_stream(&records, &config);
10101148
//assert!(events.len() == 60, "{}", format!("Expected 60 events, found: {}", events.len()));
10111149
// We should only have GNSS events when turning ON
@@ -1018,7 +1156,11 @@ mod tests {
10181156
// We initialize off of the first event and only get GNSS updates every two seconds starting from 1.0
10191157
// (60 - 1) // 2 = 29
10201158
assert!(gnss_events.len() >= 2);
1021-
assert!(gnss_events.len() == 29, "{}", format!("Expected 29 GNSS events, found: {}", gnss_events.len()));
1159+
assert!(
1160+
gnss_events.len() == 29,
1161+
"{}",
1162+
format!("Expected 29 GNSS events, found: {}", gnss_events.len())
1163+
);
10221164

10231165
// Extract elapsed_s from GNSS events and verify they occur at expected times
10241166
let gnss_times: Vec<f64> = gnss_events
@@ -1126,18 +1268,15 @@ mod tests {
11261268

11271269
// zero “truth” velocity
11281270
let (_lat, _lon, _alt, vn_c, ve_c, _hstd, _vstd) = apply_fault(
1129-
&fault, &mut st,
1130-
/*t*/ 10.0, /*dt*/ 1.0,
1131-
/*lat_deg*/ 40.0, /*lon_deg*/ -75.0, /*alt_m*/ 0.0,
1132-
/*vn_mps*/ 0.0, /*ve_mps*/ 0.0,
1271+
&fault, &mut st, /*t*/ 10.0, /*dt*/ 1.0, /*lat_deg*/ 40.0,
1272+
/*lon_deg*/ -75.0, /*alt_m*/ 0.0, /*vn_mps*/ 0.0, /*ve_mps*/ 0.0,
11331273
/*horiz_std_m*/ 3.0, /*vert_std_m*/ 5.0, /*vel_std_mps*/ 0.2,
11341274
);
11351275

1136-
assert_approx_eq!(vn_c, 0.02, 0.001);
1276+
assert_approx_eq!(vn_c, 0.02, 0.001);
11371277
assert_approx_eq!(ve_c, -0.01, 0.001);
11381278
}
11391279

1140-
11411280
#[test]
11421281
fn test_hijack_fault_model() {
11431282
let records = create_test_records(30, 0.1); // 30 records, 0.1s apart, total 3.0 seconds
@@ -1184,8 +1323,6 @@ mod tests {
11841323
}
11851324
}
11861325

1187-
1188-
11891326
#[test]
11901327
fn test_combo_fault_model() {
11911328
// Test that the combo fault model functionality exists

0 commit comments

Comments
 (0)