Skip to main content

sidereon_core/sp3/
write.rs

1//! SP3 serialization - the inverse of the parser ([`super::Sp3::parse`]).
2//!
3//! Pure and deterministic: the same [`Sp3`] always produces byte-identical text.
4//! No I/O. A read -> (merge) -> write pipeline round-trips: re-parsing the output
5//! yields the same epochs, satellites, positions, and clocks to SP3 format
6//! precision (mm / sub-ns). Header fields are derived from the product, never
7//! hardcoded; parsed per-satellite accuracy codes are preserved, while the
8//! `%f`/`%i` base descriptors are emitted as standard defaults.
9//!
10//! A satellite absent at an epoch is written as the SP3 missing-orbit sentinel
11//! (`0.0 0.0 0.0`, bad clock), never a fabricated position - so a quarantined
12//! `(sat, epoch)` cell from [`super::merge`] re-reads as missing, not zero. For
13//! velocity products the matching `V` record is still emitted, using the SP3
14//! missing-velocity vector and bad clock-rate sentinel when needed.
15
16use core::fmt::Write as _;
17
18use crate::constants::{KM_TO_M, SECONDS_PER_DAY, US_TO_S};
19
20use super::{
21    Sp3, Sp3DataType, Sp3Flags, Sp3TimeSystem, Sp3Version, BAD_CLOCK_US, CLOCK_RATE_TO_S_PER_S,
22    DM_S_TO_M_S, MISSING_POSITION_KM, MISSING_VELOCITY_DM_S,
23};
24
25/// Maximum SP3 satellite-id slots per `+` / `++` header line.
26const SATS_PER_LINE: usize = 17;
27/// SP3-c fixes five `+`/`++` lines (85 slots); SP3-d may use more.
28const MIN_PLUS_LINES: usize = 5;
29const SP3_TIME_TICKS_PER_SECOND: i64 = 100_000_000;
30const SP3_TIME_TICKS_PER_MINUTE: i64 = 60 * SP3_TIME_TICKS_PER_SECOND;
31const SP3_TIME_TICKS_PER_HOUR: i64 = 60 * SP3_TIME_TICKS_PER_MINUTE;
32const SP3_TIME_TICKS_PER_DAY: i64 = 24 * SP3_TIME_TICKS_PER_HOUR;
33
34impl Sp3 {
35    /// Serialize this product to standard SP3 text (the format named by its
36    /// header version, `c` or `d`).
37    ///
38    /// Pure and deterministic. See this module's docs for the round-trip
39    /// and missing-satellite guarantees.
40    pub fn to_sp3_string(&self) -> String {
41        let mut out =
42            String::with_capacity(self.epochs.len() * (self.header.satellites.len() + 4) * 61);
43        self.write_header(&mut out);
44        self.write_records(&mut out);
45        out.push_str("EOF\n");
46        out
47    }
48
49    fn write_header(&self, out: &mut String) {
50        let h = &self.header;
51        let version = match h.version {
52            Sp3Version::A => 'a',
53            Sp3Version::B => 'b',
54            Sp3Version::C => 'c',
55            Sp3Version::D => 'd',
56        };
57        let dtype = match h.data_type {
58            Sp3DataType::Position => 'P',
59            Sp3DataType::Velocity => 'V',
60        };
61
62        // Line 1: version/type, first-epoch calendar (cosmetic - the parser reads
63        // epochs from the `*` lines), epoch count, data descriptor, coordinate
64        // system, orbit type, agency. Columns match the parser's field offsets.
65        let (y, mo, d, hh, mi, ss) = self
66            .epochs
67            .first()
68            .map(|epoch| julian_to_civil(epoch, h.time_system))
69            .unwrap_or((2000, 1, 1, 0, 0, 0.0));
70        let dt = format_calendar(y, mo, d, hh, mi, ss);
71        let _ = writeln!(
72            out,
73            "#{version}{dtype}{dt} {n:>7} {data:<5}{coord:>6}{orbit:>4} {agency}",
74            n = self.epochs.len(),
75            data = "ORBIT",
76            coord = h.coordinate_system,
77            orbit = h.orbit_type,
78            agency = h.agency,
79        );
80
81        // Line 2 (`##`): GPS week, seconds-of-week, epoch interval, MJD, MJD frac.
82        let _ = writeln!(
83            out,
84            "## {wk:>4} {sow:15.8} {interval:14.8} {mjd:>5} {frac:.13}",
85            wk = h.gnss_week,
86            sow = h.seconds_of_week,
87            interval = h.epoch_interval_s,
88            mjd = h.mjd,
89            frac = h.mjd_fraction,
90        );
91
92        // `+` satellite-id lines and `++` accuracy-exponent lines.
93        let sats = &h.satellites;
94        let n_lines = MIN_PLUS_LINES.max(sats.len().div_ceil(SATS_PER_LINE));
95        for line in 0..n_lines {
96            // `+` line: first carries the count in columns 3-5; all start ids at 9.
97            if line == 0 {
98                let _ = write!(out, "+  {:>3}   ", sats.len());
99            } else {
100                out.push_str("+        ");
101            }
102            for slot in 0..SATS_PER_LINE {
103                match sats.get(line * SATS_PER_LINE + slot) {
104                    Some(sat) => {
105                        let _ = write!(out, "{sat}");
106                    }
107                    None => out.push_str("  0"),
108                }
109            }
110            out.push('\n');
111        }
112        for line in 0..n_lines {
113            out.push_str("++       ");
114            for slot in 0..SATS_PER_LINE {
115                let idx = line * SATS_PER_LINE + slot;
116                let code = if idx < sats.len() {
117                    h.satellite_accuracy_codes.get(idx).copied().unwrap_or(0)
118                } else {
119                    0
120                };
121                let _ = write!(out, "{code:>3}");
122            }
123            out.push('\n');
124        }
125
126        // `%c` descriptors - the first carries the time system at columns 9-11
127        // (the only `%c` content the parser reads). `%f`/`%i` are standard
128        // base/accuracy descriptors the parser skips.
129        let tsys = h.time_system.label();
130        let _ = writeln!(
131            out,
132            "%c M  cc {tsys} ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc"
133        );
134        out.push_str("%c cc cc ccc ccc cccc cccc cccc cccc ccccc ccccc ccccc ccccc\n");
135        out.push_str("%f  1.2500000  1.025000000  0.00000000000  0.000000000000000\n");
136        out.push_str("%f  0.0000000  0.000000000  0.00000000000  0.000000000000000\n");
137        out.push_str("%i    0    0    0    0      0      0      0      0         0\n");
138        out.push_str("%i    0    0    0    0      0      0      0      0         0\n");
139
140        // Provenance comments (e.g. merge derivation) are preserved when present.
141        for comment in &self.comments {
142            let _ = writeln!(out, "/* {comment}");
143        }
144    }
145
146    fn write_records(&self, out: &mut String) {
147        let with_velocity = matches!(self.header.data_type, Sp3DataType::Velocity);
148        for (idx, epoch) in self.epochs.iter().enumerate() {
149            let (y, mo, d, hh, mi, ss) = julian_to_civil(epoch, self.header.time_system);
150            let _ = writeln!(out, "*  {}", format_calendar(y, mo, d, hh, mi, ss));
151
152            let states = &self.states[idx];
153            // Every header satellite gets a record at every epoch; an absent one
154            // is the missing-orbit sentinel (so a quarantined cell is "missing",
155            // never a fabricated zero position). Velocity products also get the
156            // paired V record, using the missing-velocity sentinel as needed.
157            for sat in &self.header.satellites {
158                match states.get(sat) {
159                    Some(state) => {
160                        let p = state.position;
161                        let clk = clock_field_us(state.clock_s);
162                        let _ = write!(
163                            out,
164                            "P{sat}{:14.6}{:14.6}{:14.6}{clk:14.6}",
165                            p.x_m / KM_TO_M,
166                            p.y_m / KM_TO_M,
167                            p.z_m / KM_TO_M,
168                        );
169                        write_record_flags(out, state.flags);
170                        out.push('\n');
171                        if with_velocity {
172                            write_velocity_record(out, sat, state.velocity, state.clock_rate_s_s);
173                        }
174                    }
175                    None => {
176                        let _ = writeln!(
177                            out,
178                            "P{sat}{:14.6}{:14.6}{:14.6}{:14.6}",
179                            MISSING_POSITION_KM,
180                            MISSING_POSITION_KM,
181                            MISSING_POSITION_KM,
182                            BAD_CLOCK_US
183                        );
184                        if with_velocity {
185                            write_velocity_record(out, sat, None, None);
186                        }
187                    }
188                }
189            }
190        }
191    }
192}
193
194/// SP3 clock column (microseconds): the bad-clock sentinel when absent.
195fn clock_field_us(clock_s: Option<f64>) -> f64 {
196    match clock_s {
197        Some(s) => s / US_TO_S,
198        None => BAD_CLOCK_US,
199    }
200}
201
202/// SP3 clock-rate column: the bad-clock sentinel when absent.
203fn clock_rate_field(rate_s_s: Option<f64>) -> f64 {
204    match rate_s_s {
205        Some(r) => r / CLOCK_RATE_TO_S_PER_S,
206        None => BAD_CLOCK_US,
207    }
208}
209
210fn write_velocity_record(
211    out: &mut String,
212    sat: &crate::id::GnssSatelliteId,
213    velocity: Option<crate::frame::ItrfVelocityMS>,
214    clock_rate_s_s: Option<f64>,
215) {
216    let ((vx, vy, vz), rate) = match velocity {
217        Some(v) => (
218            (
219                v.vx_m_s / DM_S_TO_M_S,
220                v.vy_m_s / DM_S_TO_M_S,
221                v.vz_m_s / DM_S_TO_M_S,
222            ),
223            clock_rate_field(clock_rate_s_s),
224        ),
225        None => (
226            (
227                MISSING_VELOCITY_DM_S,
228                MISSING_VELOCITY_DM_S,
229                MISSING_VELOCITY_DM_S,
230            ),
231            BAD_CLOCK_US,
232        ),
233    };
234    let _ = writeln!(out, "V{sat}{vx:14.6}{vy:14.6}{vz:14.6}{rate:14.6}");
235}
236
237fn write_record_flags(out: &mut String, flags: Sp3Flags) {
238    let last_col = if flags.orbit_predicted {
239        Some(79)
240    } else if flags.maneuver {
241        Some(78)
242    } else if flags.clock_predicted {
243        Some(75)
244    } else if flags.clock_event {
245        Some(74)
246    } else {
247        None
248    };
249    let Some(last_col) = last_col else {
250        return;
251    };
252
253    for col in 60..=last_col {
254        out.push(match col {
255            74 if flags.clock_event => 'E',
256            75 if flags.clock_predicted => 'P',
257            78 if flags.maneuver => 'M',
258            79 if flags.orbit_predicted => 'P',
259            _ => ' ',
260        });
261    }
262}
263
264/// `YYYY MM DD HH MM SS.SSSSSSSS` in the SP3 epoch-line / line-1 layout.
265fn format_calendar(
266    year: i64,
267    month: i64,
268    day: i64,
269    hour: i64,
270    minute: i64,
271    seconds: f64,
272) -> String {
273    format!("{year:4} {month:>2} {day:>2} {hour:>2} {minute:>2} {seconds:11.8}")
274}
275
276/// Inverse of `super::civil_to_julian_split`: a Julian-date Instant back to the
277/// civil `(year, month, day, hour, minute, seconds)` it was built from.
278fn julian_to_civil(
279    epoch: &crate::astro::time::model::Instant,
280    time_system: Sp3TimeSystem,
281) -> (i64, i64, i64, i64, i64, f64) {
282    let split = match epoch.julian_date() {
283        Some(s) => s,
284        None => return (2000, 1, 1, 0, 0, 0.0),
285    };
286    let ticks =
287        (split.fraction * SECONDS_PER_DAY * SP3_TIME_TICKS_PER_SECOND as f64).round() as i64;
288
289    // `jd_whole` is the `*.5` midnight boundary (jdn - 0.5); recover the integer
290    // Julian Day Number, then the Fliegel-Van Flandern inverse for the calendar.
291    let mut jdn = (split.jd_whole + 0.5).round() as i64;
292    if is_utc_like(time_system)
293        && (SP3_TIME_TICKS_PER_DAY..SP3_TIME_TICKS_PER_DAY + SP3_TIME_TICKS_PER_SECOND)
294            .contains(&ticks)
295    {
296        let (year, month, day) = civil_from_jdn(jdn);
297        let seconds =
298            60.0 + (ticks - SP3_TIME_TICKS_PER_DAY) as f64 / SP3_TIME_TICKS_PER_SECOND as f64;
299        return (year, month, day, 23, 59, seconds);
300    }
301
302    jdn += ticks.div_euclid(SP3_TIME_TICKS_PER_DAY);
303    let ticks = ticks.rem_euclid(SP3_TIME_TICKS_PER_DAY);
304    let (year, month, day) = civil_from_jdn(jdn);
305
306    let hour = ticks / SP3_TIME_TICKS_PER_HOUR;
307    let rem = ticks % SP3_TIME_TICKS_PER_HOUR;
308    let minute = rem / SP3_TIME_TICKS_PER_MINUTE;
309    let seconds = (rem % SP3_TIME_TICKS_PER_MINUTE) as f64 / SP3_TIME_TICKS_PER_SECOND as f64;
310    (year, month, day, hour, minute, seconds)
311}
312
313fn is_utc_like(time_system: Sp3TimeSystem) -> bool {
314    matches!(time_system, Sp3TimeSystem::Glonass | Sp3TimeSystem::Utc)
315}
316
317fn civil_from_jdn(jdn: i64) -> (i64, i64, i64) {
318    let l = jdn + 68_569;
319    let n = 4 * l / 146_097;
320    let l = l - (146_097 * n + 3) / 4;
321    let i = 4_000 * (l + 1) / 1_461_001;
322    let l = l - 1_461 * i / 4 + 31;
323    let j = 80 * l / 2_447;
324    let day = l - 2_447 * j / 80;
325    let l = j / 11;
326    let month = j + 2 - 12 * l;
327    let year = 100 * (n - 49) + i + l;
328    (year, month, day)
329}