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::fmt::Write as _;

use crate::format::fmtnum::fixed_decimals;

use super::sentence::checksum_body;
use super::{Gga, NmeaCoordinate, NmeaError, NmeaTalker, NmeaTime};

pub fn write_gga(talker: NmeaTalker, gga: &Gga) -> Result<String, NmeaError> {
    if let Some(time) = gga.time {
        if time.decimals != 2 {
            return Err(NmeaError::InvalidInput {
                field: "time",
                reason: "GGA writer requires NmeaTime.decimals == 2",
            });
        }
        if time.nanos % 10_000_000 != 0 {
            return Err(NmeaError::InvalidInput {
                field: "time",
                reason: "GGA writer emits exactly two fractional decimals",
            });
        }
    }
    if gga.latitude.is_some() != gga.longitude.is_some() {
        return Err(NmeaError::InvalidInput {
            field: "position",
            reason: "latitude and longitude must both be present or both absent",
        });
    }

    let talker = talker.code()?;
    let mut body = String::new();
    body.push(char::from(talker[0]));
    body.push(char::from(talker[1]));
    body.push_str("GGA,");
    push_opt(&mut body, gga.time.map(format_time));
    body.push(',');
    push_opt(
        &mut body,
        gga.latitude.map(|coord| format_coordinate(coord, true)),
    );
    body.push(',');
    if let Some(latitude) = gga.latitude {
        body.push(if latitude.negative { 'S' } else { 'N' });
    }
    body.push(',');
    push_opt(
        &mut body,
        gga.longitude.map(|coord| format_coordinate(coord, false)),
    );
    body.push(',');
    if let Some(longitude) = gga.longitude {
        body.push(if longitude.negative { 'W' } else { 'E' });
    }
    body.push(',');
    push_opt(
        &mut body,
        gga.quality.map(|quality| quality.value().to_string()),
    );
    body.push(',');
    push_opt(
        &mut body,
        gga.satellites_used
            .map(|satellites| format!("{satellites:02}")),
    );
    body.push(',');
    push_opt(&mut body, gga.hdop.map(|value| fixed_decimals(value, 2)));
    body.push(',');
    push_opt(
        &mut body,
        gga.altitude_msl_m.map(|value| fixed_decimals(value, 1)),
    );
    body.push(',');
    if gga.altitude_msl_m.is_some() {
        body.push('M');
    }
    body.push(',');
    push_opt(
        &mut body,
        gga.geoid_separation_m.map(|value| fixed_decimals(value, 1)),
    );
    body.push(',');
    if gga.geoid_separation_m.is_some() {
        body.push('M');
    }
    body.push(',');
    push_opt(
        &mut body,
        gga.differential_age_s.map(|value| fixed_decimals(value, 1)),
    );
    body.push(',');
    push_opt(
        &mut body,
        gga.differential_station_id
            .map(|station| format!("{station:04}")),
    );

    let checksum = checksum_body(&body);
    let mut sentence = String::with_capacity(body.len() + 6);
    write!(&mut sentence, "${body}*{checksum:02X}\r\n").expect("write to string");
    Ok(sentence)
}

fn push_opt(body: &mut String, value: Option<String>) {
    if let Some(value) = value {
        body.push_str(&value);
    }
}

fn format_time(time: NmeaTime) -> String {
    format!(
        "{:02}{:02}{:02}.{:02}",
        time.hour,
        time.minute,
        time.second,
        time.nanos / 10_000_000
    )
}

fn format_coordinate(coordinate: NmeaCoordinate, is_latitude: bool) -> String {
    let degree_width = if is_latitude { 2 } else { 3 };
    let scale = 10_u64.pow(u32::from(coordinate.decimals));
    let whole_minutes = coordinate.minutes_scaled / scale;
    let fractional_minutes = coordinate.minutes_scaled % scale;
    if coordinate.decimals == 0 {
        format!("{:0degree_width$}{whole_minutes:02}", coordinate.degrees)
    } else {
        let frac_width = usize::from(coordinate.decimals);
        format!(
            "{:0degree_width$}{whole_minutes:02}.{fractional_minutes:0frac_width$}",
            coordinate.degrees
        )
    }
}