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