Skip to main content

sidereon_core/
constellation.rs

1//! GNSS constellation identity catalog and validation helpers.
2//!
3//! This is a data/catalog layer: it builds normalized satellite identity
4//! records from public sources and compares those records with GNSS products.
5//! It does not alter positioning solves or infer application-specific health
6//! rules. It is deterministic and performs no network access; fetching the
7//! source bytes is the caller's (binding's) job.
8//!
9//! GPS, Galileo, GLONASS, BeiDou, and QZSS are supported. The base source for
10//! every system is a CelesTrak OMM/JSON group (`gps-ops`, `galileo`, `glo-ops`,
11//! `beidou`, and the QZSS members of the `gnss` group); the within-system PRN /
12//! slot is parsed from `OBJECT_NAME` and rendered as the SP3/RINEX id (`"G13"`,
13//! `"E07"`, `"R13"`, `"C19"`, `"J02"`) via [`gnss_sp3_id`]. Each constellation
14//! names its satellites differently, so [`from_celestrak_omm`] dispatches on
15//! [`GnssSystem`] to a per-system identity adapter:
16//!
17//! - **GPS:** `(PRN nn)` in the object name is the PRN directly.
18//! - **BeiDou:** `(Cnn)` in the object name is the PRN directly.
19//! - **QZSS:** `(QZSS/PRN nnn)` carries the broadcast PRN (193..=201); the
20//!   RINEX slot is `nnn - 192` (`J01`..`J09`), per RINEX 3.0x.
21//! - **Galileo:** the object name is the `GSATdddd` build id, which carries no
22//!   PRN; the SVID/PRN is resolved from the published GSAT->SVID table
23//!   [`galileo_prn_for_gsat`].
24//! - **GLONASS:** the parenthesized number is the GLONASS (Uragan) number, not
25//!   the orbital slot; the slot is resolved from the published slot table
26//!   [`glonass_slot_for_number`], and the FDMA frequency-channel number (which
27//!   is not in OMM at all) from [`glonass_fdma_channel`].
28//!
29//! NAVCEN's GPS constellation status page can be parsed and merged as an
30//! optional overlay for SVN and NANU usability details. There is no clean
31//! equivalent health oracle for the other systems, so usability overlays are
32//! GPS-only; the OMM identity round-trip and the GLONASS FDMA check are the
33//! gates for the rest.
34//!
35//! The OMM input is the canonical [`Omm`](crate::astro::omm::Omm) produced by
36//! the core OMM parser (`crate::astro::omm::{parse_json, parse_json_array}`):
37//! this module does not re-parse OMM from scratch, it reads `OBJECT_NAME` and
38//! `NORAD_CAT_ID` off already-parsed records.
39//!
40//! ```
41//! use sidereon_core::constellation::{to_csv, BoolStyle, Record, RecordSource};
42//! use sidereon_core::GnssSystem;
43//!
44//! let record = Record {
45//!     system: GnssSystem::Gps,
46//!     prn: 3,
47//!     svn: None,
48//!     norad_id: 40294,
49//!     sp3_id: "G03".to_string(),
50//!     fdma_channel: None,
51//!     active: true,
52//!     usable: true,
53//!     source: RecordSource::default(),
54//! };
55//! assert_eq!(
56//!     to_csv(&[record], BoolStyle::Lower),
57//!     "prn,norad_cat_id,active,sp3_id\n3,40294,true,G03\n"
58//! );
59//! ```
60
61use crate::astro::omm::Omm;
62use crate::astro::passes::UtcInstant;
63use crate::ephemeris::Sp3;
64use crate::id::GnssSystem;
65use core::fmt::{self, Write as _};
66
67/// The CelesTrak GP group each system's identity base is fetched from.
68///
69/// These mirror the live CelesTrak group names (`gps-ops`, `galileo`, `glo-ops`,
70/// `beidou`); QZSS has no dedicated group and is carried in the combined `gnss`
71/// group, so its records are filtered out of that feed by the caller.
72const fn celestrak_group(system: GnssSystem) -> &'static str {
73    match system {
74        GnssSystem::Gps => "gps-ops",
75        GnssSystem::Galileo => "galileo",
76        GnssSystem::Glonass => "glo-ops",
77        GnssSystem::BeiDou => "beidou",
78        GnssSystem::Qzss => "gnss",
79        GnssSystem::Navic | GnssSystem::Sbas => "gnss",
80    }
81}
82
83/// Failure modes of the constellation catalog builders.
84///
85/// Mirrors the typed error pattern used by the core parsers (for example
86/// `astro::omm::OmmError`): a small enum with a `Display` and `std::error::Error`
87/// implementation, never a panic on malformed input.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ConstellationError {
90    /// A CelesTrak `OBJECT_NAME` did not contain a parseable `(PRN nn)` block,
91    /// or the OMM carried no object name at all. Holds the offending name.
92    MissingPrn(Option<String>),
93    /// The NAVCEN status bytes were not valid UTF-8.
94    NavcenNotUtf8,
95    /// The NAVCEN status HTML contained no GPS constellation rows.
96    NavcenNoRows,
97    /// A required NAVCEN integer cell could not be parsed. Holds the field name
98    /// and the offending text.
99    NavcenBadField {
100        /// The NAVCEN field whose cell failed to parse (for example `gps-prn`).
101        field: &'static str,
102        /// The raw cell text that failed to parse.
103        value: String,
104    },
105    /// A catalog failed SP3 validation. Holds a description of the findings.
106    Sp3Validation(String),
107}
108
109impl fmt::Display for ConstellationError {
110    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111        match self {
112            ConstellationError::MissingPrn(Some(name)) => {
113                write!(f, "CelesTrak OBJECT_NAME has no PRN: {name:?}")
114            }
115            ConstellationError::MissingPrn(None) => {
116                write!(f, "CelesTrak record has no OBJECT_NAME")
117            }
118            ConstellationError::NavcenNotUtf8 => write!(f, "NAVCEN bytes are not valid UTF-8"),
119            ConstellationError::NavcenNoRows => write!(f, "NAVCEN HTML has no GPS rows"),
120            ConstellationError::NavcenBadField { field, value } => {
121                write!(f, "NAVCEN field {field} has invalid integer {value:?}")
122            }
123            ConstellationError::Sp3Validation(msg) => {
124                write!(f, "GNSS catalog failed SP3 validation: {msg}")
125            }
126        }
127    }
128}
129
130impl std::error::Error for ConstellationError {}
131
132/// Per-source provenance kept on a [`Record`].
133///
134/// `active` in a record means the satellite is present in the base identity
135/// source. `usable` is an advisory health flag; for the current GPS path it is
136/// the result carried by a compatible merged NAVCEN row. The clock-free merge
137/// uses NAVCEN's historical immediate classification, while
138/// [`merge_navcen_at`] can carry an explicitly timed forecast assessment.
139#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct RecordSource {
141    /// CelesTrak `gps-ops` identity provenance.
142    pub celestrak: Option<CelestrakSource>,
143    /// NAVCEN overlay that was merged into this record.
144    pub navcen: Option<NavcenSource>,
145    /// A NAVCEN row that matched the PRN but was not merged because its block
146    /// type was incompatible with the CelesTrak identity (a PRN transition).
147    pub navcen_conflict: Option<NavcenSource>,
148}
149
150/// CelesTrak `gps-ops` provenance fields preserved on a record.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct CelestrakSource {
153    /// CelesTrak GP group the record came from (`gps-ops`).
154    pub group: String,
155    /// The OMM `OBJECT_NAME`.
156    pub object_name: Option<String>,
157    /// The OMM `OBJECT_ID` (international designator).
158    pub object_id: Option<String>,
159    /// The OMM `EPOCH`, ISO-8601.
160    pub epoch: Option<String>,
161    /// Block type parsed from the object name (`IIF`, `IIR`, `IIR-M`, `III`).
162    pub block_type: Option<String>,
163}
164
165/// NAVCEN status provenance fields preserved on a record or conflict.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct NavcenSource {
168    /// Space Vehicle Number.
169    pub svn: Option<u16>,
170    /// Block type as reported by NAVCEN.
171    pub block_type: Option<String>,
172    /// Orbital plane letter.
173    pub plane: Option<String>,
174    /// Slot within the plane.
175    pub slot: Option<String>,
176    /// Clock type.
177    pub clock: Option<String>,
178    /// NANU type code (for example `FCSTSUMM`, `UNUSABLE`, `DECOM`).
179    pub nanu_type: Option<String>,
180    /// NANU subject line.
181    pub nanu_subject: Option<String>,
182    /// Whether the row carried an active NANU.
183    pub active_nanu: bool,
184}
185
186/// A normalized GNSS satellite identity record.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Record {
189    /// The constellation. GPS today; the type is system-tagged for extension.
190    pub system: GnssSystem,
191    /// The within-constellation PRN.
192    pub prn: u16,
193    /// Space Vehicle Number, when known (CelesTrak alone leaves this `None`).
194    pub svn: Option<u16>,
195    /// NORAD catalog id.
196    pub norad_id: u32,
197    /// Canonical SP3/RINEX satellite token (`G03`).
198    pub sp3_id: String,
199    /// GLONASS FDMA L1/L2 frequency-channel number (`k`, in `-7..=6`), `None`
200    /// for the CDMA constellations. This is the one identity datum that is not
201    /// present in any OMM feed; it is resolved from the orbital slot via the
202    /// published IGS/MCC slot-channel table ([`glonass_fdma_channel`]).
203    pub fdma_channel: Option<i8>,
204    /// Present in the base identity source.
205    pub active: bool,
206    /// Advisory usability flag.
207    pub usable: bool,
208    /// Source provenance.
209    pub source: RecordSource,
210}
211
212/// A parsed row from NAVCEN's GPS constellation status table.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct NavcenStatus {
215    /// The constellation (GPS).
216    pub system: GnssSystem,
217    /// The within-constellation PRN.
218    pub prn: u16,
219    /// Space Vehicle Number, when present.
220    pub svn: Option<u16>,
221    /// Whether the satellite is usable per the active NANU (if any).
222    pub usable: bool,
223    /// Whether the row carried an active NANU.
224    pub active_nanu: bool,
225    /// NANU type code.
226    pub nanu_type: Option<String>,
227    /// NANU subject line.
228    pub nanu_subject: Option<String>,
229    /// Orbital plane letter.
230    pub plane: Option<String>,
231    /// Slot within the plane.
232    pub slot: Option<String>,
233    /// Block type.
234    pub block_type: Option<String>,
235    /// Clock type.
236    pub clock: Option<String>,
237}
238
239/// A bounded UTC interval carried by a NAVCEN forecast notice.
240///
241/// The interval is half-open: the satellite is affected at `start_utc` and is
242/// no longer affected at `end_utc`.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct NavcenEffectiveInterval {
245    /// First affected UTC instant (inclusive).
246    pub start_utc: UtcInstant,
247    /// First unaffected UTC instant (exclusive).
248    pub end_utc: UtcInstant,
249}
250
251/// Result of interpreting timing fields from a NAVCEN row.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum NavcenTiming {
254    /// The NANU type does not describe a forecast outage interval.
255    NotApplicable,
256    /// A complete, validated UTC interval was parsed.
257    Parsed(NavcenEffectiveInterval),
258    /// The NANU type describes a forecast outage, but no complete trustworthy
259    /// interval could be resolved from the row.
260    Unparseable,
261}
262
263/// A NAVCEN row evaluated for operational usability at an explicit UTC instant.
264///
265/// [`parse_navcen_at`] returns this type instead of silently changing the
266/// clock-free legacy behavior of [`parse_navcen`]. `status.usable` is the result
267/// at `evaluated_at_utc`; the raw NANU type and subject remain in `status`, and
268/// `outage_start` plus `timing` preserve how a forecast interval was resolved.
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct NavcenAssessment {
271    /// Parsed NAVCEN row, with `usable` evaluated at `evaluated_at_utc`.
272    pub status: NavcenStatus,
273    /// Explicit UTC instant used for the usability decision.
274    pub evaluated_at_utc: UtcInstant,
275    /// Cleaned NAVCEN "Outage Start" text. Duplicate cells are joined with
276    /// `" | "` and make bounded forecast timing unparseable.
277    pub outage_start: Option<String>,
278    /// Parsed forecast timing or an explicit non-applicable/ambiguous state.
279    pub timing: NavcenTiming,
280}
281
282/// Validation report for a constellation catalog.
283#[derive(Debug, Clone, PartialEq, Eq, Default)]
284pub struct Validation {
285    /// Active+usable catalog SP3 ids absent from the compared product.
286    pub missing_sp3_ids: Vec<String>,
287    /// `(system, PRN)` pairs that appear in more than one record. Keyed by
288    /// system so a legitimate multi-system catalog (GPS PRN 1 and Galileo PRN 1)
289    /// is not reported as a false duplicate.
290    pub duplicate_prns: Vec<(GnssSystem, u16)>,
291    /// NORAD ids that appear in more than one record.
292    pub duplicate_norad_ids: Vec<u32>,
293    /// `(system, PRN)` pairs that are inactive or unusable.
294    pub inactive_unusable_prns: Vec<(GnssSystem, u16)>,
295    /// SP3 ids present in the product but absent from the active+usable catalog.
296    pub extra_sp3_ids: Vec<String>,
297}
298
299/// A single field change on a PRN that exists in both diffed snapshots.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct FieldChange<T> {
302    /// The constellation.
303    pub system: GnssSystem,
304    /// The PRN.
305    pub prn: u16,
306    /// The value in the previous snapshot.
307    pub from: T,
308    /// The value in the current snapshot.
309    pub to: T,
310}
311
312/// Change report between two catalog snapshots, keyed by `(system, prn)`.
313#[derive(Debug, Clone, PartialEq, Eq, Default)]
314pub struct Diff {
315    /// PRNs present only in the current snapshot.
316    pub added: Vec<Record>,
317    /// PRNs present only in the previous snapshot.
318    pub removed: Vec<Record>,
319    /// NORAD id reassignments on a held PRN.
320    pub norad_reassigned: Vec<FieldChange<u32>>,
321    /// SP3 id changes on a held PRN.
322    pub sp3_id_changed: Vec<FieldChange<String>>,
323    /// SVN changes on a held PRN.
324    pub svn_changed: Vec<FieldChange<Option<u16>>>,
325    /// GLONASS FDMA frequency-channel corrections on a held slot.
326    pub fdma_channel_changed: Vec<FieldChange<Option<i8>>>,
327    /// Activity flips on a held PRN.
328    pub activity_changed: Vec<FieldChange<bool>>,
329    /// Usability flips on a held PRN.
330    pub usability_changed: Vec<FieldChange<bool>>,
331}
332
333/// How the CSV `active` column renders booleans.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
335pub enum BoolStyle {
336    /// `true` / `false` (the conventional CSV form).
337    #[default]
338    Lower,
339    /// `True` / `False` (for a consumer that reads Python booleans).
340    Title,
341}
342
343/// Render the canonical SP3/RINEX satellite token for a constellation + PRN
344/// (`(Gps, 7)` -> `"G07"`, `(Glonass, 13)` -> `"R13"`).
345#[must_use]
346pub fn gnss_sp3_id(system: GnssSystem, prn: u16) -> String {
347    format!("{}{prn:02}", system.letter())
348}
349
350/// The within-system identity an OMM `OBJECT_NAME` resolves to for a system.
351struct Identity {
352    /// The within-constellation PRN / orbital slot (the `nn` in the SP3 token).
353    prn: u16,
354    /// GLONASS FDMA channel, when applicable.
355    fdma_channel: Option<i8>,
356}
357
358/// An OMM record that [`from_celestrak_omm_lenient`] could not resolve to a
359/// [`Record`] for the requested system.
360///
361/// Carries the entry's identity (not just a count) so the caller can triage why
362/// it was skipped: a record from another constellation in a combined feed (QZSS,
363/// or anything else, living in the `gnss` group), versus a satellite of the
364/// requested system whose name does not yet resolve (a freshly launched
365/// GLONASS/Galileo not yet in the published slot/SVID table).
366#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct SkippedOmm {
368    /// The OMM `OBJECT_NAME`, when present.
369    pub object_name: Option<String>,
370    /// The OMM `NORAD_CAT_ID`.
371    pub norad_id: u32,
372}
373
374/// The result of a lenient constellation catalog build: the records that
375/// resolved, plus the OMM entries that did not.
376///
377/// Mirrors the partial-success convention of
378/// [`crate::astro::omm::OmmArray`] / [`crate::astro::sgp4::TleFile`], but keeps
379/// the skipped entries' identities (rather than a bare count) because, unlike a
380/// malformed JSON element, an unresolved OMM here carries a meaningful
381/// `OBJECT_NAME`/`NORAD_CAT_ID` the caller needs to act on.
382#[derive(Debug, Clone, PartialEq, Eq, Default)]
383pub struct Catalog {
384    /// Records built from resolvable OMM entries, sorted by `(system, prn)`.
385    pub records: Vec<Record>,
386    /// Entries whose `OBJECT_NAME` did not resolve to a PRN for the requested
387    /// system, in input order.
388    pub skipped: Vec<SkippedOmm>,
389}
390
391/// Build records for `system` from already-parsed CelesTrak OMM records, failing
392/// on the first unresolvable entry.
393///
394/// The OMM source carries no SVN, so records built from it alone have
395/// `svn: None`; GPS can be enriched afterwards with [`merge_navcen`]. Records
396/// are returned sorted by `(system, prn)`. Fails with
397/// [`ConstellationError::MissingPrn`] when an `OBJECT_NAME` cannot be resolved to
398/// a PRN for `system` (an unparseable name, or a GLONASS/Galileo satellite not
399/// in the published slot/SVID table).
400///
401/// Use this for a single-system feed already filtered to `system` (`gps-ops`,
402/// `glo-ops`, ...), where an unresolvable name is a genuine error. To ingest a
403/// raw combined feed (the `gnss` group carries QZSS plus the other systems, and
404/// freshly launched satellites resolve to `None`) without aborting, use
405/// [`from_celestrak_omm_lenient`].
406pub fn from_celestrak_omm(
407    system: GnssSystem,
408    omms: &[Omm],
409) -> Result<Vec<Record>, ConstellationError> {
410    let mut records = Vec::with_capacity(omms.len());
411    for omm in omms {
412        records.push(record_from_omm(system, omm)?);
413    }
414    records.sort_by_key(|r| (r.system, r.prn));
415    Ok(records)
416}
417
418/// Build records for `system` from already-parsed CelesTrak OMM records,
419/// skipping (rather than aborting on) entries that do not resolve.
420///
421/// The lenient sibling of [`from_celestrak_omm`]: every OMM whose `OBJECT_NAME`
422/// resolves to a PRN for `system` becomes a [`Record`]; every entry that does
423/// not is collected into [`Catalog::skipped`] with its identity. This is what a
424/// binding feeds a raw combined CelesTrak `gnss` feed: filter to one system by
425/// keeping `records` and discarding the `skipped` entries that belong to other
426/// constellations, while still seeing which satellites of `system` failed to
427/// resolve. Resolvable records are returned sorted by `(system, prn)`; no
428/// fabricated record is emitted for a skipped entry.
429#[must_use]
430pub fn from_celestrak_omm_lenient(system: GnssSystem, omms: &[Omm]) -> Catalog {
431    let mut records = Vec::with_capacity(omms.len());
432    let mut skipped = Vec::new();
433    for omm in omms {
434        match record_from_omm(system, omm) {
435            Ok(record) => records.push(record),
436            Err(_) => skipped.push(SkippedOmm {
437                object_name: omm.object_name.clone(),
438                norad_id: omm.norad_cat_id,
439            }),
440        }
441    }
442    records.sort_by_key(|r| (r.system, r.prn));
443    Catalog { records, skipped }
444}
445
446fn record_from_omm(system: GnssSystem, omm: &Omm) -> Result<Record, ConstellationError> {
447    let object_name = omm.object_name.as_deref();
448    let identity = system_identity(system, object_name)
449        .ok_or_else(|| ConstellationError::MissingPrn(omm.object_name.clone()))?;
450
451    Ok(Record {
452        system,
453        prn: identity.prn,
454        svn: None,
455        norad_id: omm.norad_cat_id,
456        sp3_id: gnss_sp3_id(system, identity.prn),
457        fdma_channel: identity.fdma_channel,
458        active: true,
459        usable: true,
460        source: RecordSource {
461            celestrak: Some(CelestrakSource {
462                group: celestrak_group(system).to_string(),
463                object_name: omm.object_name.clone(),
464                object_id: omm.object_id.clone(),
465                epoch: Some(epoch_iso8601(omm)),
466                block_type: block_type_from_object_name(system, object_name),
467            }),
468            navcen: None,
469            navcen_conflict: None,
470        },
471    })
472}
473
474/// Resolve the per-system within-constellation identity from an `OBJECT_NAME`.
475///
476/// Each constellation names its satellites differently in the CelesTrak feeds,
477/// so the adapter is dispatched on [`GnssSystem`]. Returns `None` when the name
478/// cannot be resolved to a valid PRN for the system.
479///
480/// NavIC/IRNSS and SBAS are **deliberately unsupported** here and always return
481/// `None` (no name will resolve), matching the module-level scope: NavIC OMM
482/// names (`IRNSS-1A`, `NVS-01`) carry no PRN and have no published
483/// build-id->PRN table comparable to Galileo's GSAT map, and SBAS PRNs (120..)
484/// are payload assignments not derivable from the geostationary host's name.
485/// Adding either would require a new identity source, not just a name parser; a
486/// caller passing `Navic`/`Sbas` gets an empty catalog rather than a fabricated
487/// record. This is intentional, not an oversight.
488fn system_identity(system: GnssSystem, name: Option<&str>) -> Option<Identity> {
489    match system {
490        GnssSystem::Gps => prn_from_object_name(name).map(|prn| Identity {
491            prn,
492            fdma_channel: None,
493        }),
494        GnssSystem::BeiDou => paren_letter_prn(name, 'C').map(|prn| Identity {
495            prn,
496            fdma_channel: None,
497        }),
498        GnssSystem::Qzss => qzss_slot_from_object_name(name).map(|prn| Identity {
499            prn,
500            fdma_channel: None,
501        }),
502        GnssSystem::Galileo => {
503            let gsat = gsat_from_object_name(name)?;
504            galileo_prn_for_gsat(gsat).map(|prn| Identity {
505                prn,
506                fdma_channel: None,
507            })
508        }
509        GnssSystem::Glonass => {
510            let number = paren_number(name)?;
511            let slot = glonass_slot_for_number(number)?;
512            Some(Identity {
513                prn: slot,
514                fdma_channel: glonass_fdma_channel(slot),
515            })
516        }
517        // NavIC/IRNSS and SBAS are out of scope (see the doc comment above): no
518        // name resolves, so these systems yield an empty catalog rather than a
519        // guessed PRN.
520        GnssSystem::Navic | GnssSystem::Sbas => None,
521    }
522}
523
524fn epoch_iso8601(omm: &Omm) -> String {
525    let e = &omm.epoch;
526    format!(
527        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
528        e.year, e.month, e.day, e.hour, e.minute, e.second, e.microsecond
529    )
530}
531
532/// Parse `(PRN nn)` from a CelesTrak object name, stripping leading zeros.
533///
534/// Matches the reference regex `\(PRN\s*0*([0-9]{1,3})\)` (case-insensitive),
535/// including its *search* semantics: every `(PRN` occurrence is tried, so a
536/// later valid `(PRN nn)` is found even if an earlier `(PRN ...)` does not
537/// parse. The PRN is up to three significant digits and must be positive.
538fn prn_from_object_name(name: Option<&str>) -> Option<u16> {
539    let name = name?;
540    let mut from = 0;
541    while let Some(rel) = find_ci(&name[from..], "(PRN") {
542        let after = from + rel + "(PRN".len();
543        if let Some(prn) = prn_at(&name[after..]) {
544            return Some(prn);
545        }
546        from = after;
547    }
548    None
549}
550
551/// Parse `\s*0*([0-9]{1,3})\)` at the start of `rest`.
552fn prn_at(rest: &str) -> Option<u16> {
553    let rest = rest.trim_start();
554    let bytes = rest.as_bytes();
555
556    let mut i = 0;
557    while i < bytes.len() && bytes[i] == b'0' {
558        i += 1;
559    }
560    let digit_start = i;
561    let mut count = 0;
562    while i < bytes.len() && bytes[i].is_ascii_digit() && count < 3 {
563        i += 1;
564        count += 1;
565    }
566    if i >= bytes.len() || bytes[i] != b')' || digit_start == i {
567        return None;
568    }
569    let value: u16 = rest[digit_start..i].parse().ok()?;
570    (value > 0).then_some(value)
571}
572
573/// Parse a parenthesized `(<letter>nn)` PRN from a CelesTrak object name.
574///
575/// BeiDou names the PRN inline, e.g. `BEIDOU-3 M1 (C19)`; the leading letter is
576/// the RINEX system letter. Reuses the GPS [`prn_at`] digit reader (leading
577/// zeros stripped, up to three significant digits, positive) and the same
578/// search semantics, so a later valid group wins over an earlier bad one.
579fn paren_letter_prn(name: Option<&str>, letter: char) -> Option<u16> {
580    let name = name?;
581    let needle = format!("({letter}");
582    let mut from = 0;
583    while let Some(rel) = find_ci(&name[from..], &needle) {
584        let after = from + rel + needle.len();
585        if let Some(prn) = prn_at(&name[after..]) {
586            return Some(prn);
587        }
588        from = after;
589    }
590    None
591}
592
593/// Parse the first parenthesized integer `(ddd)` from a CelesTrak object name.
594///
595/// GLONASS names carry the GLONASS (Uragan) number this way, e.g.
596/// `COSMOS 2456 (730)`.
597fn paren_number(name: Option<&str>) -> Option<u16> {
598    let name = name?;
599    let open = name.find('(')?;
600    let rest = &name[open + 1..];
601    let close = rest.find(')')?;
602    let digits = rest[..close].trim();
603    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
604        return None;
605    }
606    digits.parse().ok()
607}
608
609/// Parse the QZSS RINEX slot from a CelesTrak object name.
610///
611/// QZSS names carry the broadcast PRN, e.g. `QZS-2 (QZSS/PRN 194)`; the RINEX
612/// slot is `PRN - 192` (`J01`..`J09`), per RINEX 3.0x. Broadcast PRNs outside
613/// `193..=201` are rejected.
614fn qzss_slot_from_object_name(name: Option<&str>) -> Option<u16> {
615    let name = name?;
616    let mut from = 0;
617    while let Some(rel) = find_ci(&name[from..], "PRN") {
618        let after = from + rel + "PRN".len();
619        if let Some(prn) = leading_uint(&name[after..]) {
620            if (193..=201).contains(&prn) {
621                return Some(prn - 192);
622            }
623        }
624        from = after;
625    }
626    None
627}
628
629/// Parse the `GSATdddd` build id from a Galileo CelesTrak object name
630/// (`GSAT0210 (GALILEO 13)` -> `210`).
631fn gsat_from_object_name(name: Option<&str>) -> Option<u16> {
632    let name = name?;
633    let rel = find_ci(name, "GSAT")?;
634    leading_uint(&name[rel + "GSAT".len()..])
635}
636
637/// Read the leading run of ASCII digits (after optional whitespace) as a `u16`.
638fn leading_uint(rest: &str) -> Option<u16> {
639    let rest = rest.trim_start();
640    let end = rest
641        .find(|c: char| !c.is_ascii_digit())
642        .unwrap_or(rest.len());
643    rest.get(..end).filter(|s| !s.is_empty())?.parse().ok()
644}
645
646/// GSAT build id -> Galileo SVID (E-number).
647///
648/// The SVID is fixed per satellite at commissioning and is published in the EU
649/// GSC constellation information / Galileo metadata, so this is a stable
650/// identity table rather than a status snapshot. It carries no PRN in the OMM
651/// feed (the name is the `GSATdddd` build id), hence this lookup. Satellites
652/// with no SVID assigned yet (GIOVE prototypes, freshly launched FOC) are
653/// absent and resolve to `None`. Cross-checked against the broadcasting
654/// E-PRN set in the 2026-06-24 IGS broadcast navigation file. Source: EU GNSS
655/// Service Centre, <https://www.gsc-europa.eu/system-service-status/constellation-information>.
656#[must_use]
657pub fn galileo_prn_for_gsat(gsat: u16) -> Option<u16> {
658    let prn = match gsat {
659        101 => 11,
660        102 => 12,
661        103 => 19,
662        104 => 20,
663        201 => 18,
664        202 => 14,
665        203 => 26,
666        204 => 22,
667        205 => 24,
668        206 => 30,
669        207 => 7,
670        208 => 8,
671        209 => 9,
672        210 => 1,
673        211 => 2,
674        212 => 3,
675        213 => 4,
676        214 => 5,
677        215 => 21,
678        216 => 25,
679        217 => 27,
680        218 => 31,
681        219 => 36,
682        220 => 13,
683        221 => 15,
684        222 => 33,
685        223 => 34,
686        224 => 10,
687        225 => 29,
688        226 => 23,
689        227 => 6,
690        _ => return None,
691    };
692    Some(prn)
693}
694
695/// GLONASS (Uragan) number -> orbital slot (`1..=24`) for the operational
696/// constellation.
697///
698/// The OMM `OBJECT_NAME` carries the GLONASS number, not the slot, and slot
699/// occupancy rotates as satellites are replaced, so this is a point-in-time
700/// snapshot of the published IGS/MCC / IAC constellation status matching the
701/// committed `glonass_ops_sample.json` epoch (2026-06). Regenerate the two
702/// together when the constellation changes. Source: IAC GLONASS constellation
703/// status / List of GLONASS satellites; cross-checked against the CelesTrak
704/// `glo-ops` NORAD ids.
705#[must_use]
706pub fn glonass_slot_for_number(number: u16) -> Option<u16> {
707    let slot = match number {
708        730 => 1,
709        747 => 2,
710        744 => 3,
711        759 => 4,
712        756 => 5,
713        704 => 6,
714        745 => 7,
715        743 => 8,
716        702 => 9,
717        723 => 10,
718        705 => 11,
719        758 => 12,
720        721 => 13,
721        752 => 14,
722        757 => 15,
723        761 => 16,
724        751 => 17,
725        754 => 18,
726        707 => 19,
727        708 => 20,
728        755 => 21,
729        706 => 22,
730        732 => 23,
731        760 => 24,
732        _ => return None,
733    };
734    Some(slot)
735}
736
737/// GLONASS orbital slot (`1..=24`) -> FDMA L1/L2 frequency-channel number `k`.
738///
739/// This is the published IGS/MCC slot<->channel assignment: antipodal slots
740/// (same plane, 180 deg apart in argument of latitude) share a channel, so only
741/// 14 of the channels in `-7..=6` are in use. The mapping is stable over time
742/// (verified identical between the 2018 UNB/IAC published table and the
743/// 2026-06-24 IGS merged broadcast navigation file), and is the bit-exact golden
744/// for the FDMA datum, which appears in no OMM feed. Sources:
745/// - GLONASS Constellation Status, R. B. Langley, UNB (IAC Moscow / IGS),
746///   <https://gge.ext.unb.ca/Resources/GLONASSConstellationStatus.txt>.
747/// - IGS daily merged broadcast navigation (GLONASS frequency-number field).
748#[must_use]
749pub fn glonass_fdma_channel(slot: u16) -> Option<i8> {
750    let channel = match slot {
751        1 => 1,
752        2 => -4,
753        3 => 5,
754        4 => 6,
755        5 => 1,
756        6 => -4,
757        7 => 5,
758        8 => 6,
759        9 => -2,
760        10 => -7,
761        11 => 0,
762        12 => -1,
763        13 => -2,
764        14 => -7,
765        15 => 0,
766        16 => -1,
767        17 => 4,
768        18 => -3,
769        19 => 3,
770        20 => 2,
771        21 => 4,
772        22 => -3,
773        23 => 3,
774        24 => 2,
775        _ => return None,
776    };
777    Some(channel)
778}
779
780/// Parse the satellite block/generation from a CelesTrak object name token.
781///
782/// GPS mirrors the reference patterns, matched as whole words in the order
783/// `IIR-M`, `III`, `IIF`, `IIR` so `BIIRM` is not caught by `BIIR`. The other
784/// systems carry their generation in the name too (`BEIDOU-3S`, `BEIDOU-2`;
785/// Galileo IOV `GSAT01xx` vs FOC `GSAT02xx`); GLONASS does not, so it is `None`.
786fn block_type_from_object_name(system: GnssSystem, name: Option<&str>) -> Option<String> {
787    let name = name?;
788    match system {
789        GnssSystem::Gps => {
790            if contains_word_ci(name, "BIIRM") || contains_word_ci(name, "BIIR-M") {
791                Some("IIR-M".to_string())
792            } else if contains_word_ci(name, "BIII") {
793                Some("III".to_string())
794            } else if contains_word_ci(name, "BIIF") {
795                Some("IIF".to_string())
796            } else if contains_word_ci(name, "BIIR") {
797                Some("IIR".to_string())
798            } else {
799                None
800            }
801        }
802        GnssSystem::BeiDou => {
803            if contains_word_ci(name, "BEIDOU-3S") {
804                Some("BDS-3S".to_string())
805            } else if contains_word_ci(name, "BEIDOU-3") {
806                Some("BDS-3".to_string())
807            } else if contains_word_ci(name, "BEIDOU-2") {
808                Some("BDS-2".to_string())
809            } else {
810                None
811            }
812        }
813        GnssSystem::Galileo => match gsat_from_object_name(Some(name)) {
814            Some(gsat) if gsat < 200 => Some("IOV".to_string()),
815            Some(_) => Some("FOC".to_string()),
816            None => None,
817        },
818        _ => None,
819    }
820}
821
822/// Parse NAVCEN's GPS constellation status HTML from raw bytes.
823///
824/// The parser targets the Drupal table-field classes NAVCEN's public GPS
825/// constellation page uses, scanned without an HTML crate. Returns status rows
826/// sorted by PRN; merge them into CelesTrak records with [`merge_navcen`].
827pub fn parse_navcen(bytes: &[u8]) -> Result<Vec<NavcenStatus>, ConstellationError> {
828    let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
829
830    let mut statuses = Vec::new();
831    for row in navcen_rows(html) {
832        statuses.push(navcen_status_from_row(row)?);
833    }
834
835    if statuses.is_empty() {
836        return Err(ConstellationError::NavcenNoRows);
837    }
838    statuses.sort_by_key(|s| s.prn);
839    Ok(statuses)
840}
841
842/// Parse NAVCEN GPS status HTML and evaluate usability at an explicit UTC time.
843///
844/// This is the deterministic, time-aware companion to [`parse_navcen`]. Active
845/// bounded forecast notices affect usability only on their parsed half-open
846/// interval `[start_utc, end_utc)`. A forecast whose interval cannot be parsed
847/// is retained as [`NavcenTiming::Unparseable`] and conservatively leaves the
848/// satellite usable. Active `UNUSABLE` and `DECOM` notices retain their legacy
849/// immediate-unusable semantics. The time-aware path also recognizes active
850/// `UNUSUFN` as immediately unusable; the legacy clock-free parser did not.
851///
852/// The parser reads the interval's year from the row's raw "Outage Start"
853/// date and requires its day-of-year to agree with the first `JDAY` in the NANU
854/// subject. It never reads the host clock and performs no network access.
855pub fn parse_navcen_at(
856    bytes: &[u8],
857    evaluated_at_utc: UtcInstant,
858) -> Result<Vec<NavcenAssessment>, ConstellationError> {
859    let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
860
861    let mut assessments = Vec::new();
862    for row in navcen_rows(html) {
863        assessments.push(navcen_assessment_from_row(row, evaluated_at_utc)?);
864    }
865
866    if assessments.is_empty() {
867        return Err(ConstellationError::NavcenNoRows);
868    }
869    assessments.sort_by_key(|assessment| assessment.status.prn);
870    Ok(assessments)
871}
872
873fn navcen_status_from_row(row: &str) -> Result<NavcenStatus, ConstellationError> {
874    let prn = navcen_required_int(row, "gps-prn")?;
875    let svn = navcen_optional_int(row, "gps-svn")?;
876    let nanu_type = navcen_text(row, "nanu-type");
877    let active_nanu = navcen_active(row);
878    let usable = !(active_nanu && unusable_nanu_type(nanu_type.as_deref()));
879
880    Ok(NavcenStatus {
881        system: GnssSystem::Gps,
882        prn,
883        svn,
884        usable,
885        active_nanu,
886        nanu_type: blank_to_none(nanu_type),
887        nanu_subject: blank_to_none(navcen_text(row, "nanu-subject")),
888        plane: blank_to_none(navcen_text(row, "gps-con-plane")),
889        slot: blank_to_none(navcen_text(row, "gps-con-slot")),
890        block_type: blank_to_none(navcen_text(row, "gps-con-block-type")),
891        clock: blank_to_none(navcen_text(row, "gps-con-clock")),
892    })
893}
894
895fn navcen_assessment_from_row(
896    row: &str,
897    evaluated_at_utc: UtcInstant,
898) -> Result<NavcenAssessment, ConstellationError> {
899    let mut status = navcen_status_from_row(row)?;
900    let outage_start_cells = navcen_texts(row, "nanu-outage-start-date");
901    let outage_start = blank_to_none(Some(outage_start_cells.join(" | ")));
902    let timing = if outage_start_cells.len() > 1
903        && status
904            .nanu_type
905            .as_deref()
906            .is_some_and(|text| forecast_outage_type(&text.trim().to_ascii_uppercase()))
907    {
908        NavcenTiming::Unparseable
909    } else {
910        navcen_timing(
911            status.nanu_type.as_deref(),
912            status.nanu_subject.as_deref(),
913            outage_start.as_deref(),
914        )
915    };
916    status.usable = navcen_usable_at(
917        status.active_nanu,
918        status.nanu_type.as_deref(),
919        timing,
920        evaluated_at_utc,
921    );
922
923    Ok(NavcenAssessment {
924        status,
925        evaluated_at_utc,
926        outage_start,
927        timing,
928    })
929}
930
931fn navcen_usable_at(
932    active_nanu: bool,
933    nanu_type: Option<&str>,
934    timing: NavcenTiming,
935    evaluated_at_utc: UtcInstant,
936) -> bool {
937    if !active_nanu {
938        return true;
939    }
940    let Some(nanu_type) = nanu_type else {
941        return true;
942    };
943    let upper = nanu_type.trim().to_ascii_uppercase();
944    if matches!(upper.as_str(), "UNUSABLE" | "UNUSUFN" | "DECOM") {
945        return false;
946    }
947    if !forecast_outage_type(&upper) {
948        return true;
949    }
950    match timing {
951        NavcenTiming::Parsed(interval) => {
952            !(interval.start_utc <= evaluated_at_utc && evaluated_at_utc < interval.end_utc)
953        }
954        NavcenTiming::NotApplicable | NavcenTiming::Unparseable => true,
955    }
956}
957
958fn navcen_timing(
959    nanu_type: Option<&str>,
960    subject: Option<&str>,
961    outage_start: Option<&str>,
962) -> NavcenTiming {
963    let Some(nanu_type) = nanu_type else {
964        return NavcenTiming::NotApplicable;
965    };
966    let upper = nanu_type.trim().to_ascii_uppercase();
967    if !forecast_outage_type(&upper) {
968        return NavcenTiming::NotApplicable;
969    }
970
971    parse_navcen_interval(subject, outage_start)
972        .map_or(NavcenTiming::Unparseable, NavcenTiming::Parsed)
973}
974
975fn forecast_outage_type(upper_nanu_type: &str) -> bool {
976    matches!(upper_nanu_type, "FCSTDV" | "FCSTMX" | "FCSTEXTD")
977}
978
979fn parse_navcen_interval(
980    subject: Option<&str>,
981    outage_start: Option<&str>,
982) -> Option<NavcenEffectiveInterval> {
983    let (year, outage_day_of_year) = parse_navcen_outage_date(outage_start?)?;
984    let ((start_day, start_hour, start_minute), (end_day, end_hour, end_minute)) =
985        parse_jday_interval(subject?)?;
986    if start_day != outage_day_of_year || !valid_day_of_year(year, start_day) {
987        return None;
988    }
989
990    let end_year = if end_day >= start_day {
991        year
992    } else if start_day >= 335 && end_day <= 31 {
993        year.checked_add(1)?
994    } else {
995        return None;
996    };
997    if !valid_day_of_year(end_year, end_day) {
998        return None;
999    }
1000
1001    let start_utc = utc_from_ordinal(year, start_day, start_hour, start_minute)?;
1002    let end_utc = utc_from_ordinal(end_year, end_day, end_hour, end_minute)?;
1003    (end_utc > start_utc).then_some(NavcenEffectiveInterval { start_utc, end_utc })
1004}
1005
1006fn parse_navcen_outage_date(text: &str) -> Option<(i32, u16)> {
1007    let words: Vec<&str> = text.split_ascii_whitespace().collect();
1008    let mut parsed = None;
1009    for window in words.windows(3) {
1010        let Ok(day) = window[0].parse::<u8>() else {
1011            continue;
1012        };
1013        let Some(month) = month_number(window[1]) else {
1014            continue;
1015        };
1016        if window[2].len() != 4 || !window[2].bytes().all(|byte| byte.is_ascii_digit()) {
1017            continue;
1018        }
1019        let Ok(year @ 1980..=9999) = window[2].parse::<i32>() else {
1020            continue;
1021        };
1022        if UtcInstant::from_utc(year, i32::from(month), i32::from(day), 0, 0, 0, 0).is_none() {
1023            continue;
1024        }
1025        let Some(day_of_year) = ordinal_day(year, month, day) else {
1026            continue;
1027        };
1028        if parsed.replace((year, day_of_year)).is_some() {
1029            return None;
1030        }
1031    }
1032    parsed
1033}
1034
1035fn month_number(text: &str) -> Option<u8> {
1036    match text.to_ascii_uppercase().as_str() {
1037        "JAN" => Some(1),
1038        "FEB" => Some(2),
1039        "MAR" => Some(3),
1040        "APR" => Some(4),
1041        "MAY" => Some(5),
1042        "JUN" => Some(6),
1043        "JUL" => Some(7),
1044        "AUG" => Some(8),
1045        "SEP" => Some(9),
1046        "OCT" => Some(10),
1047        "NOV" => Some(11),
1048        "DEC" => Some(12),
1049        _ => None,
1050    }
1051}
1052
1053fn ordinal_day(year: i32, month: u8, day: u8) -> Option<u16> {
1054    const MONTH_DAYS: [u16; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1055    if month == 0 || month > 12 {
1056        return None;
1057    }
1058    let mut ordinal: u16 = MONTH_DAYS[..usize::from(month - 1)].iter().sum();
1059    if month > 2 && leap_year(year) {
1060        ordinal += 1;
1061    }
1062    ordinal.checked_add(u16::from(day))
1063}
1064
1065fn leap_year(year: i32) -> bool {
1066    year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
1067}
1068
1069fn valid_day_of_year(year: i32, day: u16) -> bool {
1070    day >= 1 && day <= if leap_year(year) { 366 } else { 365 }
1071}
1072
1073fn utc_from_ordinal(year: i32, day: u16, hour: u8, minute: u8) -> Option<UtcInstant> {
1074    if !valid_day_of_year(year, day) || hour >= 24 || minute >= 60 {
1075        return None;
1076    }
1077    let jan_1 = UtcInstant::from_utc(year, 1, 1, 0, 0, 0, 0)?;
1078    let day_us = i64::from(day - 1).checked_mul(86_400_000_000)?;
1079    let clock_us = (i64::from(hour) * 3_600 + i64::from(minute) * 60).checked_mul(1_000_000)?;
1080    Some(UtcInstant::from_unix_microseconds(
1081        jan_1
1082            .unix_microseconds()
1083            .checked_add(day_us)?
1084            .checked_add(clock_us)?,
1085    ))
1086}
1087
1088type JdayClock = (u16, u8, u8);
1089
1090fn parse_jday_interval(text: &str) -> Option<(JdayClock, JdayClock)> {
1091    let upper = text.to_ascii_uppercase();
1092    let bytes = upper.as_bytes();
1093    let mut cursor = 0;
1094    let mut clocks = [(0, 0, 0); 2];
1095
1096    for clock in &mut clocks {
1097        let relative = upper.get(cursor..)?.find("JDAY")?;
1098        cursor = cursor.checked_add(relative + 4)?;
1099        while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1100            cursor += 1;
1101        }
1102        let day = parse_ascii_digits(bytes, &mut cursor, 3)?;
1103        if bytes.get(cursor) != Some(&b'/') {
1104            return None;
1105        }
1106        cursor += 1;
1107        let hhmm = parse_ascii_digits(bytes, &mut cursor, 4)?;
1108        if bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
1109            return None;
1110        }
1111        let hour = u8::try_from(hhmm / 100).ok()?;
1112        let minute = u8::try_from(hhmm % 100).ok()?;
1113        if hour >= 24 || minute >= 60 {
1114            return None;
1115        }
1116        *clock = (u16::try_from(day).ok()?, hour, minute);
1117    }
1118
1119    if upper.get(cursor..)?.contains("JDAY") {
1120        return None;
1121    }
1122
1123    Some((clocks[0], clocks[1]))
1124}
1125
1126fn parse_ascii_digits(bytes: &[u8], cursor: &mut usize, count: usize) -> Option<u32> {
1127    let end = cursor.checked_add(count)?;
1128    let digits = bytes.get(*cursor..end)?;
1129    if !digits.iter().all(u8::is_ascii_digit) || bytes.get(end).is_some_and(u8::is_ascii_digit) {
1130        return None;
1131    }
1132    *cursor = end;
1133    core::str::from_utf8(digits).ok()?.parse().ok()
1134}
1135
1136fn navcen_required_int(row: &str, field: &'static str) -> Result<u16, ConstellationError> {
1137    let text = navcen_text(row, field);
1138    parse_positive_int(text.as_deref().unwrap_or(""), field)
1139}
1140
1141fn navcen_optional_int(row: &str, field: &'static str) -> Result<Option<u16>, ConstellationError> {
1142    match navcen_text(row, field).as_deref() {
1143        None | Some("") => Ok(None),
1144        Some(text) => parse_positive_int(text, field).map(Some),
1145    }
1146}
1147
1148fn parse_positive_int(text: &str, field: &'static str) -> Result<u16, ConstellationError> {
1149    let trimmed = text.trim();
1150    match trimmed.parse::<u16>() {
1151        Ok(value) if value > 0 => Ok(value),
1152        _ => Err(ConstellationError::NavcenBadField {
1153            field,
1154            value: trimmed.to_string(),
1155        }),
1156    }
1157}
1158
1159fn navcen_text(row: &str, field: &str) -> Option<String> {
1160    navcen_texts(row, field).into_iter().next()
1161}
1162
1163fn navcen_texts(row: &str, field: &str) -> Vec<String> {
1164    let needle = format!("views-field-field-{field}");
1165    td_inners(row, &needle)
1166        .into_iter()
1167        .map(clean_html)
1168        .collect()
1169}
1170
1171fn navcen_active(row: &str) -> bool {
1172    td_inner(row, "nanu-active-check")
1173        .map(clean_html)
1174        .as_deref()
1175        == Some("1")
1176}
1177
1178fn unusable_nanu_type(nanu_type: Option<&str>) -> bool {
1179    nanu_type.is_some_and(|text| {
1180        let upper = text.trim().to_ascii_uppercase();
1181        matches!(
1182            upper.as_str(),
1183            "UNUSABLE" | "DECOM" | "FCSTDV" | "FCSTMX" | "FCSTEXTD"
1184        )
1185    })
1186}
1187
1188/// Merge NAVCEN status rows into normalized records by PRN.
1189///
1190/// NAVCEN does not publish NORAD ids, so CelesTrak stays the identity base. When
1191/// a PRN exists in both sources and the block types are compatible, this fills
1192/// `svn`, updates `usable`, and records the NAVCEN provenance. A NAVCEN row that
1193/// matches the PRN but carries an incompatible block type (a PRN transition) is
1194/// recorded under `navcen_conflict` rather than merged. Returns records sorted
1195/// by `(system, prn)`.
1196///
1197/// NAVCEN's status page is GPS-only, so the overlay is keyed by `(system, PRN)`
1198/// and only ever lands on GPS records. Keying by PRN alone would splice GPS SVN /
1199/// usability / provenance onto a same-PRN record of another constellation
1200/// (`R01`, `J01`, ...), corrupting cross-system identity.
1201///
1202/// As in the reference (`Map.new(statuses, &{&1.prn, &1})`), at most one status
1203/// is kept per `(system, PRN)`; if the input carries duplicates the last wins.
1204#[must_use]
1205pub fn merge_navcen(records: &[Record], statuses: &[NavcenStatus]) -> Vec<Record> {
1206    let mut by_key: std::collections::HashMap<(GnssSystem, u16), &NavcenStatus> =
1207        std::collections::HashMap::with_capacity(statuses.len());
1208    for status in statuses {
1209        by_key.insert((status.system, status.prn), status);
1210    }
1211
1212    let mut merged: Vec<Record> = records
1213        .iter()
1214        .map(|record| {
1215            by_key
1216                .get(&(record.system, record.prn))
1217                .map_or_else(|| record.clone(), |status| merge_status(record, status))
1218        })
1219        .collect();
1220    merged.sort_by_key(|r| (r.system, r.prn));
1221    merged
1222}
1223
1224/// Merge time-aware NAVCEN assessments into normalized records.
1225///
1226/// Each assessment already carries both its explicit evaluation time and the
1227/// resulting `status.usable` value. The returned record provenance retains the
1228/// raw NANU type and subject; callers retain the assessment to inspect its raw
1229/// outage-start cell and parsed/ambiguous timing state.
1230#[must_use]
1231pub fn merge_navcen_at(records: &[Record], assessments: &[NavcenAssessment]) -> Vec<Record> {
1232    let statuses: Vec<NavcenStatus> = assessments
1233        .iter()
1234        .map(|assessment| assessment.status.clone())
1235        .collect();
1236    merge_navcen(records, &statuses)
1237}
1238
1239fn merge_status(record: &Record, status: &NavcenStatus) -> Record {
1240    let mut out = record.clone();
1241    if navcen_compatible(record, status) {
1242        out.svn = status.svn;
1243        out.usable = status.usable;
1244        out.source.navcen = Some(navcen_source(status));
1245    } else {
1246        out.source.navcen_conflict = Some(navcen_source(status));
1247    }
1248    out
1249}
1250
1251fn navcen_source(status: &NavcenStatus) -> NavcenSource {
1252    NavcenSource {
1253        svn: status.svn,
1254        block_type: status.block_type.clone(),
1255        plane: status.plane.clone(),
1256        slot: status.slot.clone(),
1257        clock: status.clock.clone(),
1258        nanu_type: status.nanu_type.clone(),
1259        nanu_subject: status.nanu_subject.clone(),
1260        active_nanu: status.active_nanu,
1261    }
1262}
1263
1264fn navcen_compatible(record: &Record, status: &NavcenStatus) -> bool {
1265    let celestrak_block = record
1266        .source
1267        .celestrak
1268        .as_ref()
1269        .and_then(|c| c.block_type.as_deref());
1270    let navcen_block = status
1271        .block_type
1272        .as_deref()
1273        .map(|b| b.trim().to_ascii_uppercase());
1274
1275    match (celestrak_block, navcen_block) {
1276        (Some(a), Some(b)) => a == b,
1277        _ => true,
1278    }
1279}
1280
1281/// Export records as the compact mapping CSV.
1282///
1283/// The header is `prn,norad_cat_id,active,sp3_id`. The `active` column is `true`
1284/// only when both `active` and `usable` hold. Records are sorted by
1285/// `(system, prn)`; the system is encoded in the `sp3_id` letter, so equal PRNs
1286/// across systems are ordered deterministically without a separate column.
1287#[must_use]
1288pub fn to_csv(records: &[Record], booleans: BoolStyle) -> String {
1289    let mut sorted: Vec<&Record> = records.iter().collect();
1290    sorted.sort_by_key(|r| (r.system, r.prn));
1291
1292    let mut out = String::from("prn,norad_cat_id,active,sp3_id\n");
1293    for record in sorted {
1294        let active = format_bool(operational(record), booleans);
1295        let _ = writeln!(
1296            out,
1297            "{},{},{},{}",
1298            record.prn, record.norad_id, active, record.sp3_id
1299        );
1300    }
1301    out
1302}
1303
1304fn format_bool(value: bool, style: BoolStyle) -> &'static str {
1305    match (style, value) {
1306        (BoolStyle::Lower, true) => "true",
1307        (BoolStyle::Lower, false) => "false",
1308        (BoolStyle::Title, true) => "True",
1309        (BoolStyle::Title, false) => "False",
1310    }
1311}
1312
1313fn operational(record: &Record) -> bool {
1314    record.active && record.usable
1315}
1316
1317/// Validate catalog identity without an SP3 product.
1318///
1319/// Reports duplicate PRNs, duplicate NORAD ids, and PRNs that are inactive or
1320/// unusable.
1321#[must_use]
1322pub fn validate(records: &[Record]) -> Validation {
1323    validation(records, None)
1324}
1325
1326/// Validate catalog identity against a loaded SP3 product.
1327///
1328/// `missing_sp3_ids` reports active+usable catalog ids absent from the product;
1329/// `extra_sp3_ids` reports product ids absent from the active+usable catalog,
1330/// restricted to the constellations the catalog covers (so a single-system
1331/// catalog is not flagged against a multi-GNSS product's other systems).
1332#[must_use]
1333pub fn validate_against_sp3(records: &[Record], sp3: &Sp3) -> Validation {
1334    let ids: Vec<String> = sp3
1335        .header
1336        .satellites
1337        .iter()
1338        .map(ToString::to_string)
1339        .collect();
1340    validation(records, Some(&ids))
1341}
1342
1343/// Validate catalog identity against a plain list of SP3/RINEX satellite tokens.
1344#[must_use]
1345pub fn validate_against_sp3_ids(records: &[Record], sp3_ids: &[&str]) -> Validation {
1346    let ids: Vec<String> = sp3_ids.iter().map(|id| (*id).to_string()).collect();
1347    validation(records, Some(&ids))
1348}
1349
1350fn validation(records: &[Record], sp3_ids: Option<&[String]>) -> Validation {
1351    let mut report = Validation {
1352        missing_sp3_ids: Vec::new(),
1353        duplicate_prns: duplicates(records.iter().map(|r| (r.system, r.prn))),
1354        duplicate_norad_ids: duplicates(records.iter().map(|r| r.norad_id)),
1355        inactive_unusable_prns: inactive_unusable_prns(records),
1356        extra_sp3_ids: Vec::new(),
1357    };
1358
1359    if let Some(sp3_ids) = sp3_ids {
1360        // Only compare against the systems this catalog actually covers, so a
1361        // GPS-only catalog is not flagged for the Galileo/GLONASS ids in a
1362        // multi-GNSS product. The hardcoded `'G'` filter generalizes to the set
1363        // of system letters present in the records.
1364        let letters: std::collections::HashSet<char> =
1365            records.iter().map(|r| r.system.letter()).collect();
1366        let catalog: Vec<String> = records
1367            .iter()
1368            .filter(|r| operational(r))
1369            .map(|r| r.sp3_id.to_ascii_uppercase())
1370            .collect();
1371        let product: Vec<String> = sp3_ids
1372            .iter()
1373            .map(|id| id.to_ascii_uppercase())
1374            .filter(|id| id.chars().next().is_some_and(|c| letters.contains(&c)))
1375            .collect();
1376
1377        report.missing_sp3_ids = set_difference(&catalog, &product);
1378        report.extra_sp3_ids = set_difference(&product, &catalog);
1379    }
1380
1381    report
1382}
1383
1384fn duplicates<T>(values: impl Iterator<Item = T>) -> Vec<T>
1385where
1386    T: Ord + Copy,
1387{
1388    let mut seen: Vec<T> = values.collect();
1389    seen.sort_unstable();
1390    let mut out = Vec::new();
1391    let mut i = 0;
1392    while i < seen.len() {
1393        let mut j = i + 1;
1394        while j < seen.len() && seen[j] == seen[i] {
1395            j += 1;
1396        }
1397        if j - i > 1 {
1398            out.push(seen[i]);
1399        }
1400        i = j;
1401    }
1402    out
1403}
1404
1405fn inactive_unusable_prns(records: &[Record]) -> Vec<(GnssSystem, u16)> {
1406    let mut prns: Vec<(GnssSystem, u16)> = records
1407        .iter()
1408        .filter(|r| !operational(r))
1409        .map(|r| (r.system, r.prn))
1410        .collect();
1411    prns.sort_unstable();
1412    prns.dedup();
1413    prns
1414}
1415
1416fn set_difference(left: &[String], right: &[String]) -> Vec<String> {
1417    let mut out: Vec<String> = left
1418        .iter()
1419        .filter(|id| !right.contains(id))
1420        .cloned()
1421        .collect();
1422    out.sort();
1423    out.dedup();
1424    out
1425}
1426
1427/// Returns `true` when a validation report has no findings.
1428#[must_use]
1429pub fn is_valid(report: &Validation) -> bool {
1430    report.missing_sp3_ids.is_empty()
1431        && report.duplicate_prns.is_empty()
1432        && report.duplicate_norad_ids.is_empty()
1433        && report.inactive_unusable_prns.is_empty()
1434        && report.extra_sp3_ids.is_empty()
1435}
1436
1437/// Validate against a plain SP3 id list and fail unless the catalog is clean.
1438///
1439/// A build-time gate: returns `Ok(())` when the report has no findings, otherwise
1440/// [`ConstellationError::Sp3Validation`] describing them.
1441pub fn validate_against_sp3_ids_strict(
1442    records: &[Record],
1443    sp3_ids: &[&str],
1444) -> Result<(), ConstellationError> {
1445    let report = validate_against_sp3_ids(records, sp3_ids);
1446    if is_valid(&report) {
1447        Ok(())
1448    } else {
1449        Err(ConstellationError::Sp3Validation(describe_findings(
1450            &report,
1451        )))
1452    }
1453}
1454
1455fn describe_findings(report: &Validation) -> String {
1456    let mut parts = Vec::new();
1457    if !report.missing_sp3_ids.is_empty() {
1458        parts.push(format!("missing_sp3_ids: {:?}", report.missing_sp3_ids));
1459    }
1460    if !report.extra_sp3_ids.is_empty() {
1461        parts.push(format!("extra_sp3_ids: {:?}", report.extra_sp3_ids));
1462    }
1463    if !report.duplicate_prns.is_empty() {
1464        parts.push(format!("duplicate_prns: {:?}", report.duplicate_prns));
1465    }
1466    if !report.duplicate_norad_ids.is_empty() {
1467        parts.push(format!(
1468            "duplicate_norad_ids: {:?}",
1469            report.duplicate_norad_ids
1470        ));
1471    }
1472    if !report.inactive_unusable_prns.is_empty() {
1473        parts.push(format!(
1474            "inactive_unusable_prns: {:?}",
1475            report.inactive_unusable_prns
1476        ));
1477    }
1478    parts.join("; ")
1479}
1480
1481/// Compare two catalog snapshots by `(system, prn)` identity.
1482///
1483/// Assumes each input has at most one record per `(system, prn)`; run
1484/// [`validate`] first on hand-edited catalogs and treat duplicate findings as
1485/// malformed input rather than a constellation change.
1486#[must_use]
1487pub fn diff(previous: &[Record], current: &[Record]) -> Diff {
1488    let key = |r: &Record| (r.system, r.prn);
1489
1490    let added: Vec<Record> = current
1491        .iter()
1492        .filter(|c| !previous.iter().any(|p| key(p) == key(c)))
1493        .cloned()
1494        .collect();
1495    let removed: Vec<Record> = previous
1496        .iter()
1497        .filter(|p| !current.iter().any(|c| key(c) == key(p)))
1498        .cloned()
1499        .collect();
1500
1501    let mut added = added;
1502    let mut removed = removed;
1503    added.sort_by_key(|r| (r.system, r.prn));
1504    removed.sort_by_key(|r| (r.system, r.prn));
1505
1506    let mut common: Vec<(GnssSystem, u16)> = previous
1507        .iter()
1508        .filter_map(|p| current.iter().find(|c| key(c) == key(p)).map(|_| key(p)))
1509        .collect();
1510    common.sort_unstable();
1511
1512    let pairs: Vec<(&Record, &Record)> = common
1513        .iter()
1514        .map(|k| {
1515            let p = previous.iter().find(|r| key(r) == *k).expect("common key");
1516            let c = current.iter().find(|r| key(r) == *k).expect("common key");
1517            (p, c)
1518        })
1519        .collect();
1520
1521    Diff {
1522        added,
1523        removed,
1524        norad_reassigned: changes(&pairs, |r| r.norad_id),
1525        sp3_id_changed: changes(&pairs, |r| r.sp3_id.clone()),
1526        svn_changed: changes(&pairs, |r| r.svn),
1527        fdma_channel_changed: changes(&pairs, |r| r.fdma_channel),
1528        activity_changed: changes(&pairs, |r| r.active),
1529        usability_changed: changes(&pairs, |r| r.usable),
1530    }
1531}
1532
1533fn changes<T, F>(pairs: &[(&Record, &Record)], field: F) -> Vec<FieldChange<T>>
1534where
1535    T: PartialEq,
1536    F: Fn(&Record) -> T,
1537{
1538    pairs
1539        .iter()
1540        .filter_map(|(p, c)| {
1541            let from = field(p);
1542            let to = field(c);
1543            if from == to {
1544                None
1545            } else {
1546                Some(FieldChange {
1547                    system: p.system,
1548                    prn: p.prn,
1549                    from,
1550                    to,
1551                })
1552            }
1553        })
1554        .collect()
1555}
1556
1557/// Returns `true` when a diff has any findings.
1558#[must_use]
1559pub fn changed(diff: &Diff) -> bool {
1560    !diff.added.is_empty()
1561        || !diff.removed.is_empty()
1562        || !diff.norad_reassigned.is_empty()
1563        || !diff.sp3_id_changed.is_empty()
1564        || !diff.svn_changed.is_empty()
1565        || !diff.fdma_channel_changed.is_empty()
1566        || !diff.activity_changed.is_empty()
1567        || !diff.usability_changed.is_empty()
1568}
1569
1570// ── HTML/text scanning helpers (dependency-light) ────────────────────────────
1571
1572fn blank_to_none(value: Option<String>) -> Option<String> {
1573    value.filter(|v| !v.is_empty())
1574}
1575
1576/// Case-insensitive ASCII substring search returning the byte offset.
1577fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
1578    let hay = haystack.as_bytes();
1579    let need = needle.as_bytes();
1580    if need.is_empty() {
1581        return Some(0);
1582    }
1583    if hay.len() < need.len() {
1584        return None;
1585    }
1586    (0..=hay.len() - need.len()).find(|&i| {
1587        hay[i..i + need.len()]
1588            .iter()
1589            .zip(need)
1590            .all(|(a, b)| a.eq_ignore_ascii_case(b))
1591    })
1592}
1593
1594fn is_word_byte(b: u8) -> bool {
1595    b.is_ascii_alphanumeric() || b == b'_'
1596}
1597
1598/// Case-insensitive whole-word match, mirroring regex `\bword\b` boundaries.
1599fn contains_word_ci(haystack: &str, word: &str) -> bool {
1600    let hay = haystack.as_bytes();
1601    let need = word.as_bytes();
1602    let n = need.len();
1603    if n == 0 || hay.len() < n {
1604        return false;
1605    }
1606    (0..=hay.len() - n).any(|i| {
1607        let matched = hay[i..i + n]
1608            .iter()
1609            .zip(need)
1610            .all(|(a, b)| a.eq_ignore_ascii_case(b));
1611        if !matched {
1612            return false;
1613        }
1614        let left_ok = i == 0 || !is_word_byte(hay[i - 1]);
1615        let right_ok = i + n == hay.len() || !is_word_byte(hay[i + n]);
1616        left_ok && right_ok
1617    })
1618}
1619
1620/// Split HTML into the inner text of each `<tr>...</tr>` block.
1621fn tr_blocks(html: &str) -> Vec<&str> {
1622    let mut out = Vec::new();
1623    let mut rest = html;
1624    while let Some(start) = find_ci(rest, "<tr") {
1625        let Some(gt) = rest[start..].find('>') else {
1626            break;
1627        };
1628        let content_start = start + gt + 1;
1629        let Some(close) = find_ci(&rest[content_start..], "</tr>") else {
1630            break;
1631        };
1632        out.push(&rest[content_start..content_start + close]);
1633        rest = &rest[content_start + close + "</tr>".len()..];
1634    }
1635    out
1636}
1637
1638fn navcen_rows(html: &str) -> impl Iterator<Item = &str> {
1639    tr_blocks(html).into_iter().filter(|row| {
1640        find_ci(row, "views-field-field-gps-prn").is_some() && find_ci(row, "<td").is_some()
1641    })
1642}
1643
1644/// Inner text of the first `<td>` whose attributes contain `class_needle`.
1645fn td_inner<'a>(row: &'a str, class_needle: &str) -> Option<&'a str> {
1646    td_inners(row, class_needle).into_iter().next()
1647}
1648
1649/// Inner text of every `<td>` whose attributes contain `class_needle`.
1650fn td_inners<'a>(row: &'a str, class_needle: &str) -> Vec<&'a str> {
1651    let mut out = Vec::new();
1652    let mut rest = row;
1653    while let Some(start) = find_ci(rest, "<td") {
1654        let Some(gt) = rest[start..].find('>') else {
1655            break;
1656        };
1657        let attrs = &rest[start..start + gt];
1658        let content_start = start + gt + 1;
1659        let Some(close) = find_ci(&rest[content_start..], "</td>") else {
1660            break;
1661        };
1662        let inner = &rest[content_start..content_start + close];
1663        if find_ci(attrs, class_needle).is_some() {
1664            out.push(inner);
1665        }
1666        rest = &rest[content_start + close + "</td>".len()..];
1667    }
1668    out
1669}
1670
1671/// Strip tags, unescape entities, and collapse whitespace, matching the
1672/// reference `clean_html`.
1673fn clean_html(text: &str) -> String {
1674    let mut stripped = String::with_capacity(text.len());
1675    let mut in_tag = false;
1676    for c in text.chars() {
1677        match c {
1678            '<' => in_tag = true,
1679            '>' => in_tag = false,
1680            _ if !in_tag => stripped.push(c),
1681            _ => {}
1682        }
1683    }
1684    let unescaped = html_unescape(&stripped);
1685    unescaped.split_whitespace().collect::<Vec<_>>().join(" ")
1686}
1687
1688/// Decode HTML entities: the named set the reference handles plus numeric
1689/// character references (`&#160;`, `&#xA0;`). Numeric decoding is a superset of
1690/// the reference's named-only set, so it never changes a reference-covered case
1691/// but keeps generated markup (numeric `&nbsp;`, `&apos;`) from leaking literal
1692/// `&#160;` into a cell and breaking, for example, optional-integer parsing.
1693fn html_unescape(text: &str) -> String {
1694    let mut out = String::with_capacity(text.len());
1695    let mut rest = text;
1696    while let Some(amp) = rest.find('&') {
1697        out.push_str(&rest[..amp]);
1698        let tail = &rest[amp..];
1699        if let Some((decoded, consumed)) = decode_entity(tail) {
1700            out.push(decoded);
1701            rest = &tail[consumed..];
1702        } else {
1703            out.push('&');
1704            rest = &tail[1..];
1705        }
1706    }
1707    out.push_str(rest);
1708    out
1709}
1710
1711/// Decode a single entity at the start of `s` (which begins with `&`), returning
1712/// the decoded char and the number of bytes consumed, or `None` if `s` does not
1713/// start with a recognized entity.
1714fn decode_entity(s: &str) -> Option<(char, usize)> {
1715    for (entity, decoded) in [
1716        ("&amp;", '&'),
1717        ("&lt;", '<'),
1718        ("&gt;", '>'),
1719        ("&quot;", '"'),
1720        ("&#39;", '\''),
1721        ("&apos;", '\''),
1722        ("&nbsp;", ' '),
1723    ] {
1724        if s.starts_with(entity) {
1725            return Some((decoded, entity.len()));
1726        }
1727    }
1728
1729    // Numeric character reference: &#DDD; or &#xHHH;
1730    let body = s.strip_prefix("&#")?;
1731    let semi = body.find(';')?;
1732    let (digits, radix) = match body.strip_prefix(['x', 'X']) {
1733        Some(hex) => (&hex[..semi - 1], 16),
1734        None => (&body[..semi], 10),
1735    };
1736    if digits.is_empty() {
1737        return None;
1738    }
1739    let code = u32::from_str_radix(digits, radix).ok()?;
1740    let decoded = char::from_u32(code)?;
1741    Some((decoded, "&#".len() + semi + 1))
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746    use super::*;
1747
1748    #[test]
1749    fn prn_parses_padded_and_multi_digit() {
1750        assert_eq!(prn_from_object_name(Some("GPS BIIF-8  (PRN 03)")), Some(3));
1751        assert_eq!(prn_from_object_name(Some("GPS BIII-10 (PRN 13)")), Some(13));
1752        assert_eq!(prn_from_object_name(Some("X (PRN 003)")), Some(3));
1753    }
1754
1755    #[test]
1756    fn prn_search_skips_unparseable_earlier_occurrence() {
1757        // A leading "(PRN ...)" that does not parse must not block a later valid
1758        // one, matching the reference regex's search semantics.
1759        assert_eq!(
1760            prn_from_object_name(Some("GPS (PRN X) BIIF (PRN 07)")),
1761            Some(7)
1762        );
1763        assert_eq!(prn_from_object_name(Some("GPS WITHOUT PRN")), None);
1764        assert_eq!(prn_from_object_name(Some("(PRN 000)")), None);
1765    }
1766
1767    #[test]
1768    fn html_unescape_decodes_named_and_numeric_entities() {
1769        assert_eq!(html_unescape("a &amp; b"), "a & b");
1770        assert_eq!(html_unescape("&#39;x&#39;"), "'x'");
1771        // Numeric references for NBSP (decimal and hex) decode to spaces.
1772        assert_eq!(html_unescape("&#160;"), "\u{a0}");
1773        assert_eq!(html_unescape("&#xA0;"), "\u{a0}");
1774        // An unrecognized "&" is left literal rather than dropped.
1775        assert_eq!(html_unescape("AT&T"), "AT&T");
1776    }
1777
1778    #[test]
1779    fn optional_int_treats_numeric_nbsp_cell_as_blank() {
1780        // A cell whose only content is a numeric NBSP cleans to whitespace and
1781        // collapses to "", so it is absent rather than a parse error.
1782        let row = r#"<td class="views-field-field-gps-svn">&#160;</td>"#;
1783        assert_eq!(navcen_optional_int(row, "gps-svn"), Ok(None));
1784    }
1785
1786    #[test]
1787    fn beidou_prn_parses_from_parenthesized_letter_group() {
1788        assert_eq!(paren_letter_prn(Some("BEIDOU-3 M1 (C19)"), 'C'), Some(19));
1789        assert_eq!(paren_letter_prn(Some("BEIDOU-2 G8 (C01)"), 'C'), Some(1));
1790        assert_eq!(paren_letter_prn(Some("BEIDOU-3 G2 (C60)"), 'C'), Some(60));
1791        assert_eq!(paren_letter_prn(Some("NO LETTER GROUP"), 'C'), None);
1792    }
1793
1794    #[test]
1795    fn qzss_slot_is_broadcast_prn_minus_192() {
1796        // RINEX 3.0x: J-slot = broadcast PRN - 192, valid only for 193..=201.
1797        assert_eq!(
1798            qzss_slot_from_object_name(Some("QZS-2 (QZSS/PRN 194)")),
1799            Some(2)
1800        );
1801        assert_eq!(
1802            qzss_slot_from_object_name(Some("QZS-3 (QZSS/PRN 199)")),
1803            Some(7)
1804        );
1805        assert_eq!(
1806            qzss_slot_from_object_name(Some("QZS-6 (QZSS/PRN 200)")),
1807            Some(8)
1808        );
1809        // Out-of-band broadcast PRN (e.g. an SBAS-style 122) is rejected.
1810        assert_eq!(qzss_slot_from_object_name(Some("X (PRN 122)")), None);
1811    }
1812
1813    #[test]
1814    fn galileo_gsat_parses_and_maps_to_svid() {
1815        assert_eq!(
1816            gsat_from_object_name(Some("GSAT0210 (GALILEO 13)")),
1817            Some(210)
1818        );
1819        assert_eq!(
1820            gsat_from_object_name(Some("GSAT0101 (GALILEO-PFM)")),
1821            Some(101)
1822        );
1823        assert_eq!(gsat_from_object_name(Some("COSMOS 2456 (730)")), None);
1824        // GSAT0210 ("GALILEO 13") is SVID E01, not E13 - the table, not the name.
1825        assert_eq!(galileo_prn_for_gsat(210), Some(1));
1826        assert_eq!(galileo_prn_for_gsat(211), Some(2));
1827        assert_eq!(galileo_prn_for_gsat(101), Some(11));
1828        assert_eq!(galileo_prn_for_gsat(228), None);
1829    }
1830
1831    #[test]
1832    fn glonass_number_resolves_to_slot_and_channel() {
1833        assert_eq!(paren_number(Some("COSMOS 2456 (730)")), Some(730));
1834        assert_eq!(glonass_slot_for_number(730), Some(1));
1835        assert_eq!(glonass_slot_for_number(721), Some(13));
1836        assert_eq!(glonass_slot_for_number(999), None);
1837        // FDMA channels: antipodal slots (180 deg apart in plane) share a
1838        // channel, e.g. slots 1 and 5 are both +1, 2 and 6 both -4.
1839        assert_eq!(glonass_fdma_channel(1), Some(1));
1840        assert_eq!(glonass_fdma_channel(5), Some(1));
1841        assert_eq!(glonass_fdma_channel(2), Some(-4));
1842        assert_eq!(glonass_fdma_channel(6), Some(-4));
1843        assert_eq!(glonass_fdma_channel(13), Some(-2));
1844        assert_eq!(glonass_fdma_channel(0), None);
1845        assert_eq!(glonass_fdma_channel(25), None);
1846    }
1847
1848    #[test]
1849    fn gnss_sp3_id_renders_per_system_token() {
1850        assert_eq!(gnss_sp3_id(GnssSystem::Gps, 7), "G07");
1851        assert_eq!(gnss_sp3_id(GnssSystem::Galileo, 7), "E07");
1852        assert_eq!(gnss_sp3_id(GnssSystem::Glonass, 13), "R13");
1853        assert_eq!(gnss_sp3_id(GnssSystem::BeiDou, 19), "C19");
1854        assert_eq!(gnss_sp3_id(GnssSystem::Qzss, 2), "J02");
1855    }
1856
1857    /// Minimal OMM carrying only the identity fields the constellation builders
1858    /// read (`OBJECT_NAME`, `NORAD_CAT_ID`); the orbital elements are unused here.
1859    fn omm_named(object_name: &str, norad_cat_id: u32) -> Omm {
1860        Omm {
1861            ccsds_omm_vers: String::new(),
1862            creation_date: None,
1863            originator: None,
1864            object_name: Some(object_name.to_string()),
1865            object_id: None,
1866            center_name: None,
1867            ref_frame: None,
1868            time_system: None,
1869            mean_element_theory: None,
1870            epoch: crate::astro::omm::OmmEpoch {
1871                year: 2026,
1872                month: 6,
1873                day: 24,
1874                hour: 0,
1875                minute: 0,
1876                second: 0,
1877                microsecond: 0,
1878                femtosecond: 0,
1879            },
1880            mean_motion: 0.0,
1881            eccentricity: 0.0,
1882            inclination_deg: 0.0,
1883            ra_of_asc_node_deg: 0.0,
1884            arg_of_pericenter_deg: 0.0,
1885            mean_anomaly_deg: 0.0,
1886            ephemeris_type: 0,
1887            classification_type: String::new(),
1888            norad_cat_id,
1889            element_set_no: 0,
1890            rev_at_epoch: 0,
1891            bstar: 0.0,
1892            mean_motion_dot: 0.0,
1893            mean_motion_ddot: 0.0,
1894            exact_sgp4_epoch: None,
1895            quantize_tle_derived_fields: true,
1896        }
1897    }
1898
1899    #[test]
1900    fn lenient_builder_returns_partial_success_with_skipped_identities() {
1901        // A raw combined `gnss`-style slice viewed as GPS: two resolvable GPS
1902        // names, one QZSS member (lives in the same combined group, no GPS PRN),
1903        // and one GPS-looking name with no parseable PRN block.
1904        let omms = [
1905            omm_named("GPS BIIF-8  (PRN 03)", 40294),
1906            omm_named("QZS-2 (QZSS/PRN 194)", 42738),
1907            omm_named("GPS BIII-1  (PRN 04)", 43873),
1908            omm_named("GPS WITHOUT PRN", 99999),
1909        ];
1910
1911        // Strict path aborts on the first unresolvable entry, naming it.
1912        assert_eq!(
1913            from_celestrak_omm(GnssSystem::Gps, &omms),
1914            Err(ConstellationError::MissingPrn(Some(
1915                "QZS-2 (QZSS/PRN 194)".to_string()
1916            )))
1917        );
1918
1919        // Lenient path keeps the resolvable GPS records (sorted by prn) and
1920        // reports each skipped entry's identity, in input order.
1921        let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1922        assert_eq!(
1923            catalog.records.iter().map(|r| r.prn).collect::<Vec<_>>(),
1924            vec![3, 4]
1925        );
1926        assert!(catalog.records.iter().all(|r| r.system == GnssSystem::Gps));
1927        assert_eq!(
1928            catalog.skipped,
1929            vec![
1930                SkippedOmm {
1931                    object_name: Some("QZS-2 (QZSS/PRN 194)".to_string()),
1932                    norad_id: 42738,
1933                },
1934                SkippedOmm {
1935                    object_name: Some("GPS WITHOUT PRN".to_string()),
1936                    norad_id: 99999,
1937                },
1938            ]
1939        );
1940    }
1941
1942    #[test]
1943    fn lenient_builder_partitions_a_realistic_combined_gnss_feed() {
1944        // The combined CelesTrak `gnss` group carries every system, each with its
1945        // own naming convention. Lenient build for one system must keep only that
1946        // system's records and skip the rest - the per-system identity adapters
1947        // are what distinguish them (only GPS uses the bare `(PRN nn)` form, QZSS
1948        // uses `(QZSS/PRN nnn)`, GLONASS a bare `(number)`, etc.).
1949        let feed = [
1950            omm_named("GPS BIIF-8  (PRN 03)", 40294),
1951            omm_named("COSMOS 2456 (730)", 37139), // GLONASS slot 1
1952            omm_named("GSAT0210 (GALILEO 13)", 41859), // Galileo E01
1953            omm_named("BEIDOU-3 M1 (C19)", 43001), // BeiDou C19
1954            omm_named("QZS-2 (QZSS/PRN 194)", 42738), // QZSS J02
1955        ];
1956
1957        let gps = from_celestrak_omm_lenient(GnssSystem::Gps, &feed);
1958        assert_eq!(
1959            gps.records
1960                .iter()
1961                .map(|r| r.sp3_id.as_str())
1962                .collect::<Vec<_>>(),
1963            vec!["G03"]
1964        );
1965        assert_eq!(gps.skipped.len(), 4, "the four non-GPS names are skipped");
1966
1967        let glonass = from_celestrak_omm_lenient(GnssSystem::Glonass, &feed);
1968        assert_eq!(
1969            glonass
1970                .records
1971                .iter()
1972                .map(|r| r.sp3_id.as_str())
1973                .collect::<Vec<_>>(),
1974            vec!["R01"]
1975        );
1976        assert_eq!(glonass.skipped.len(), 4);
1977
1978        // The partitions are disjoint: each system claims exactly one record and
1979        // skips the other four, so no name is double-counted across systems.
1980        for system in [
1981            GnssSystem::Gps,
1982            GnssSystem::Glonass,
1983            GnssSystem::Galileo,
1984            GnssSystem::BeiDou,
1985            GnssSystem::Qzss,
1986        ] {
1987            let cat = from_celestrak_omm_lenient(system, &feed);
1988            assert_eq!(cat.records.len(), 1, "{system:?}: one record");
1989            assert_eq!(cat.skipped.len(), 4, "{system:?}: four skipped");
1990            assert!(cat.records.iter().all(|r| r.system == system));
1991        }
1992    }
1993
1994    /// Build a minimal record for a given system/prn carrying a CelesTrak source
1995    /// (so block-type compatibility is exercised), used by the merge tests.
1996    fn record_for(system: GnssSystem, prn: u16, norad_id: u32) -> Record {
1997        Record {
1998            system,
1999            prn,
2000            svn: None,
2001            norad_id,
2002            sp3_id: gnss_sp3_id(system, prn),
2003            fdma_channel: None,
2004            active: true,
2005            usable: true,
2006            source: RecordSource::default(),
2007        }
2008    }
2009
2010    fn navcen_gps(prn: u16, svn: u16, usable: bool) -> NavcenStatus {
2011        NavcenStatus {
2012            system: GnssSystem::Gps,
2013            prn,
2014            svn: Some(svn),
2015            usable,
2016            active_nanu: !usable,
2017            nanu_type: None,
2018            nanu_subject: None,
2019            plane: None,
2020            slot: None,
2021            block_type: None,
2022            clock: None,
2023        }
2024    }
2025
2026    #[test]
2027    fn merge_navcen_does_not_cross_systems() {
2028        // GPS PRN 1 and GLONASS slot 1 (R01) share the integer PRN. A GPS-only
2029        // NAVCEN row for PRN 1 must merge onto the GPS record and leave R01/J01
2030        // untouched - keying by PRN alone corrupted the GLONASS/QZSS records.
2031        let records = [
2032            record_for(GnssSystem::Gps, 1, 40000),
2033            record_for(GnssSystem::Glonass, 1, 50000),
2034            record_for(GnssSystem::Qzss, 1, 60000),
2035        ];
2036        let statuses = [navcen_gps(1, 63, false)];
2037
2038        let merged = merge_navcen(&records, &statuses);
2039
2040        let gps = merged.iter().find(|r| r.system == GnssSystem::Gps).unwrap();
2041        assert_eq!(gps.svn, Some(63), "GPS record gets the NAVCEN SVN");
2042        assert!(!gps.usable, "GPS usability follows NAVCEN");
2043        assert!(gps.source.navcen.is_some());
2044
2045        for system in [GnssSystem::Glonass, GnssSystem::Qzss] {
2046            let other = merged.iter().find(|r| r.system == system).unwrap();
2047            assert_eq!(other.svn, None, "{system:?} must not inherit GPS SVN");
2048            assert!(other.usable, "{system:?} usability untouched");
2049            assert!(
2050                other.source.navcen.is_none(),
2051                "{system:?} must carry no NAVCEN provenance"
2052            );
2053        }
2054    }
2055
2056    #[test]
2057    fn merge_navcen_sorts_by_system_then_prn() {
2058        let records = [
2059            record_for(GnssSystem::Glonass, 2, 50002),
2060            record_for(GnssSystem::Gps, 5, 40005),
2061            record_for(GnssSystem::Gps, 1, 40001),
2062        ];
2063        let merged = merge_navcen(&records, &[]);
2064        let order: Vec<(GnssSystem, u16)> = merged.iter().map(|r| (r.system, r.prn)).collect();
2065        assert_eq!(
2066            order,
2067            vec![
2068                (GnssSystem::Gps, 1),
2069                (GnssSystem::Gps, 5),
2070                (GnssSystem::Glonass, 2),
2071            ]
2072        );
2073    }
2074
2075    #[test]
2076    fn navcen_interval_parser_accepts_valid_same_year_rollover_and_leap_intervals() {
2077        assert_eq!(
2078            parse_navcen_interval(
2079                Some("OUTAGE JDAY 205/0115 - JDAY 205/1315"),
2080                Some("24 JUL 2026")
2081            ),
2082            Some(NavcenEffectiveInterval {
2083                start_utc: UtcInstant::from_utc(2026, 7, 24, 1, 15, 0, 0).unwrap(),
2084                end_utc: UtcInstant::from_utc(2026, 7, 24, 13, 15, 0, 0).unwrap(),
2085            })
2086        );
2087        assert_eq!(
2088            parse_navcen_interval(
2089                Some("OUTAGE JDAY 365/2300 - JDAY 001/0100"),
2090                Some("31 DEC 2026")
2091            ),
2092            Some(NavcenEffectiveInterval {
2093                start_utc: UtcInstant::from_utc(2026, 12, 31, 23, 0, 0, 0).unwrap(),
2094                end_utc: UtcInstant::from_utc(2027, 1, 1, 1, 0, 0, 0).unwrap(),
2095            })
2096        );
2097        assert_eq!(
2098            parse_navcen_interval(
2099                Some("OUTAGE JDAY 060/0000 - JDAY 060/0100"),
2100                Some("29 FEB 2028")
2101            ),
2102            Some(NavcenEffectiveInterval {
2103                start_utc: UtcInstant::from_utc(2028, 2, 29, 0, 0, 0, 0).unwrap(),
2104                end_utc: UtcInstant::from_utc(2028, 2, 29, 1, 0, 0, 0).unwrap(),
2105            })
2106        );
2107        assert_eq!(
2108            parse_navcen_interval(
2109                Some("OUTAGE JDAY 365/2300 - JDAY 366/0100"),
2110                Some("30 DEC 2028")
2111            ),
2112            Some(NavcenEffectiveInterval {
2113                start_utc: UtcInstant::from_utc(2028, 12, 30, 23, 0, 0, 0).unwrap(),
2114                end_utc: UtcInstant::from_utc(2028, 12, 31, 1, 0, 0, 0).unwrap(),
2115            })
2116        );
2117    }
2118
2119    #[test]
2120    fn navcen_interval_parser_rejects_ambiguous_or_invalid_inputs() {
2121        assert_eq!(
2122            parse_navcen_outage_date("Outage Start 24 JUL 2026 UTC"),
2123            Some((2026, 205))
2124        );
2125        assert_eq!(
2126            parse_navcen_outage_date("24 JUL 2026 revised 25 JUL 2026"),
2127            None
2128        );
2129        assert_eq!(parse_navcen_outage_date("24 JUL 26"), None);
2130        assert_eq!(parse_navcen_outage_date("24 JUL 1979"), None);
2131        assert_eq!(
2132            parse_jday_interval("JDAY 205/0115 - JDAY 205/1315 - JDAY 205/1415"),
2133            None
2134        );
2135        assert_eq!(parse_jday_interval("JDAY 205/01150 - JDAY 205/1315"), None);
2136        assert_eq!(
2137            parse_navcen_interval(
2138                Some("OUTAGE JDAY 204/0115 - JDAY 205/1315"),
2139                Some("24 JUL 2026")
2140            ),
2141            None,
2142            "first JDAY must agree with Outage Start"
2143        );
2144        assert_eq!(
2145            parse_navcen_interval(
2146                Some("OUTAGE JDAY 205/1315 - JDAY 205/0115"),
2147                Some("24 JUL 2026")
2148            ),
2149            None,
2150            "reversed same-day interval"
2151        );
2152        assert_eq!(
2153            parse_navcen_interval(
2154                Some("OUTAGE JDAY 205/0115 - JDAY 205/0115"),
2155                Some("24 JUL 2026")
2156            ),
2157            None,
2158            "zero-length interval"
2159        );
2160        assert_eq!(
2161            parse_navcen_interval(
2162                Some("OUTAGE JDAY 205/2400 - JDAY 205/2500"),
2163                Some("24 JUL 2026")
2164            ),
2165            None,
2166            "invalid clock"
2167        );
2168        assert_eq!(
2169            parse_navcen_interval(
2170                Some("OUTAGE JDAY 205/0100 - JDAY 001/0200"),
2171                Some("24 JUL 2026")
2172            ),
2173            None,
2174            "only a December-to-January rollover is accepted"
2175        );
2176        assert_eq!(
2177            parse_navcen_interval(
2178                Some("OUTAGE JDAY 365/0100 - JDAY 366/0200"),
2179                Some("31 DEC 2026")
2180            ),
2181            None,
2182            "JDAY 366 is invalid in a non-leap year"
2183        );
2184    }
2185
2186    #[test]
2187    fn navcen_assessment_rejects_duplicate_outage_start_cells() {
2188        let row = r#"
2189            <td class="views-field-field-gps-prn">7</td>
2190            <td class="views-field-field-nanu-outage-start-date">24 JUL 2026</td>
2191            <td class="views-field-field-nanu-outage-start-date">25 JUL 2026</td>
2192            <td class="views-field-field-nanu-type">FCSTDV</td>
2193            <td class="views-field-field-nanu-subject">
2194              JDAY 205/0115 - JDAY 205/1315
2195            </td>
2196            <td class="nanu-active-check">1</td>
2197        "#;
2198        let assessment =
2199            navcen_assessment_from_row(row, UtcInstant::from_utc(2026, 7, 24, 2, 0, 0, 0).unwrap())
2200                .unwrap();
2201
2202        assert_eq!(assessment.timing, NavcenTiming::Unparseable);
2203        assert_eq!(
2204            assessment.outage_start.as_deref(),
2205            Some("24 JUL 2026 | 25 JUL 2026")
2206        );
2207        assert!(assessment.status.usable);
2208    }
2209
2210    #[test]
2211    fn navcen_assessment_applies_bounded_fcstextd_timing() {
2212        let row = r#"
2213            <td class="views-field-field-gps-prn">9</td>
2214            <td class="views-field-field-nanu-outage-start-date">24 JUL 2026</td>
2215            <td class="views-field-field-nanu-type">FCSTEXTD</td>
2216            <td class="views-field-field-nanu-subject">
2217              JDAY 205/0115 - JDAY 205/1315
2218            </td>
2219            <td class="nanu-active-check">1</td>
2220        "#;
2221        let assessment =
2222            navcen_assessment_from_row(row, UtcInstant::from_utc(2026, 7, 24, 2, 0, 0, 0).unwrap())
2223                .unwrap();
2224
2225        assert!(matches!(assessment.timing, NavcenTiming::Parsed(_)));
2226        assert!(!assessment.status.usable);
2227    }
2228
2229    #[test]
2230    fn only_legacy_outage_forecast_types_require_bounded_timing() {
2231        for nanu_type in ["FCSTDV", "FCSTMX", "FCSTEXTD"] {
2232            assert!(forecast_outage_type(nanu_type), "{nanu_type}");
2233        }
2234        for nanu_type in [
2235            "FCSTSUMM",
2236            "FCSTCANC",
2237            "FCSTUUFN",
2238            "FCSTRESCD",
2239            "UNUSABLE",
2240            "DECOM",
2241        ] {
2242            assert!(!forecast_outage_type(nanu_type), "{nanu_type}");
2243        }
2244    }
2245
2246    #[test]
2247    fn lenient_builder_all_resolvable_has_empty_skipped() {
2248        let omms = [
2249            omm_named("GPS BIIF-8  (PRN 03)", 40294),
2250            omm_named("GPS BIII-1  (PRN 04)", 43873),
2251        ];
2252        let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
2253        assert_eq!(catalog.records.len(), 2);
2254        assert!(catalog.skipped.is_empty());
2255        // Matches the strict builder exactly when nothing is skipped.
2256        assert_eq!(
2257            catalog.records,
2258            from_celestrak_omm(GnssSystem::Gps, &omms).unwrap()
2259        );
2260    }
2261}