use super::*;
const SP3C_FILE: &str = "\
#cP2020 6 24 0 0 0.00000000 2 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 2 G01G02 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3-c FIXTURE
* 2020 6 24 0 0 0.00000000
PG01 15000.000000 -20000.000000 5000.000000 123.456789
PG02 -1234.567890 2345.678901 -3456.789012 999999.999999
* 2020 6 24 0 15 0.00000000
PG01 15100.000000 -20100.000000 5100.000000 -987.654321 E
PG02 0.000000 0.000000 0.000000 100.000000
EOF
";
const SP3D_FILE: &str = "\
#dV2022 1 2 3 4 5.00000000 1 ORBIT IGS20 FIT TST
## 2191 270245.00000000 300.00000000 59581 0.1281597222222
+ 3 G05E11C30 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c M cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3-d FIXTURE
* 2022 1 2 3 4 5.00000000
PG05 10000.000000 20000.000000 30000.000000 -50.000000
VG05 10000.000000 -20000.000000 30000.000000 1.000000
PE11 -11111.111111 22222.222222 -33333.333333 250.000000 P
VE11 -5000.000000 5000.000000 -5000.000000 2.500000
PC30 1000.000000 2000.000000 3000.000000 -10.000000 MP
VC30 1234.000000 5678.000000 9012.000000 -1.000000
EOF
";
fn id(sys: GnssSystem, prn: u8) -> GnssSatelliteId {
GnssSatelliteId::new(sys, prn).expect("valid satellite id")
}
fn assert_parse_error_contains(text: &str, needle: &str) {
let err = Sp3::parse(text.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains(needle)),
"expected parse error containing {needle:?}; got {err:?}"
);
}
#[test]
fn parses_sp3c_header() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).expect("parse SP3-c");
let h = &sp3.header;
assert_eq!(h.version, Sp3Version::C);
assert_eq!(h.data_type, Sp3DataType::Position);
assert_eq!(h.num_epochs, 2);
assert_eq!(h.coordinate_system, "IGS14");
assert_eq!(h.orbit_type, "FIT");
assert_eq!(h.agency, "TST");
assert_eq!(h.gnss_week, 2111);
assert_eq!(h.seconds_of_week, 432000.0);
assert_eq!(h.epoch_interval_s, 900.0);
assert_eq!(h.mjd, 59024);
assert_eq!(h.time_system, Sp3TimeSystem::Gps);
assert_eq!(h.time_scale, TimeScale::Gpst);
assert_eq!(
h.satellites,
vec![id(GnssSystem::Gps, 1), id(GnssSystem::Gps, 2)]
);
assert_eq!(h.satellite_accuracy_codes, vec![0, 0]);
assert_eq!(sp3.epoch_count(), 2);
assert_eq!(sp3.comments, vec!["TEST SP3-c FIXTURE".to_string()]);
}
#[test]
fn parses_sp3c_position_and_clock_units() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let st = sp3.state(id(GnssSystem::Gps, 1), 0).unwrap();
assert_eq!(st.position.x_m, 15000.000000 * 1_000.0);
assert_eq!(st.position.y_m, -20000.000000 * 1_000.0);
assert_eq!(st.position.z_m, 5000.000000 * 1_000.0);
assert_eq!(st.clock_s, Some(123.456789 * 1.0e-6));
assert!(st.velocity.is_none());
assert!(st.clock_rate_s_s.is_none());
assert_eq!(st.flags, Sp3Flags::default());
}
#[test]
fn missing_clock_sentinel_is_none_but_position_kept() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let st = sp3.state(id(GnssSystem::Gps, 2), 0).unwrap();
assert_eq!(st.position.x_m, -1234.567890 * 1_000.0);
assert_eq!(st.clock_s, None, "bad-clock sentinel must surface as None");
}
#[test]
fn missing_position_record_is_dropped() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let err = sp3.state(id(GnssSystem::Gps, 2), 1).unwrap_err();
assert_eq!(err, Error::UnknownSatellite(id(GnssSystem::Gps, 2)));
assert!(sp3.state(id(GnssSystem::Gps, 1), 1).is_ok());
}
#[test]
fn clock_event_flag_parsed() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let st = sp3.state(id(GnssSystem::Gps, 1), 1).unwrap();
assert!(st.flags.clock_event, "E flag in clock-event column");
assert!(!st.flags.orbit_predicted);
assert_eq!(st.clock_s, Some(-987.654321 * 1.0e-6));
}
#[test]
fn parses_sp3d_multignss_velocity() {
let sp3 = Sp3::parse(SP3D_FILE.as_bytes()).expect("parse SP3-d");
assert_eq!(sp3.header.version, Sp3Version::D);
assert_eq!(sp3.header.data_type, Sp3DataType::Velocity);
assert_eq!(
sp3.header.satellites,
vec![
id(GnssSystem::Gps, 5),
id(GnssSystem::Galileo, 11),
id(GnssSystem::BeiDou, 30),
]
);
let g = sp3.state(id(GnssSystem::Gps, 5), 0).unwrap();
assert_eq!(g.position.x_m, 10000.0 * 1_000.0);
let v = g.velocity.expect("velocity present");
assert_eq!(v.vx_m_s, 10000.0 * 0.1);
assert_eq!(v.vy_m_s, -20000.0 * 0.1);
assert_eq!(v.vz_m_s, 30000.0 * 0.1);
assert_ne!(v.vx_m_s, v.vy_m_s, "X and Y velocity must not be aliased");
assert_eq!(g.clock_rate_s_s, Some(1.0 * 1.0e-10));
}
#[test]
fn predicted_and_maneuver_flags_sp3d() {
let sp3 = Sp3::parse(SP3D_FILE.as_bytes()).unwrap();
let e = sp3.state(id(GnssSystem::Galileo, 11), 0).unwrap();
assert!(e.flags.orbit_predicted, "trailing P = predicted orbit");
let c = sp3.state(id(GnssSystem::BeiDou, 30), 0).unwrap();
assert!(c.flags.maneuver, "M = maneuver");
assert!(c.flags.orbit_predicted, "P after M = predicted orbit");
}
#[test]
fn epoch_index_out_of_range_errors() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
assert_eq!(
sp3.state(id(GnssSystem::Gps, 1), 99),
Err(Error::EpochOutOfRange)
);
assert!(sp3.states_at(99).is_err());
}
#[test]
fn epoch_julian_split_is_consistent() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let e0 = sp3.epochs[0].julian_date().unwrap();
let e1 = sp3.epochs[1].julian_date().unwrap();
assert_eq!(e0.jd_whole, e1.jd_whole, "same civil day");
assert_eq!(e1.fraction - e0.fraction, 900.0 / 86_400.0);
assert_eq!(e0.jd_whole + e0.fraction, 2_459_024.5);
}
#[test]
fn missing_header_line1_errors() {
let no_h1 = "\
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
EOF
";
let err = Sp3::parse(no_h1.as_bytes()).unwrap_err();
assert!(matches!(err, Error::Parse(_)));
}
#[test]
fn missing_header_line2_errors() {
let no_h2 = "\
#cP2020 6 24 0 0 0.00000000 2 ORBIT IGS14 FIT TST
EOF
";
let err = Sp3::parse(no_h2.as_bytes()).unwrap_err();
assert!(matches!(err, Error::Parse(_)));
}
#[test]
fn missing_pc_descriptor_errors_for_sp3c() {
let no_pc = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
* 2020 6 24 0 0 0.00000000
PG01 15000.000000 -20000.000000 5000.000000 123.456789
EOF
";
let err = Sp3::parse(no_pc.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("time system")),
"missing %c must error, not default to GPST; got {err:?}"
);
}
#[test]
fn short_pc_descriptor_errors_for_sp3c() {
let short_pc = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G
EOF
";
let err = Sp3::parse(short_pc.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("too short")),
"short %c must error; got {err:?}"
);
}
#[test]
fn blank_pc_time_system_errors_for_sp3c() {
let blank_pc = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
EOF
";
let err = Sp3::parse(blank_pc.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("blank")),
"blank %c time system must error; got {err:?}"
);
}
#[test]
fn sp3a_with_no_pc_descriptor_is_gpst() {
let sp3a = "\
#aP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
* 2020 6 24 0 0 0.00000000
P 1 15000.000000 -20000.000000 5000.000000 123.456789
EOF
";
let sp3 = Sp3::parse(sp3a.as_bytes()).expect("SP3-a parses without %c");
assert_eq!(sp3.header.version, Sp3Version::A);
assert_eq!(sp3.header.time_system, Sp3TimeSystem::Gps);
assert_eq!(sp3.header.time_scale, TimeScale::Gpst);
assert!(sp3.state(id(GnssSystem::Gps, 1), 0).is_ok());
}
#[test]
fn sp3a_ignores_pc_descriptor_and_stays_gpst() {
let sp3a = "\
#aP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c R cc GLO ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
* 2020 6 24 0 0 0.00000000
P 1 15000.000000 -20000.000000 5000.000000 123.456789
EOF
";
let sp3 = Sp3::parse(sp3a.as_bytes()).expect("SP3-a parses, ignoring %c");
assert_eq!(sp3.header.time_system, Sp3TimeSystem::Gps);
assert_eq!(sp3.header.time_scale, TimeScale::Gpst);
}
#[test]
fn valid_gps_pc_descriptor_parses() {
let sp3 = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
assert_eq!(sp3.header.time_system, Sp3TimeSystem::Gps);
assert_eq!(sp3.header.time_scale, TimeScale::Gpst);
}
fn sp3_fixture_with_time_system(label: &str) -> String {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
);
let text = std::fs::read_to_string(path).expect("read committed SP3 fixture");
text.replacen("%c M cc GPS", &format!("%c M cc {label}"), 1)
}
#[test]
fn standard_sp3_time_system_labels_parse_from_committed_fixture() {
for (label, system, scale) in [
("GLO", Sp3TimeSystem::Glonass, TimeScale::Utc),
("QZS", Sp3TimeSystem::Qzss, TimeScale::Qzsst),
("IRN", Sp3TimeSystem::Irnss, TimeScale::Gpst),
("GAL", Sp3TimeSystem::Galileo, TimeScale::Gst),
("BDT", Sp3TimeSystem::Beidou, TimeScale::Bdt),
("TAI", Sp3TimeSystem::Tai, TimeScale::Tai),
("UTC", Sp3TimeSystem::Utc, TimeScale::Utc),
] {
let text = sp3_fixture_with_time_system(label);
let sp3 = Sp3::parse(text.as_bytes())
.unwrap_or_else(|err| panic!("{label} SP3 fixture should parse: {err}"));
assert_eq!(sp3.header.time_system, system, "{label} label");
assert_eq!(sp3.header.time_scale, scale, "{label} core scale");
assert_eq!(sp3.header.time_system.label(), label);
assert_eq!(sp3.epoch_count(), 96);
}
}
#[test]
fn bogus_sp3_time_system_label_errors() {
let text = sp3_fixture_with_time_system("BAD");
let err = Sp3::parse(text.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("BAD")),
"bogus time-system label must error; got {err:?}"
);
}
#[test]
fn malformed_mjd_fraction_errors() {
let bad = SP3C_FILE.replace("59024 0.0000000000000", "59024 NOT_A_NUMBER");
assert_parse_error_contains(&bad, "mjd_fraction");
}
#[test]
fn epoch_line_rejects_invalid_civil_month() {
let bad = SP3C_FILE.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2020 13 24 0 0 0.00000000",
1,
);
assert_parse_error_contains(&bad, "valid civil date");
}
#[test]
fn epoch_line_rejects_invalid_civil_hour() {
let bad = SP3C_FILE.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2020 6 24 24 0 0.00000000",
1,
);
assert_parse_error_contains(&bad, "valid civil time");
}
#[test]
fn gps_time_system_rejects_leap_second_epoch_label() {
let bad = SP3C_FILE.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2016 12 31 23 59 60.00000000",
1,
);
assert_parse_error_contains(&bad, "valid civil time");
}
#[test]
fn utc_time_system_accepts_leap_second_epoch_label() {
let utc = SP3C_FILE
.replacen("%c G cc GPS", "%c G cc UTC", 1)
.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2016 12 31 23 59 60.00000000",
1,
);
let sp3 = Sp3::parse(utc.as_bytes()).expect("UTC SP3 leap-second epoch");
assert_eq!(sp3.header.time_system, Sp3TimeSystem::Utc);
assert_eq!(sp3.epochs[0].scale, TimeScale::Utc);
}
#[test]
fn utc_time_system_accepts_fractional_leap_second_epoch_label() {
let utc = SP3C_FILE
.replacen("%c G cc GPS", "%c G cc UTC", 1)
.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2016 12 31 23 59 60.50000000",
1,
);
let sp3 = Sp3::parse(utc.as_bytes()).expect("UTC SP3 fractional leap-second epoch");
let split = sp3.epochs[0]
.julian_date()
.expect("SP3 epoch stored as split JD");
assert_eq!(sp3.header.time_system, Sp3TimeSystem::Utc);
assert_eq!(sp3.epochs[0].scale, TimeScale::Utc);
assert_eq!(split.jd_whole, 2_457_754.5);
assert!((split.fraction - 0.5 / 86_400.0).abs() < 1.0e-15);
}
#[test]
fn utc_time_system_rejects_malformed_leap_second_epoch_without_panic() {
let bad = SP3C_FILE
.replacen("%c G cc GPS", "%c G cc UTC", 1)
.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2016 12 31 23 59 61.00000000",
1,
);
assert_parse_error_contains(&bad, "valid civil time");
}
#[test]
fn truncated_position_record_errors() {
let bad = SP3C_FILE.replace(
"PG01 15000.000000 -20000.000000 5000.000000 123.456789",
"PG01 15000.000000 -20000.000000",
);
assert_parse_error_contains(&bad, "position record truncated");
}
#[test]
fn truncated_velocity_record_errors() {
let bad = SP3D_FILE.replace(
"VG05 10000.000000 -20000.000000 30000.000000 1.000000",
"VG05 10000.000000 -20000.000000",
);
assert_parse_error_contains(&bad, "velocity record truncated");
}
#[test]
fn non_finite_position_coordinate_errors() {
let bad_line = format!(
"PG01{:>14}{:>14}{:>14}{:>14}",
"NaN", "-20000.000000", "5000.000000", "123.456789"
);
let bad = SP3C_FILE.replace(
"PG01 15000.000000 -20000.000000 5000.000000 123.456789",
&bad_line,
);
assert_parse_error_contains(&bad, "coordinate is not a finite number");
}
#[test]
fn non_finite_clock_errors() {
let bad_line = format!(
"PG01{:>14}{:>14}{:>14}{:>14}",
"15000.000000", "-20000.000000", "5000.000000", "NaN"
);
let bad = SP3C_FILE.replace(
"PG01 15000.000000 -20000.000000 5000.000000 123.456789",
&bad_line,
);
assert_parse_error_contains(&bad, "clock is not a finite number");
}
#[test]
fn non_finite_velocity_coordinate_errors() {
let bad_line = format!(
"VG05{:>14}{:>14}{:>14}{:>14}",
"NaN", "-20000.000000", "30000.000000", "1.000000"
);
let bad = SP3D_FILE.replace(
"VG05 10000.000000 -20000.000000 30000.000000 1.000000",
&bad_line,
);
assert_parse_error_contains(&bad, "coordinate is not a finite number");
}
#[test]
fn non_finite_clock_rate_errors() {
let bad_line = format!(
"VG05{:>14}{:>14}{:>14}{:>14}",
"10000.000000", "-20000.000000", "30000.000000", "NaN"
);
let bad = SP3D_FILE.replace(
"VG05 10000.000000 -20000.000000 30000.000000 1.000000",
&bad_line,
);
assert_parse_error_contains(&bad, "clock is not a finite number");
}
#[test]
fn velocity_only_record_produces_no_state() {
let vel_only = "\
#dV2022 1 2 3 4 5.00000000 1 ORBIT IGS20 FIT TST
## 2191 270245.00000000 300.00000000 59581 0.1281597222222
+ 1 G05 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c M cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
* 2022 1 2 3 4 5.00000000
VG05 10000.000000 -20000.000000 30000.000000 1.000000
EOF
";
let sp3 = Sp3::parse(vel_only.as_bytes()).expect("parse velocity-only");
let err = sp3.state(id(GnssSystem::Gps, 5), 0).unwrap_err();
assert_eq!(err, Error::UnknownSatellite(id(GnssSystem::Gps, 5)));
assert!(
sp3.states_at(0).unwrap().is_empty(),
"no (0,0,0) state leaked"
);
}
#[test]
fn position_then_velocity_augments_velocity() {
let sp3 = Sp3::parse(SP3D_FILE.as_bytes()).unwrap();
let st = sp3.state(id(GnssSystem::Gps, 5), 0).unwrap();
assert_eq!(st.position.x_m, 10000.0 * 1_000.0);
let v = st
.velocity
.expect("velocity augmented onto the P-record state");
assert_eq!(v.vx_m_s, 10000.0 * 0.1);
assert_eq!(v.vy_m_s, -20000.0 * 0.1);
assert_eq!(v.vz_m_s, 30000.0 * 0.1);
}
#[test]
fn position_record_before_epoch_errors() {
let bad = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
PG01 15000.000000 -20000.000000 5000.000000 123.456789
EOF
";
let err = Sp3::parse(bad.as_bytes()).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("before any epoch")),
"got {err:?}"
);
}
#[test]
fn non_utf8_input_errors() {
let bytes = [0xffu8, 0xfe, 0x00, 0x01];
let err = Sp3::parse(&bytes).unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("UTF-8")),
"got {err:?}"
);
}
#[test]
fn multibyte_comment_text_errors_without_panicking() {
let err = Sp3::parse(b"/*\xc3\xa9\n").unwrap_err();
assert!(
matches!(err, Error::Parse(ref m) if m.contains("ASCII")),
"got {err:?}"
);
}
#[test]
fn trailing_truncation_after_eof_tolerated() {
let truncated = format!("{SP3C_FILE}garbage line that should be ignored\n");
let sp3 = Sp3::parse(truncated.as_bytes()).unwrap();
assert_eq!(sp3.epoch_count(), 2);
}
#[test]
fn sv_token_round_trips_through_display() {
for (sys, prns) in [
(GnssSystem::Gps, &[1u8, 5, 9, 12, 30, 32][..]),
(GnssSystem::Glonass, &[1u8, 5, 9, 12, 26, 27][..]),
(GnssSystem::Galileo, &[1u8, 5, 9, 12, 30, 36][..]),
(GnssSystem::BeiDou, &[1u8, 5, 9, 12, 30, 63][..]),
(GnssSystem::Qzss, &[1u8, 5, 9][..]),
(GnssSystem::Navic, &[1u8, 5, 9, 14][..]),
(GnssSystem::Sbas, &[20u8, 23, 36, 58][..]),
] {
for &prn in prns {
let want = id(sys, prn);
let token = want.to_string(); let got = super::parse_sv_token(&token, Some(Sp3Version::D))
.unwrap_or_else(|| panic!("token {token:?} failed to parse"));
assert_eq!(got, want);
}
}
}
#[test]
fn sp3a_bare_numeric_prn_is_gps() {
assert_eq!(
super::parse_sv_token(" 7", Some(Sp3Version::A)),
Some(id(GnssSystem::Gps, 7))
);
assert_eq!(
super::parse_sv_token("23", Some(Sp3Version::A)),
Some(id(GnssSystem::Gps, 23))
);
}
#[test]
fn multibyte_line_does_not_panic() {
let file = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
* 2020 6 24 0 0 0.00000000
PG01 15000.000000 -20000.000000 5000.000000 123.456789 \u{00e9}\u{00e9}\u{00e9}
EOF
";
let _ = Sp3::parse(file.as_bytes());
}
#[test]
fn coordinate_sign_and_magnitude_preserved() {
for &km in &[0.000001f64, -12345.678901, 26560.123456, -26560.999999] {
let line = format!("PG01{:14.6}{:14.6}{:14.6}{:14.6}", km, km, km, 0.0);
let file = format!(
"#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST\n\
## 2111 432000.00000000 900.00000000 59024 0.0000000000000\n\
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n\
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n\
* 2020 6 24 0 0 0.00000000\n\
{line}\n\
EOF\n"
);
let sp3 = Sp3::parse(file.as_bytes()).unwrap();
let st = sp3.state(id(GnssSystem::Gps, 1), 0).unwrap();
assert_eq!(st.position.x_m, km * 1_000.0, "km={km}");
assert_eq!(st.position.z_m, km * 1_000.0, "km={km}");
}
}
fn assert_round_trip(original: &Sp3) {
let text = original.to_sp3_string();
let reparsed = Sp3::parse(text.as_bytes())
.unwrap_or_else(|e| panic!("re-parse of written SP3 failed: {e}\n--- written ---\n{text}"));
assert_eq!(reparsed.header.version, original.header.version, "version");
assert_eq!(
reparsed.header.time_scale, original.header.time_scale,
"time scale"
);
assert_eq!(
reparsed.header.time_system, original.header.time_system,
"time system"
);
assert_eq!(
reparsed.header.coordinate_system, original.header.coordinate_system,
"coordinate system"
);
assert_eq!(
reparsed.header.satellites, original.header.satellites,
"satellite list"
);
assert_eq!(
reparsed.header.satellite_accuracy_codes, original.header.satellite_accuracy_codes,
"satellite accuracy codes"
);
assert_eq!(
reparsed.header.num_epochs, original.header.num_epochs,
"header epoch count"
);
assert_eq!(reparsed.comments, original.comments, "comments");
assert_eq!(reparsed.epochs.len(), original.epochs.len(), "epoch count");
for i in 0..original.epochs.len() {
let ja = original.epochs[i].julian_date().unwrap();
let jb = reparsed.epochs[i].julian_date().unwrap();
assert!(
((ja.jd_whole + ja.fraction) - (jb.jd_whole + jb.fraction)).abs() < 1.0e-9,
"epoch {i} time differs"
);
let sa = original.states_at(i).unwrap();
let sb = reparsed.states_at(i).unwrap();
let ka: Vec<_> = sa.keys().copied().collect();
let kb: Vec<_> = sb.keys().copied().collect();
assert_eq!(ka, kb, "satellite set at epoch {i}");
for (sat, a) in sa {
let b = &sb[sat];
let (pa, pb) = (a.position.as_array(), b.position.as_array());
for k in 0..3 {
assert!(
(pa[k] - pb[k]).abs() < 1.0e-3,
"epoch {i} {sat:?} pos[{k}] {} vs {}",
pa[k],
pb[k]
);
}
match (a.clock_s, b.clock_s) {
(Some(x), Some(y)) => {
assert!(
(x - y).abs() < 1.0e-12,
"epoch {i} {sat:?} clock {x} vs {y}"
)
}
(None, None) => {}
_ => panic!("epoch {i} {sat:?} clock presence mismatch"),
}
match (a.velocity, b.velocity) {
(Some(x), Some(y)) => {
let (xa, yb) = (x.as_array(), y.as_array());
for k in 0..3 {
assert!((xa[k] - yb[k]).abs() < 1.0e-3, "epoch {i} {sat:?} vel[{k}]");
}
}
(None, None) => {}
_ => panic!("epoch {i} {sat:?} velocity presence mismatch"),
}
assert_eq!(b.flags, a.flags, "epoch {i} {sat:?} flags");
}
}
}
#[test]
fn writer_round_trips_sp3c_position_clock() {
assert_round_trip(&Sp3::parse(SP3C_FILE.as_bytes()).unwrap());
}
#[test]
fn writer_round_trips_sp3d_multignss_velocity() {
assert_round_trip(&Sp3::parse(SP3D_FILE.as_bytes()).unwrap());
}
#[test]
fn writer_preserves_satellite_accuracy_codes() {
let file = SP3C_FILE.replacen("++ 0 0", "++ 5 17", 1);
let original = Sp3::parse(file.as_bytes()).expect("parse SP3 accuracy codes");
assert_eq!(original.header.satellite_accuracy_codes, vec![5, 17]);
let text = original.to_sp3_string();
let first_accuracy_line = text
.lines()
.find(|line| line.starts_with("++"))
.expect("written accuracy line");
assert!(
first_accuracy_line.starts_with("++ 5 17"),
"writer must preserve non-default accuracy code fields:\n{text}"
);
let reparsed = Sp3::parse(text.as_bytes()).expect("reparse written SP3 accuracy codes");
assert_eq!(
reparsed.header.satellite_accuracy_codes,
original.header.satellite_accuracy_codes
);
assert_eq!(reparsed, original);
}
fn assert_exact_parse_write_parse_round_trip(file: &str) {
let original = Sp3::parse(file.as_bytes()).expect("parse source SP3");
let text = original.to_sp3_string();
let reparsed = Sp3::parse(text.as_bytes())
.unwrap_or_else(|err| panic!("reparse written SP3: {err}\n--- written ---\n{text}"));
assert_eq!(
reparsed, original,
"parse -> write -> parse changed product"
);
}
#[test]
fn writer_does_not_inject_comment_into_no_comment_product() {
let no_comment = "\
#cP2020 6 24 0 0 0.00000000 1 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
* 2020 6 24 0 0 0.00000000
PG01 1.000000 2.000000 3.000000 999999.999999
EOF
";
let original = Sp3::parse(no_comment.as_bytes()).expect("parse no-comment SP3");
assert!(original.comments.is_empty());
let text = original.to_sp3_string();
assert!(
!text.lines().any(|line| line.starts_with("/*")),
"writer must not synthesize a provenance comment:\n{text}"
);
assert_exact_parse_write_parse_round_trip(no_comment);
}
#[test]
fn parser_canonicalizes_declared_epoch_count_to_body_count() {
let mismatched = "\
#cP2020 6 24 0 0 0.00000000 9 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 1 G01 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* DECLARED COUNT MISMATCH
* 2020 6 24 0 0 0.00000000
PG01 1.000000 2.000000 3.000000 999999.999999
EOF
";
let original = Sp3::parse(mismatched.as_bytes()).expect("parse mismatched-count SP3");
assert_eq!(original.epochs.len(), 1);
assert_eq!(original.header.num_epochs, original.epochs.len() as u64);
assert_exact_parse_write_parse_round_trip(mismatched);
}
#[test]
fn writer_round_trips_utc_like_leap_second_epoch_without_hour_24() {
for (label, expected_system) in [("UTC", Sp3TimeSystem::Utc), ("GLO", Sp3TimeSystem::Glonass)] {
let file = SP3C_FILE
.replacen("%c G cc GPS", &format!("%c G cc {label}"), 1)
.replacen(
"* 2020 6 24 0 0 0.00000000",
"* 2016 12 31 23 59 60.00000000",
1,
);
let original = Sp3::parse(file.as_bytes()).expect("parse UTC-like leap-second SP3");
assert_eq!(original.header.time_system, expected_system);
let text = original.to_sp3_string();
assert!(
text.contains("#cP2016 12 31 23 59 60.00000000"),
"line-1 epoch must preserve the accepted leap-second label for {label}:\n{text}"
);
assert!(
text.contains("* 2016 12 31 23 59 60.00000000\n"),
"epoch line must preserve the accepted leap-second label for {label}:\n{text}"
);
assert!(
!text.contains("2016 12 31 24 0 0.00000000"),
"writer must not emit hour 24 for {label}:\n{text}"
);
assert!(
text.contains("* 2020 6 24 0 15 0.00000000\n"),
"ordinary epoch formatting must stay unchanged for {label}:\n{text}"
);
let reparsed = Sp3::parse(text.as_bytes())
.unwrap_or_else(|err| panic!("reparse written {label} leap-second SP3: {err}"));
assert_eq!(reparsed.epochs, original.epochs);
assert_eq!(reparsed.header.time_system, expected_system);
}
}
#[test]
fn writer_is_deterministic() {
let sp3 = Sp3::parse(SP3D_FILE.as_bytes()).unwrap();
assert_eq!(
sp3.to_sp3_string(),
sp3.to_sp3_string(),
"byte-identical output"
);
}
#[test]
fn writer_round_trips_record_flags() {
let flags_file = "\
#dP2022 1 2 3 4 5.00000000 1 ORBIT IGS20 FIT TST
## 2191 270245.00000000 300.00000000 59581 0.1281597222222
+ 1 G05 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c M cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3 FLAGS
* 2022 1 2 3 4 5.00000000
PG05 10000.000000 20000.000000 30000.000000 -50.000000 EP MP
EOF
";
let original = Sp3::parse(flags_file.as_bytes()).expect("parse SP3 with all flags");
let expected = Sp3Flags {
clock_event: true,
clock_predicted: true,
maneuver: true,
orbit_predicted: true,
};
assert_eq!(
original.state(id(GnssSystem::Gps, 5), 0).unwrap().flags,
expected
);
let text = original.to_sp3_string();
let p_line = text
.lines()
.find(|line| line.starts_with("PG05"))
.expect("written P record");
assert_eq!(p_line.as_bytes().get(74), Some(&b'E'));
assert_eq!(p_line.as_bytes().get(75), Some(&b'P'));
assert_eq!(p_line.as_bytes().get(78), Some(&b'M'));
assert_eq!(p_line.as_bytes().get(79), Some(&b'P'));
let reparsed = Sp3::parse(text.as_bytes()).expect("reparse written SP3 flags");
assert_eq!(
reparsed.state(id(GnssSystem::Gps, 5), 0).unwrap().flags,
expected
);
}
#[test]
fn writer_emits_velocity_records_for_missing_velocity_cells() {
let mut sp3 = Sp3::parse(SP3D_FILE.as_bytes()).unwrap();
let missing_velocity = id(GnssSystem::Galileo, 11);
let absent_sat = id(GnssSystem::BeiDou, 30);
{
let state = sp3.states[0]
.get_mut(&missing_velocity)
.expect("fixture state");
state.velocity = None;
state.clock_rate_s_s = None;
}
sp3.states[0]
.remove(&absent_sat)
.expect("fixture absent-sat state");
let text = sp3.to_sp3_string();
let lines: Vec<_> = text.lines().collect();
let data_record_count = lines
.iter()
.filter(|line| line.starts_with('P') || line.starts_with('V'))
.count();
assert_eq!(
data_record_count,
sp3.header.satellites.len() * 2,
"each header satellite must have a P+V record:\n{text}"
);
for (p, v) in [("PG05", "VG05"), ("PE11", "VE11"), ("PC30", "VC30")] {
let p_idx = lines
.iter()
.position(|line| line.starts_with(p))
.unwrap_or_else(|| panic!("missing {p} in:\n{text}"));
assert!(
lines.get(p_idx + 1).is_some_and(|line| line.starts_with(v)),
"{p} must be followed by {v} in:\n{text}"
);
}
for prefix in ["VE11", "VC30"] {
let line = lines
.iter()
.find(|line| line.starts_with(prefix))
.unwrap_or_else(|| panic!("missing {prefix} in:\n{text}"));
let fields: Vec<_> = line.split_whitespace().collect();
assert_eq!(
fields.as_slice(),
&[prefix, "0.000000", "0.000000", "0.000000", "999999.999999"],
"{prefix} must use the SP3 missing-velocity and bad-clock-rate sentinels"
);
}
let reparsed = Sp3::parse(text.as_bytes()).expect("reparse velocity sentinels");
let state = reparsed.state(missing_velocity, 0).unwrap();
assert!(
state.velocity.is_none(),
"missing velocity must not reparse as zero velocity"
);
assert!(state.clock_rate_s_s.is_none());
assert!(
reparsed.state(absent_sat, 0).is_err(),
"absent satellite must remain absent after P+V sentinel records"
);
}
#[test]
fn writer_emits_missing_satellite_as_sentinel_not_fabricated() {
let original = Sp3::parse(SP3C_FILE.as_bytes()).unwrap();
let text = original.to_sp3_string();
assert!(
text.contains("PG02 0.000000 0.000000 0.000000"),
"absent G02 must be the 0,0,0 missing sentinel:\n{text}"
);
let reparsed = Sp3::parse(text.as_bytes()).unwrap();
assert!(
reparsed.state(id(GnssSystem::Gps, 2), 1).is_err(),
"G02 must read back as absent at epoch 1"
);
assert!(
reparsed.state(id(GnssSystem::Gps, 2), 0).is_ok(),
"G02 is present at epoch 0"
);
}
#[test]
fn writer_round_trips_a_real_full_day_fixture() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/sp3/GRG0MGXFIN_20201760000_01D_15M_ORB.SP3"
);
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read GRG fixture {path}: {e}"));
let original = Sp3::parse(&bytes).expect("parse GRG fixture");
assert_eq!(
original.header.satellites.len(),
75,
"fixture satellite count"
);
assert_eq!(original.epochs.len(), 96, "fixture epoch count");
assert_round_trip(&original);
}
#[test]
fn rejects_position_record_for_undeclared_satellite() {
let bytes = include_bytes!(
"../../../../fuzz/corpus/sp3_round_trip/regression-undeclared-sat-roundtrip"
);
let err = super::Sp3::parse(bytes).expect_err("undeclared-satellite SP3 must be rejected");
match err {
crate::error::Error::Parse(msg) => {
assert!(
msg.contains("not in the header satellite list"),
"got: {msg}"
);
}
other => panic!("expected a parse error, got {other:?}"),
}
}
#[test]
fn skips_out_of_range_satellite_position_record_and_counts_it() {
const FILE: &str = "\
#cP2020 6 24 0 0 0.00000000 2 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 2 G01G02 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3-c FIXTURE
* 2020 6 24 0 0 0.00000000
PG01 15000.000000 -20000.000000 5000.000000 123.456789
PR28 16000.000000 -21000.000000 6000.000000 222.222222
PG02 -1234.567890 2345.678901 -3456.789012 111.111111
EOF
";
let sp3 = Sp3::parse(FILE.as_bytes()).expect("file with one out-of-range sat must still parse");
assert_eq!(sp3.skipped_records, 1, "the R28 record must be counted");
let states = sp3.states_at(0).expect("epoch 0 present");
let present: Vec<u8> = states.keys().map(|s| s.prn).collect();
assert_eq!(present, vec![1, 2], "both GPS records must survive");
assert!(sp3.state(id(GnssSystem::Gps, 1), 0).is_ok());
assert!(sp3.state(id(GnssSystem::Gps, 2), 0).is_ok());
}
#[test]
fn skips_out_of_range_satellite_header_declaration_and_counts_it() {
const FILE: &str = "\
#cP2020 6 24 0 0 0.00000000 2 ORBIT IGS14 FIT TST
## 2111 432000.00000000 900.00000000 59024 0.0000000000000
+ 3 G01R28G02 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 5 9 17 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c G cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3-c FIXTURE
* 2020 6 24 0 0 0.00000000
PG01 15000.000000 -20000.000000 5000.000000 123.456789
PG02 -1234.567890 2345.678901 -3456.789012 111.111111
EOF
";
let sp3 = Sp3::parse(FILE.as_bytes())
.expect("file with one out-of-range header sat must still parse");
assert_eq!(
sp3.skipped_records, 1,
"the R28 header declaration must be counted"
);
let states = sp3.states_at(0).expect("epoch 0 present");
let present: Vec<u8> = states.keys().map(|s| s.prn).collect();
assert_eq!(present, vec![1, 2], "both representable GPS sats survive");
assert_eq!(
sp3.header.satellite_accuracy_codes,
vec![5, 17],
"accuracy codes stay aligned with the surviving satellites"
);
}
#[test]
fn skips_out_of_range_satellite_velocity_record_and_counts_it() {
const FILE: &str = "\
#dV2022 1 2 3 4 5.00000000 1 ORBIT IGS20 FIT TST
## 2191 270245.00000000 300.00000000 59581 0.1281597222222
+ 1 G05 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
++ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
%c M cc GPS ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc
%f 1.2500000 1.025000000 0.00000000000 0.000000000000000
%f 0.0000000 0.000000000 0.00000000000 0.000000000000000
%i 0 0 0 0 0 0 0 0 0
%i 0 0 0 0 0 0 0 0 0
/* TEST SP3-d FIXTURE
* 2022 1 2 3 4 5.00000000
PG05 10000.000000 20000.000000 30000.000000 -50.000000
VG05 10000.000000 -20000.000000 30000.000000 1.000000
VR28 10000.000000 -20000.000000 30000.000000 1.000000
EOF
";
let sp3 = Sp3::parse(FILE.as_bytes()).expect("file with one out-of-range V record must parse");
assert_eq!(sp3.skipped_records, 1, "the VR28 record must be counted");
let state = sp3.state(id(GnssSystem::Gps, 5), 0).expect("G05 present");
assert!(state.velocity.is_some(), "G05 velocity must be applied");
}
#[test]
fn round_trips_plus_line_padded_with_00_zero_fill() {
let bytes = include_bytes!("../../../../fuzz/corpus/sp3_round_trip/valid-gbm-bds-trim.sp3");
let original = Sp3::parse(bytes).expect("GBM fixture must parse");
assert_eq!(
original.skipped_records, 0,
"` 00` zero-fill slots are padding, not skipped satellites"
);
let encoded = original.to_sp3_string();
let reparsed = Sp3::parse(encoded.as_bytes()).expect("re-encoded GBM fixture must reparse");
assert_eq!(
reparsed, original,
"parse -> write -> parse changed product"
);
}