sidereon-core 0.13.0

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 crate::astro::time::gnss::{seconds_of_week_from_calendar, week_from_calendar};
use crate::astro::time::model::{GnssWeekTow, TimeScale};
use crate::error::{Error, Result};
use crate::id::GnssSatelliteId;

use super::message::SbasWireForm;
use super::store::sbas_prn_to_sat;

#[derive(Clone, Debug, PartialEq)]
pub struct SbasLogBlock {
    pub satellite_id: GnssSatelliteId,
    pub epoch: GnssWeekTow,
    pub form: SbasWireForm,
    pub bytes: Vec<u8>,
}

pub fn parse_ems_lines(text: &str) -> Result<Vec<SbasLogBlock>> {
    let mut out = Vec::new();
    for line in text.lines() {
        if let Some(block) = parse_ems_line(line)? {
            out.push(block);
        }
    }
    Ok(out)
}

pub fn parse_rtklib_lines(text: &str) -> Result<Vec<SbasLogBlock>> {
    let mut out = Vec::new();
    for line in text.lines() {
        if let Some(block) = parse_rtklib_line(line)? {
            out.push(block);
        }
    }
    Ok(out)
}

fn parse_ems_line(line: &str) -> Result<Option<SbasLogBlock>> {
    let parts: Vec<&str> = line
        .split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .collect();
    if parts.len() < 8 {
        return Ok(None);
    }
    let Some(hex) = parts.last().copied().filter(|s| looks_hex(s)) else {
        return Ok(None);
    };
    let Some(prn) = parse_u16(parts[0]) else {
        return Ok(None);
    };
    let Some(satellite_id) = sbas_prn_to_sat(prn) else {
        return Ok(None);
    };
    let Some(year) = parse_i64(parts[1]) else {
        return Ok(None);
    };
    let Some(month) = parse_i64(parts[2]) else {
        return Ok(None);
    };
    let Some(day) = parse_i64(parts[3]) else {
        return Ok(None);
    };
    let Some(hour) = parse_i64(parts[4]) else {
        return Ok(None);
    };
    let Some(minute) = parse_i64(parts[5]) else {
        return Ok(None);
    };
    let Some(second) = parse_i64(parts[6]) else {
        return Ok(None);
    };
    let year = if year < 100 { 2000 + year } else { year };
    let Some(week) = week_from_calendar(TimeScale::Gpst, year, month, day) else {
        return Ok(None);
    };
    let tow_s = seconds_of_week_from_calendar(year, month, day, hour, minute, second);
    let epoch = GnssWeekTow::new(TimeScale::Gpst, week, tow_s)
        .map_err(|e| Error::Parse(format!("invalid SBAS EMS epoch: {e}")))?;
    let (form, bytes) = decode_hex_block(hex)?;
    Ok(Some(SbasLogBlock {
        satellite_id,
        epoch,
        form,
        bytes,
    }))
}

fn parse_rtklib_line(line: &str) -> Result<Option<SbasLogBlock>> {
    let Some((head, hex)) = line.split_once(':') else {
        return Ok(None);
    };
    if !looks_hex(hex.trim()) {
        return Ok(None);
    }
    let fields: Vec<&str> = head.split_whitespace().collect();
    if fields.len() < 4 {
        return Ok(None);
    }
    let Some(week) = parse_u32(fields[0]) else {
        return Ok(None);
    };
    let Some(tow_s) = parse_f64(fields[1]) else {
        return Ok(None);
    };
    let Some(prn) = parse_u16(fields[2]) else {
        return Ok(None);
    };
    let Some(satellite_id) = sbas_prn_to_sat(prn) else {
        return Ok(None);
    };
    let epoch = GnssWeekTow::new(TimeScale::Gpst, week, tow_s)
        .map_err(|e| Error::Parse(format!("invalid SBAS RTKLIB epoch: {e}")))?;
    let (_, bytes) = decode_hex_block(hex.trim())?;
    Ok(Some(SbasLogBlock {
        satellite_id,
        epoch,
        form: SbasWireForm::Body226,
        bytes,
    }))
}

fn decode_hex_block(hex: &str) -> Result<(SbasWireForm, Vec<u8>)> {
    let mut clean: String = hex.chars().filter(|c| !c.is_whitespace()).collect();
    if !clean.len().is_multiple_of(2) {
        clean.push('0');
    }
    let mut bytes = Vec::with_capacity(clean.len() / 2);
    for idx in (0..clean.len()).step_by(2) {
        let byte = u8::from_str_radix(&clean[idx..idx + 2], 16)
            .map_err(|e| Error::Parse(format!("invalid SBAS hex block: {e}")))?;
        bytes.push(byte);
    }
    let form = match bytes.len() {
        32 => SbasWireForm::Framed250,
        29 => SbasWireForm::Body226,
        _ => return Err(Error::Parse("invalid SBAS hex block length".to_string())),
    };
    Ok((form, bytes))
}

fn looks_hex(value: &str) -> bool {
    let trimmed = value.trim();
    !trimmed.is_empty()
        && trimmed
            .chars()
            .all(|c| c.is_ascii_hexdigit() || c.is_whitespace())
}

fn parse_u16(value: &str) -> Option<u16> {
    value.trim().parse().ok()
}

fn parse_u32(value: &str) -> Option<u32> {
    value.trim().parse().ok()
}

fn parse_i64(value: &str) -> Option<i64> {
    value.trim().parse().ok()
}

fn parse_f64(value: &str) -> Option<f64> {
    value.trim().parse().ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sbas::message::{SbasBlock, SbasMessage, SbasPrnMask, SpareBits};

    fn sample_body_hex() -> String {
        let mut mask = [false; 210];
        mask[0] = true;
        let bytes = SbasBlock {
            form: SbasWireForm::Body226,
            message: SbasMessage::PrnMask(SbasPrnMask {
                preamble: 0x53,
                iodp: 1,
                mask,
                reserved: SpareBits::new(),
            }),
        }
        .encode();
        bytes.iter().map(|b| format!("{b:02X}")).collect()
    }

    #[test]
    fn rtklib_lines_parse_body_blocks_and_skip_malformed_lines() {
        let hex = sample_body_hex();
        let text = format!("bad line\n2360 259200 120 1 : {hex}\n");
        let parsed = parse_rtklib_lines(&text).expect("parse RTKLIB lines");
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].satellite_id.to_string(), "S20");
        assert_eq!(parsed[0].form, SbasWireForm::Body226);
        assert_eq!(parsed[0].bytes.len(), 29);
    }

    #[test]
    fn ems_lines_parse_calendar_epochs() {
        let hex = sample_body_hex();
        let text = format!("120,26,7,1,0,0,1,1,{hex}\nnot,enough\n");
        let parsed = parse_ems_lines(&text).expect("parse EMS lines");
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].satellite_id.to_string(), "S20");
        assert_eq!(parsed[0].form, SbasWireForm::Body226);
    }
}