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 super::*;

const GGA_SAMPLE: &str = "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n";

#[test]
fn parses_gga_sentence_fields() {
    let parsed = parse_sentence(GGA_SAMPLE).expect("parse GGA");
    assert!(parsed.diagnostics.is_empty());
    assert_eq!(
        parsed.value.talker,
        NmeaTalker::System(crate::GnssSystem::Gps)
    );
    let NmeaBody::Gga(gga) = parsed.value.body else {
        panic!("expected GGA");
    };
    assert_eq!(
        gga.time,
        Some(NmeaTime {
            hour: 12,
            minute: 35,
            second: 19,
            nanos: 0,
            decimals: 0,
        })
    );
    assert_eq!(gga.quality, Some(GgaQuality::GpsSps));
    assert_eq!(gga.satellites_used, Some(8));
    assert_eq!(gga.hdop, Some(0.9));
    assert_eq!(gga.altitude_msl_m, Some(545.4));
    assert_eq!(gga.geoid_separation_m, Some(46.9));
    assert_eq!(gga.latitude.expect("latitude").degrees, 48);
    assert_eq!(gga.longitude.expect("longitude").degrees, 11);
}

#[test]
fn rejects_checksum_mismatch_before_decoding() {
    let error = parse_sentence("$GPGGA,123519,4807.038,N,01131.000,E,9,08,0.9,545.4,M,46.9,M,,*47")
        .expect_err("checksum mismatch");
    assert!(matches!(error, NmeaError::ChecksumMismatch { .. }));

    let parsed =
        parse_nmea_str("$GPGGA,123519,4807.038,N,01131.000,E,9,08,0.9,545.4,M,46.9,M,,*47\n");
    assert!(parsed.value.sentences.is_empty());
    assert_eq!(
        parsed.diagnostics.skips[0].reason,
        SkipReason::InconsistentRecord("checksum mismatch")
    );
}

#[test]
fn rejects_every_wrong_checksum_byte_before_decoding() {
    let body = "GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,";
    let computed = body.bytes().fold(0, |acc, byte| acc ^ byte);
    for stated in 0..=u8::MAX {
        if stated == computed {
            continue;
        }
        let sentence = format!("${body}*{stated:02X}");
        let error = parse_sentence(&sentence).expect_err("checksum mismatch");
        assert!(matches!(
            error,
            NmeaError::ChecksumMismatch {
                computed: got_computed,
                stated: got_stated,
            } if got_computed == computed && got_stated == stated
        ));
    }
}

#[test]
fn unsupported_and_proprietary_sentences_are_typed_skips() {
    let parsed = parse_nmea_str("$GPTXT,01,01,02,message*26\n$PUBX,00*33\n");
    assert!(parsed.value.sentences.is_empty());
    assert_eq!(parsed.diagnostics.skips.len(), 2);
    assert_eq!(
        parsed.diagnostics.skips[0].reason,
        SkipReason::UnsupportedRecordType("unsupported sentence type")
    );
    assert_eq!(
        parsed.diagnostics.skips[1].reason,
        SkipReason::UnsupportedRecordType("proprietary sentence")
    );
}

#[test]
fn encapsulated_and_malformed_checksum_have_specific_skips() {
    let parsed = parse_nmea_str("!AIVDM,1,1,,A,15Muq?002>G?svP00<:O?vN60<0u,0\n$GPGGA,1*ZZ\n");
    assert!(parsed.value.sentences.is_empty());
    assert_eq!(
        parsed.diagnostics.skips[0].reason,
        SkipReason::UnsupportedRecordType("encapsulated sentence")
    );
    assert_eq!(
        parsed.diagnostics.skips[1].reason,
        SkipReason::InconsistentRecord("malformed checksum")
    );
}

#[test]
fn parses_core_sentence_set_and_resolution_tables() {
    let text = "$GPRMC,123520,A,4807.038,N,01131.000,E,22.4,84.4,230394,3.1,W,A,S*72\n\
        $GNGSA,A,3,01,02,03,04,,,,,,,,,1.5,0.9,1.2,1*3B\n\
        $GPGSV,2,1,05,01,45,083,42,02,17,308,40,03,25,120,39,04,10,200,35,1*68\n\
        $GPGSV,2,2,05,05,05,010,30,,,,,,,,,1*53\n\
        $GPGST,123520,1.2,3.4,2.3,45.0,0.5,0.6,0.7*4E\n\
        $GPVTG,84.4,T,83.1,M,22.4,N,41.5,K,A*25\n\
        $GPGLL,4807.038,N,01131.000,E,123520,A,A*42\n\
        $GPZDA,123520,23,03,1994,00,00*48\n";
    let parsed = parse_nmea_str(text);
    assert!(
        parsed.diagnostics.skips.is_empty(),
        "{:?}",
        parsed.diagnostics
    );
    assert_eq!(parsed.value.sentences.len(), 8);

    let NmeaBody::Rmc(rmc) = &parsed.value.sentences[0].body else {
        panic!("expected RMC");
    };
    assert_eq!(
        rmc.date,
        Some(NmeaDate {
            year: 1994,
            month: 3,
            day: 23,
        })
    );
    assert_eq!(rmc.magnetic_variation_deg, Some(-3.1));

    let NmeaBody::Gsa(gsa) = &parsed.value.sentences[1].body else {
        panic!("expected GSA");
    };
    assert_eq!(gsa.system, Some(crate::GnssSystem::Gps));
    assert_eq!(gsa.satellites[0].resolved.unwrap().to_string(), "G01");

    let NmeaBody::Gsv(gsv) = &parsed.value.sentences[2].body else {
        panic!("expected GSV");
    };
    assert_eq!(
        gsv.signal.unwrap().carrier_band(),
        Some(crate::frequencies::CarrierBand::L1)
    );
    assert_eq!(gsv.satellites[0].sat_number.unwrap().raw, 1);

    let epochs = group_epochs(&parsed.value);
    assert_eq!(epochs.len(), 1);
    assert_eq!(epochs[0].gsa.len(), 1);
    assert_eq!(epochs[0].gsv.len(), 1);
    assert!(epochs[0].gsv[0].complete);
    assert_eq!(epochs[0].satellites_in_view(), 5);
    assert_eq!(epochs[0].hdop(), Some(0.9));
    assert_eq!(epochs[0].date.unwrap().year, 1994);
}

#[test]
fn missing_checksum_decodes_with_warning() {
    let parsed = parse_sentence("$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,")
        .expect("parse without checksum");
    assert_eq!(parsed.diagnostics.warnings.len(), 1);
    assert_eq!(
        parsed.diagnostics.warnings[0].kind,
        WarningKind::MissingMetadata
    );
}

#[test]
fn write_gga_round_trips_and_emits_checksum() {
    let gga = Gga {
        time: Some(NmeaTime {
            hour: 12,
            minute: 35,
            second: 19,
            nanos: 0,
            decimals: 2,
        }),
        latitude: Some(NmeaCoordinate::parse("4807.038", "N", true).unwrap()),
        longitude: Some(NmeaCoordinate::parse("01131.000", "E", false).unwrap()),
        quality: Some(GgaQuality::GpsSps),
        satellites_used: Some(8),
        hdop: Some(0.9),
        altitude_msl_m: Some(545.4),
        geoid_separation_m: Some(46.9),
        differential_age_s: None,
        differential_station_id: None,
    };
    let sentence = write_gga(NmeaTalker::System(crate::GnssSystem::Gps), &gga).unwrap();
    assert_eq!(
        sentence,
        "$GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.90,545.4,M,46.9,M,,*59\r\n"
    );
    let round_trip = parse_sentence(&sentence).expect("parse written GGA");
    let NmeaBody::Gga(parsed_gga) = round_trip.value.body else {
        panic!("expected GGA");
    };
    assert_eq!(parsed_gga, gga);
    assert_eq!(
        write_gga(round_trip.value.talker, &parsed_gga).unwrap(),
        sentence
    );
}

#[test]
fn write_gga_rejects_time_without_exact_two_decimal_contract() {
    let mut gga = Gga {
        time: Some(NmeaTime {
            hour: 12,
            minute: 35,
            second: 19,
            nanos: 0,
            decimals: 0,
        }),
        latitude: Some(NmeaCoordinate::parse("4807.038", "N", true).unwrap()),
        longitude: Some(NmeaCoordinate::parse("01131.000", "E", false).unwrap()),
        quality: Some(GgaQuality::GpsSps),
        satellites_used: Some(8),
        hdop: Some(0.9),
        altitude_msl_m: Some(545.4),
        geoid_separation_m: Some(46.9),
        differential_age_s: None,
        differential_station_id: None,
    };
    let error = write_gga(NmeaTalker::System(crate::GnssSystem::Gps), &gga)
        .expect_err("decimal count is part of writer contract");
    assert!(matches!(
        error,
        NmeaError::InvalidInput {
            field: "time",
            reason: "GGA writer requires NmeaTime.decimals == 2",
        }
    ));

    gga.time.as_mut().unwrap().decimals = 2;
    gga.time.as_mut().unwrap().nanos = 123_000_000;
    let error = write_gga(NmeaTalker::System(crate::GnssSystem::Gps), &gga)
        .expect_err("centisecond grid is part of writer contract");
    assert!(matches!(
        error,
        NmeaError::InvalidInput {
            field: "time",
            reason: "GGA writer emits exactly two fractional decimals",
        }
    ));
}

#[test]
fn coordinate_degrees_f64_keeps_expected_bit_pattern() {
    let latitude = NmeaCoordinate::parse("4807.038", "N", true).unwrap();
    let longitude = NmeaCoordinate::parse("01131.000", "W", false).unwrap();
    assert_eq!(latitude.degrees_f64().to_bits(), 0x4048_0f03_afb7_e910);
    assert_eq!(longitude.degrees_f64().to_bits(), 0xc027_0888_8888_8889);
}

#[test]
fn vrs_position_carries_coordinate_rounding_into_next_degree() {
    let position = crate::Wgs84Geodetic::new(
        (12.0_f64 + 59.999_9 / 60.0).to_radians(),
        -(1.0_f64 + 59.999_9 / 60.0).to_radians(),
        10.0,
    )
    .unwrap();
    let gga = Gga::vrs_position(
        position,
        NmeaTime {
            hour: 1,
            minute: 2,
            second: 3,
            nanos: 450_000_000,
            decimals: 2,
        },
        GgaQuality::GpsSps,
        8,
        0.9,
        2,
    )
    .unwrap();
    let latitude = gga.latitude.unwrap();
    let longitude = gga.longitude.unwrap();
    assert_eq!((latitude.degrees, latitude.minutes_scaled), (13, 0));
    assert_eq!((longitude.degrees, longitude.minutes_scaled), (2, 0));
    assert!(!latitude.negative);
    assert!(longitude.negative);
}

#[test]
fn accumulator_splits_gga_epochs_by_exact_time() {
    let log = parse_nmea_str(
        "$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\n\
         $GPGGA,123520,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*4D\n",
    );
    assert!(log.diagnostics.is_empty());
    let epochs = group_epochs(&log.value);
    assert_eq!(epochs.len(), 2);
    assert_eq!(epochs[0].time_of_day.expect("time").second, 19);
    assert_eq!(epochs[1].time_of_day.expect("time").second, 20);
}

#[test]
fn accumulator_reports_duplicate_budget_and_midnight_diagnostics() {
    let first =
        parse_sentence("$GPGGA,123519.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*69")
            .unwrap()
            .value;
    let next =
        parse_sentence("$GPGGA,123520.00,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*63")
            .unwrap()
            .value;
    let mut accumulator = NmeaAccumulator::new();
    assert!(accumulator.push(&first).is_none());
    assert!(accumulator.push(&first).is_none());
    let completed = accumulator.push(&next).unwrap();
    assert_eq!(completed.diagnostics.warnings.len(), 1);

    let mut accumulator = NmeaAccumulator::new().with_max_sentences_per_epoch(16);
    for _ in 0..16 {
        assert!(accumulator.push(&first).is_none());
    }
    let completed = accumulator.push(&first).unwrap();
    assert!(!completed.diagnostics.warnings.is_empty());

    let late = parse_sentence("$GPGGA,235959,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*4B")
        .unwrap()
        .value;
    let early = parse_sentence("$GPGGA,000000,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*4A")
        .unwrap()
        .value;
    let date = NmeaDate {
        year: 2026,
        month: 7,
        day: 2,
    };
    let mut accumulator = NmeaAccumulator::with_date(date);
    accumulator.push(&late);
    let completed = accumulator.push(&early).unwrap();
    assert_eq!(completed.date, Some(date));
    assert_eq!(accumulator.finish().unwrap().date.unwrap().day, 3);
}

#[test]
fn push_bytes_is_bounded_line_numbered_and_split_independent() {
    let mut accumulator = NmeaAccumulator::new();
    let output = accumulator.push_bytes(&vec![b'X'; 1100]);
    assert_eq!(accumulator.retained_len(), 0);
    assert_eq!(output.diagnostics.skips[0].at.line, Some(1));
    assert_eq!(
        output.diagnostics.skips[0].reason,
        SkipReason::InconsistentRecord("sentence over length cap")
    );

    let text = b"$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47\r\n$GPGGA,123520,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*4D\r\n";
    let mut whole = NmeaAccumulator::new();
    let whole_out = whole.push_bytes(text);
    let whole_tail = whole.finish();

    let mut split = NmeaAccumulator::new();
    let mut split_sentences = Vec::new();
    let mut split_snapshots = Vec::new();
    for chunk in text.chunks(7) {
        let out = split.push_bytes(chunk);
        split_sentences.extend(out.sentences);
        split_snapshots.extend(out.snapshots);
    }
    let split_tail = split.finish();
    assert_eq!(whole_out.sentences, split_sentences);
    assert_eq!(whole_out.snapshots, split_snapshots);
    assert_eq!(whole_tail, split_tail);
}