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}