Skip to main content

sidereon_core/observation_qc/
report_text.rs

1use std::collections::BTreeMap;
2use std::fmt::Write as _;
3
4use crate::format::fmtnum::fixed_decimals;
5use crate::id::GnssSystem;
6use crate::rinex_qc::Severity;
7
8use super::{
9    IntervalSource, MpStats, ObservationQcHeader, ObservationQcReport, ObservationQcTime, SnrStats,
10};
11
12const SNR_COL_WIDTH: usize = 96;
13
14pub fn render_text(report: &ObservationQcReport) -> String {
15    let mut out = String::new();
16    writeln!(&mut out, "RINEX OBSERVATION QC +QC SUMMARY").expect("write to string");
17    writeln!(&mut out).expect("write to string");
18    write_header(&mut out, report);
19    writeln!(&mut out).expect("write to string");
20    write_system_table(&mut out, report);
21    writeln!(&mut out).expect("write to string");
22    write_findings(&mut out, report);
23    out
24}
25
26#[derive(Debug, Clone)]
27pub(super) struct SystemRenderRow {
28    pub system_letter: char,
29    pub system_name: &'static str,
30    pub satellites_seen: usize,
31    pub epochs: usize,
32    pub observations: usize,
33    pub expected: usize,
34    pub completeness: String,
35    pub snr: String,
36    pub mp1: String,
37    pub mp2: String,
38    pub slips: usize,
39    pub gaps: usize,
40    pub gap_s: String,
41}
42
43pub(super) fn system_rows(report: &ObservationQcReport) -> Vec<SystemRenderRow> {
44    let slips_by_system = report
45        .cycle_slips
46        .by_system
47        .iter()
48        .map(|row| (row.system, row.slips))
49        .collect::<BTreeMap<_, _>>();
50    let mp_by_system = report
51        .multipath
52        .systems
53        .iter()
54        .map(|row| (row.system, row))
55        .collect::<BTreeMap<_, _>>();
56
57    report
58        .systems
59        .iter()
60        .map(|row| {
61            let mp = mp_by_system.get(&row.system).copied();
62            SystemRenderRow {
63                system_letter: row.system.letter(),
64                system_name: row.system.as_str(),
65                satellites_seen: row.satellites_seen,
66                epochs: row.epochs_with_observations,
67                observations: row.value_observations,
68                expected: row.expected_observations,
69                completeness: format_optional_ratio(row.completeness_ratio),
70                snr: fit_ascii(&snr_by_band(report, row.system), SNR_COL_WIDTH),
71                mp1: format_optional_mp(mp.and_then(|mp| mp.mp1)),
72                mp2: format_optional_mp(mp.and_then(|mp| mp.mp2)),
73                slips: *slips_by_system.get(&row.system).unwrap_or(&0),
74                gaps: row.gap_count,
75                gap_s: fixed_decimals(row.total_gap_s, 1),
76            }
77        })
78        .collect()
79}
80
81pub(super) fn severity_label(severity: Severity) -> &'static str {
82    match severity {
83        Severity::Fatal => "FATAL",
84        Severity::Error => "ERROR",
85        Severity::Warning => "WARN",
86        Severity::Info => "INFO",
87    }
88}
89
90pub(super) fn format_epoch_time(time: &ObservationQcTime) -> String {
91    let epoch = time.epoch;
92    let mut second = fixed_decimals(epoch.second, 7);
93    if epoch.second < 10.0 {
94        second.insert(0, '0');
95    }
96    let mut text = format!(
97        "{:04}-{:02}-{:02} {:02}:{:02}:{}",
98        epoch.year, epoch.month, epoch.day, epoch.hour, epoch.minute, second
99    );
100    if let Some(scale) = &time.time_scale {
101        let _ = write!(text, " {scale}");
102    }
103    text
104}
105
106pub(super) fn format_vec3_m(values: [f64; 3]) -> String {
107    format!(
108        "{} {} {}",
109        fixed_decimals(values[0], 4),
110        fixed_decimals(values[1], 4),
111        fixed_decimals(values[2], 4)
112    )
113}
114
115fn write_header(out: &mut String, report: &ObservationQcReport) {
116    writeln!(out, "HEADER").expect("write to string");
117    let header = &report.header;
118    push_header_field(out, "MARKER NAME", header.marker_name.as_deref());
119    push_header_field(out, "MARKER NUMBER", header.marker_number.as_deref());
120    push_header_field(out, "MARKER TYPE", header.marker_type.as_deref());
121    push_header_field(out, "RECEIVER", format_receiver(header).as_deref());
122    push_header_field(out, "ANTENNA", format_antenna(header).as_deref());
123    push_header_field(
124        out,
125        "POSITION XYZ M",
126        header.approx_position_m.map(format_vec3_m).as_deref(),
127    );
128    push_header_field(
129        out,
130        "ANTENNA HEN M",
131        header.antenna_delta_hen_m.map(format_vec3_m).as_deref(),
132    );
133    push_header_field(
134        out,
135        "TIME FIRST",
136        header
137            .time_of_first_obs
138            .as_ref()
139            .map(format_epoch_time)
140            .as_deref(),
141    );
142    push_header_field(
143        out,
144        "TIME LAST",
145        header
146            .time_of_last_obs
147            .as_ref()
148            .map(format_epoch_time)
149            .as_deref(),
150    );
151    push_header_field(out, "INTERVAL S", format_interval(report).as_deref());
152    push_header_field(
153        out,
154        "DURATION S",
155        header
156            .duration_s
157            .map(|value| fixed_decimals(value, 1))
158            .as_deref(),
159    );
160}
161
162fn push_header_field(out: &mut String, label: &str, value: Option<&str>) {
163    if let Some(value) = value.filter(|value| !value.is_empty()) {
164        writeln!(out, "  {label:<18} {value}").expect("write to string");
165    }
166}
167
168fn format_receiver(header: &ObservationQcHeader) -> Option<String> {
169    header.receiver.as_ref().and_then(|receiver| {
170        join_non_empty([
171            receiver.number.as_str(),
172            receiver.receiver_type.as_str(),
173            receiver.version.as_str(),
174        ])
175    })
176}
177
178fn format_antenna(header: &ObservationQcHeader) -> Option<String> {
179    header.antenna.as_ref().and_then(|antenna| {
180        join_non_empty([antenna.number.as_str(), antenna.antenna_type.as_str()])
181    })
182}
183
184fn join_non_empty<const N: usize>(parts: [&str; N]) -> Option<String> {
185    let joined = parts
186        .into_iter()
187        .filter(|part| !part.is_empty())
188        .collect::<Vec<_>>()
189        .join(" / ");
190    (!joined.is_empty()).then_some(joined)
191}
192
193fn format_interval(report: &ObservationQcReport) -> Option<String> {
194    report.interval_s.map(|interval_s| {
195        format!(
196            "{} ({})",
197            fixed_decimals(interval_s, 3),
198            interval_source_label(report.interval_source)
199        )
200    })
201}
202
203fn interval_source_label(source: IntervalSource) -> &'static str {
204    match source {
205        IntervalSource::Override => "override",
206        IntervalSource::Header => "header",
207        IntervalSource::Inferred => "inferred",
208        IntervalSource::Unresolved => "unresolved",
209    }
210}
211
212fn write_system_table(out: &mut String, report: &ObservationQcReport) {
213    writeln!(out, "PER-CONSTELLATION").expect("write to string");
214    writeln!(
215        out,
216        "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
217        "SYS",
218        "NAME",
219        "SATS",
220        "EPOCHS",
221        "OBS",
222        "EXPECT",
223        "COMP",
224        "SNR MEAN/MIN BY BAND",
225        "MP1 RMS",
226        "MP2 RMS",
227        "SLIPS",
228        "GAPS",
229        "GAP S",
230        snr_width = SNR_COL_WIDTH
231    )
232    .expect("write to string");
233    writeln!(
234        out,
235        "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
236        "---",
237        "--------",
238        "----",
239        "------",
240        "--------",
241        "--------",
242        "--------",
243        "-".repeat(SNR_COL_WIDTH),
244        "--------",
245        "--------",
246        "------",
247        "----",
248        "---------",
249        snr_width = SNR_COL_WIDTH
250    )
251    .expect("write to string");
252
253    let rows = system_rows(report);
254    if rows.is_empty() {
255        writeln!(out, "  NONE").expect("write to string");
256        return;
257    }
258
259    for row in rows {
260        writeln!(
261            out,
262            "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
263            row.system_letter,
264            row.system_name,
265            row.satellites_seen,
266            row.epochs,
267            row.observations,
268            row.expected,
269            row.completeness,
270            row.snr,
271            row.mp1,
272            row.mp2,
273            row.slips,
274            row.gaps,
275            row.gap_s,
276            snr_width = SNR_COL_WIDTH
277        )
278        .expect("write to string");
279    }
280}
281
282fn write_findings(out: &mut String, report: &ObservationQcReport) {
283    writeln!(out, "FINDINGS").expect("write to string");
284    writeln!(out, "{:<8} {:<8} SPEC REF", "CODE", "SEVERITY").expect("write to string");
285    writeln!(
286        out,
287        "-------- -------- ------------------------------------------------"
288    )
289    .expect("write to string");
290    if report.lint_findings.is_empty() {
291        writeln!(out, "NONE").expect("write to string");
292        return;
293    }
294
295    for finding in &report.lint_findings {
296        writeln!(
297            out,
298            "{:<8} {:<8} {}",
299            finding.code,
300            severity_label(finding.severity),
301            finding.spec_ref
302        )
303        .expect("write to string");
304    }
305}
306
307#[derive(Debug, Clone, Copy, Default)]
308struct SnrBandAccum {
309    n: usize,
310    weighted_mean_sum: f64,
311    min: Option<f64>,
312}
313
314impl SnrBandAccum {
315    fn add(&mut self, stats: SnrStats) {
316        self.n += stats.n;
317        self.weighted_mean_sum += stats.mean * stats.n as f64;
318        self.min = Some(self.min.map_or(stats.min, |min| min.min(stats.min)));
319    }
320
321    fn finish(self) -> Option<(f64, f64)> {
322        let min = self.min?;
323        (self.n > 0).then(|| (self.weighted_mean_sum / self.n as f64, min))
324    }
325}
326
327fn snr_by_band(report: &ObservationQcReport, system: GnssSystem) -> String {
328    let mut bands = BTreeMap::<char, SnrBandAccum>::new();
329    for signal in report
330        .system_signals
331        .iter()
332        .filter(|signal| signal.system == system)
333    {
334        if !signal.code.starts_with('S') {
335            continue;
336        }
337        let Some(stats) = signal.snr else {
338            continue;
339        };
340        let band = signal.code.chars().nth(1).unwrap_or('?');
341        bands.entry(band).or_default().add(stats);
342    }
343
344    let text = bands
345        .into_iter()
346        .filter_map(|(band, accum)| {
347            let (mean, min) = accum.finish()?;
348            Some(format!(
349                "{band}:{}/{}",
350                fixed_decimals(mean, 1),
351                fixed_decimals(min, 1)
352            ))
353        })
354        .collect::<Vec<_>>()
355        .join(" ");
356    if text.is_empty() {
357        "-".to_string()
358    } else {
359        text
360    }
361}
362
363fn format_optional_ratio(value: Option<f64>) -> String {
364    value
365        .filter(|value| value.is_finite())
366        .map(|value| fixed_decimals(value, 3))
367        .unwrap_or_else(|| "-".to_string())
368}
369
370fn format_optional_mp(value: Option<MpStats>) -> String {
371    value
372        .map(|stats| stats.rms_m)
373        .filter(|value| value.is_finite())
374        .map(|value| fixed_decimals(value, 3))
375        .unwrap_or_else(|| "-".to_string())
376}
377
378fn fit_ascii(value: &str, width: usize) -> String {
379    if value.len() <= width {
380        value.to_string()
381    } else {
382        value[..width].to_string()
383    }
384}