sidereon-core 0.11.1

The complete Sidereon engine: numerical astrodynamics propagation core plus the GNSS domain layer (SP3, broadcast ephemeris, multi-GNSS positioning, RTK/PPP, ionosphere/troposphere, DOP) behind a default-on gnss feature
Documentation
use std::collections::BTreeMap;
use std::fmt::Write as _;

use crate::format::fmtnum::fixed_decimals;
use crate::id::GnssSystem;
use crate::rinex_qc::Severity;

use super::{
    IntervalSource, MpStats, ObservationQcHeader, ObservationQcReport, ObservationQcTime, SnrStats,
};

const SNR_COL_WIDTH: usize = 96;

pub fn render_text(report: &ObservationQcReport) -> String {
    let mut out = String::new();
    writeln!(&mut out, "RINEX OBSERVATION QC +QC SUMMARY").expect("write to string");
    writeln!(&mut out).expect("write to string");
    write_header(&mut out, report);
    writeln!(&mut out).expect("write to string");
    write_system_table(&mut out, report);
    writeln!(&mut out).expect("write to string");
    write_findings(&mut out, report);
    out
}

#[derive(Debug, Clone)]
pub(super) struct SystemRenderRow {
    pub system_letter: char,
    pub system_name: &'static str,
    pub satellites_seen: usize,
    pub epochs: usize,
    pub observations: usize,
    pub expected: usize,
    pub completeness: String,
    pub snr: String,
    pub mp1: String,
    pub mp2: String,
    pub slips: usize,
    pub gaps: usize,
    pub gap_s: String,
}

pub(super) fn system_rows(report: &ObservationQcReport) -> Vec<SystemRenderRow> {
    let slips_by_system = report
        .cycle_slips
        .by_system
        .iter()
        .map(|row| (row.system, row.slips))
        .collect::<BTreeMap<_, _>>();
    let mp_by_system = report
        .multipath
        .systems
        .iter()
        .map(|row| (row.system, row))
        .collect::<BTreeMap<_, _>>();

    report
        .systems
        .iter()
        .map(|row| {
            let mp = mp_by_system.get(&row.system).copied();
            SystemRenderRow {
                system_letter: row.system.letter(),
                system_name: row.system.as_str(),
                satellites_seen: row.satellites_seen,
                epochs: row.epochs_with_observations,
                observations: row.value_observations,
                expected: row.expected_observations,
                completeness: format_optional_ratio(row.completeness_ratio),
                snr: fit_ascii(&snr_by_band(report, row.system), SNR_COL_WIDTH),
                mp1: format_optional_mp(mp.and_then(|mp| mp.mp1)),
                mp2: format_optional_mp(mp.and_then(|mp| mp.mp2)),
                slips: *slips_by_system.get(&row.system).unwrap_or(&0),
                gaps: row.gap_count,
                gap_s: fixed_decimals(row.total_gap_s, 1),
            }
        })
        .collect()
}

pub(super) fn severity_label(severity: Severity) -> &'static str {
    match severity {
        Severity::Fatal => "FATAL",
        Severity::Error => "ERROR",
        Severity::Warning => "WARN",
        Severity::Info => "INFO",
    }
}

pub(super) fn format_epoch_time(time: &ObservationQcTime) -> String {
    let epoch = time.epoch;
    let mut second = fixed_decimals(epoch.second, 7);
    if epoch.second < 10.0 {
        second.insert(0, '0');
    }
    let mut text = format!(
        "{:04}-{:02}-{:02} {:02}:{:02}:{}",
        epoch.year, epoch.month, epoch.day, epoch.hour, epoch.minute, second
    );
    if let Some(scale) = &time.time_scale {
        let _ = write!(text, " {scale}");
    }
    text
}

pub(super) fn format_vec3_m(values: [f64; 3]) -> String {
    format!(
        "{} {} {}",
        fixed_decimals(values[0], 4),
        fixed_decimals(values[1], 4),
        fixed_decimals(values[2], 4)
    )
}

fn write_header(out: &mut String, report: &ObservationQcReport) {
    writeln!(out, "HEADER").expect("write to string");
    let header = &report.header;
    push_header_field(out, "MARKER NAME", header.marker_name.as_deref());
    push_header_field(out, "MARKER NUMBER", header.marker_number.as_deref());
    push_header_field(out, "MARKER TYPE", header.marker_type.as_deref());
    push_header_field(out, "RECEIVER", format_receiver(header).as_deref());
    push_header_field(out, "ANTENNA", format_antenna(header).as_deref());
    push_header_field(
        out,
        "POSITION XYZ M",
        header.approx_position_m.map(format_vec3_m).as_deref(),
    );
    push_header_field(
        out,
        "ANTENNA HEN M",
        header.antenna_delta_hen_m.map(format_vec3_m).as_deref(),
    );
    push_header_field(
        out,
        "TIME FIRST",
        header
            .time_of_first_obs
            .as_ref()
            .map(format_epoch_time)
            .as_deref(),
    );
    push_header_field(
        out,
        "TIME LAST",
        header
            .time_of_last_obs
            .as_ref()
            .map(format_epoch_time)
            .as_deref(),
    );
    push_header_field(out, "INTERVAL S", format_interval(report).as_deref());
    push_header_field(
        out,
        "DURATION S",
        header
            .duration_s
            .map(|value| fixed_decimals(value, 1))
            .as_deref(),
    );
}

fn push_header_field(out: &mut String, label: &str, value: Option<&str>) {
    if let Some(value) = value.filter(|value| !value.is_empty()) {
        writeln!(out, "  {label:<18} {value}").expect("write to string");
    }
}

fn format_receiver(header: &ObservationQcHeader) -> Option<String> {
    header.receiver.as_ref().and_then(|receiver| {
        join_non_empty([
            receiver.number.as_str(),
            receiver.receiver_type.as_str(),
            receiver.version.as_str(),
        ])
    })
}

fn format_antenna(header: &ObservationQcHeader) -> Option<String> {
    header.antenna.as_ref().and_then(|antenna| {
        join_non_empty([antenna.number.as_str(), antenna.antenna_type.as_str()])
    })
}

fn join_non_empty<const N: usize>(parts: [&str; N]) -> Option<String> {
    let joined = parts
        .into_iter()
        .filter(|part| !part.is_empty())
        .collect::<Vec<_>>()
        .join(" / ");
    (!joined.is_empty()).then_some(joined)
}

fn format_interval(report: &ObservationQcReport) -> Option<String> {
    report.interval_s.map(|interval_s| {
        format!(
            "{} ({})",
            fixed_decimals(interval_s, 3),
            interval_source_label(report.interval_source)
        )
    })
}

fn interval_source_label(source: IntervalSource) -> &'static str {
    match source {
        IntervalSource::Override => "override",
        IntervalSource::Header => "header",
        IntervalSource::Inferred => "inferred",
        IntervalSource::Unresolved => "unresolved",
    }
}

fn write_system_table(out: &mut String, report: &ObservationQcReport) {
    writeln!(out, "PER-CONSTELLATION").expect("write to string");
    writeln!(
        out,
        "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
        "SYS",
        "NAME",
        "SATS",
        "EPOCHS",
        "OBS",
        "EXPECT",
        "COMP",
        "SNR MEAN/MIN BY BAND",
        "MP1 RMS",
        "MP2 RMS",
        "SLIPS",
        "GAPS",
        "GAP S",
        snr_width = SNR_COL_WIDTH
    )
    .expect("write to string");
    writeln!(
        out,
        "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
        "---",
        "--------",
        "----",
        "------",
        "--------",
        "--------",
        "--------",
        "-".repeat(SNR_COL_WIDTH),
        "--------",
        "--------",
        "------",
        "----",
        "---------",
        snr_width = SNR_COL_WIDTH
    )
    .expect("write to string");

    let rows = system_rows(report);
    if rows.is_empty() {
        writeln!(out, "  NONE").expect("write to string");
        return;
    }

    for row in rows {
        writeln!(
            out,
            "{:<3} {:<8} {:>4} {:>6} {:>8} {:>8} {:>8} {:<snr_width$} {:>8} {:>8} {:>6} {:>4} {:>9}",
            row.system_letter,
            row.system_name,
            row.satellites_seen,
            row.epochs,
            row.observations,
            row.expected,
            row.completeness,
            row.snr,
            row.mp1,
            row.mp2,
            row.slips,
            row.gaps,
            row.gap_s,
            snr_width = SNR_COL_WIDTH
        )
        .expect("write to string");
    }
}

fn write_findings(out: &mut String, report: &ObservationQcReport) {
    writeln!(out, "FINDINGS").expect("write to string");
    writeln!(out, "{:<8} {:<8} SPEC REF", "CODE", "SEVERITY").expect("write to string");
    writeln!(
        out,
        "-------- -------- ------------------------------------------------"
    )
    .expect("write to string");
    if report.lint_findings.is_empty() {
        writeln!(out, "NONE").expect("write to string");
        return;
    }

    for finding in &report.lint_findings {
        writeln!(
            out,
            "{:<8} {:<8} {}",
            finding.code,
            severity_label(finding.severity),
            finding.spec_ref
        )
        .expect("write to string");
    }
}

#[derive(Debug, Clone, Copy, Default)]
struct SnrBandAccum {
    n: usize,
    weighted_mean_sum: f64,
    min: Option<f64>,
}

impl SnrBandAccum {
    fn add(&mut self, stats: SnrStats) {
        self.n += stats.n;
        self.weighted_mean_sum += stats.mean * stats.n as f64;
        self.min = Some(self.min.map_or(stats.min, |min| min.min(stats.min)));
    }

    fn finish(self) -> Option<(f64, f64)> {
        let min = self.min?;
        (self.n > 0).then(|| (self.weighted_mean_sum / self.n as f64, min))
    }
}

fn snr_by_band(report: &ObservationQcReport, system: GnssSystem) -> String {
    let mut bands = BTreeMap::<char, SnrBandAccum>::new();
    for signal in report
        .system_signals
        .iter()
        .filter(|signal| signal.system == system)
    {
        if !signal.code.starts_with('S') {
            continue;
        }
        let Some(stats) = signal.snr else {
            continue;
        };
        let band = signal.code.chars().nth(1).unwrap_or('?');
        bands.entry(band).or_default().add(stats);
    }

    let text = bands
        .into_iter()
        .filter_map(|(band, accum)| {
            let (mean, min) = accum.finish()?;
            Some(format!(
                "{band}:{}/{}",
                fixed_decimals(mean, 1),
                fixed_decimals(min, 1)
            ))
        })
        .collect::<Vec<_>>()
        .join(" ");
    if text.is_empty() {
        "-".to_string()
    } else {
        text
    }
}

fn format_optional_ratio(value: Option<f64>) -> String {
    value
        .filter(|value| value.is_finite())
        .map(|value| fixed_decimals(value, 3))
        .unwrap_or_else(|| "-".to_string())
}

fn format_optional_mp(value: Option<MpStats>) -> String {
    value
        .map(|stats| stats.rms_m)
        .filter(|value| value.is_finite())
        .map(|value| fixed_decimals(value, 3))
        .unwrap_or_else(|| "-".to_string())
}

fn fit_ascii(value: &str, width: usize) -> String {
    if value.len() <= width {
        value.to_string()
    } else {
        value[..width].to_string()
    }
}