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::ephemeris::Sp3;
63use crate::id::GnssSystem;
64use core::fmt::{self, Write as _};
65
66/// The CelesTrak GP group each system's identity base is fetched from.
67///
68/// These mirror the live CelesTrak group names (`gps-ops`, `galileo`, `glo-ops`,
69/// `beidou`); QZSS has no dedicated group and is carried in the combined `gnss`
70/// group, so its records are filtered out of that feed by the caller.
71const fn celestrak_group(system: GnssSystem) -> &'static str {
72    match system {
73        GnssSystem::Gps => "gps-ops",
74        GnssSystem::Galileo => "galileo",
75        GnssSystem::Glonass => "glo-ops",
76        GnssSystem::BeiDou => "beidou",
77        GnssSystem::Qzss => "gnss",
78        GnssSystem::Navic | GnssSystem::Sbas => "gnss",
79    }
80}
81
82/// Failure modes of the constellation catalog builders.
83///
84/// Mirrors the typed error pattern used by the core parsers (for example
85/// `astro::omm::OmmError`): a small enum with a `Display` and `std::error::Error`
86/// implementation, never a panic on malformed input.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum ConstellationError {
89    /// A CelesTrak `OBJECT_NAME` did not contain a parseable `(PRN nn)` block,
90    /// or the OMM carried no object name at all. Holds the offending name.
91    MissingPrn(Option<String>),
92    /// The NAVCEN status bytes were not valid UTF-8.
93    NavcenNotUtf8,
94    /// The NAVCEN status HTML contained no GPS constellation rows.
95    NavcenNoRows,
96    /// A required NAVCEN integer cell could not be parsed. Holds the field name
97    /// and the offending text.
98    NavcenBadField {
99        /// The NAVCEN field whose cell failed to parse (for example `gps-prn`).
100        field: &'static str,
101        /// The raw cell text that failed to parse.
102        value: String,
103    },
104    /// A catalog failed SP3 validation. Holds a description of the findings.
105    Sp3Validation(String),
106}
107
108impl fmt::Display for ConstellationError {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            ConstellationError::MissingPrn(Some(name)) => {
112                write!(f, "CelesTrak OBJECT_NAME has no PRN: {name:?}")
113            }
114            ConstellationError::MissingPrn(None) => {
115                write!(f, "CelesTrak record has no OBJECT_NAME")
116            }
117            ConstellationError::NavcenNotUtf8 => write!(f, "NAVCEN bytes are not valid UTF-8"),
118            ConstellationError::NavcenNoRows => write!(f, "NAVCEN HTML has no GPS rows"),
119            ConstellationError::NavcenBadField { field, value } => {
120                write!(f, "NAVCEN field {field} has invalid integer {value:?}")
121            }
122            ConstellationError::Sp3Validation(msg) => {
123                write!(f, "GNSS catalog failed SP3 validation: {msg}")
124            }
125        }
126    }
127}
128
129impl std::error::Error for ConstellationError {}
130
131/// Per-source provenance kept on a [`Record`].
132///
133/// `active` in a record means the satellite is present in the base identity
134/// source. `usable` is an advisory health flag; for the current GPS path it is
135/// `true` unless a compatible merged NAVCEN row carries an active NANU that
136/// marks the PRN unusable or decommissioned.
137#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct RecordSource {
139    /// CelesTrak `gps-ops` identity provenance.
140    pub celestrak: Option<CelestrakSource>,
141    /// NAVCEN overlay that was merged into this record.
142    pub navcen: Option<NavcenSource>,
143    /// A NAVCEN row that matched the PRN but was not merged because its block
144    /// type was incompatible with the CelesTrak identity (a PRN transition).
145    pub navcen_conflict: Option<NavcenSource>,
146}
147
148/// CelesTrak `gps-ops` provenance fields preserved on a record.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct CelestrakSource {
151    /// CelesTrak GP group the record came from (`gps-ops`).
152    pub group: String,
153    /// The OMM `OBJECT_NAME`.
154    pub object_name: Option<String>,
155    /// The OMM `OBJECT_ID` (international designator).
156    pub object_id: Option<String>,
157    /// The OMM `EPOCH`, ISO-8601.
158    pub epoch: Option<String>,
159    /// Block type parsed from the object name (`IIF`, `IIR`, `IIR-M`, `III`).
160    pub block_type: Option<String>,
161}
162
163/// NAVCEN status provenance fields preserved on a record or conflict.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct NavcenSource {
166    /// Space Vehicle Number.
167    pub svn: Option<u16>,
168    /// Block type as reported by NAVCEN.
169    pub block_type: Option<String>,
170    /// Orbital plane letter.
171    pub plane: Option<String>,
172    /// Slot within the plane.
173    pub slot: Option<String>,
174    /// Clock type.
175    pub clock: Option<String>,
176    /// NANU type code (for example `FCSTSUMM`, `UNUSABLE`, `DECOM`).
177    pub nanu_type: Option<String>,
178    /// NANU subject line.
179    pub nanu_subject: Option<String>,
180    /// Whether the row carried an active NANU.
181    pub active_nanu: bool,
182}
183
184/// A normalized GNSS satellite identity record.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Record {
187    /// The constellation. GPS today; the type is system-tagged for extension.
188    pub system: GnssSystem,
189    /// The within-constellation PRN.
190    pub prn: u16,
191    /// Space Vehicle Number, when known (CelesTrak alone leaves this `None`).
192    pub svn: Option<u16>,
193    /// NORAD catalog id.
194    pub norad_id: u32,
195    /// Canonical SP3/RINEX satellite token (`G03`).
196    pub sp3_id: String,
197    /// GLONASS FDMA L1/L2 frequency-channel number (`k`, in `-7..=6`), `None`
198    /// for the CDMA constellations. This is the one identity datum that is not
199    /// present in any OMM feed; it is resolved from the orbital slot via the
200    /// published IGS/MCC slot-channel table ([`glonass_fdma_channel`]).
201    pub fdma_channel: Option<i8>,
202    /// Present in the base identity source.
203    pub active: bool,
204    /// Advisory usability flag.
205    pub usable: bool,
206    /// Source provenance.
207    pub source: RecordSource,
208}
209
210/// A parsed row from NAVCEN's GPS constellation status table.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct NavcenStatus {
213    /// The constellation (GPS).
214    pub system: GnssSystem,
215    /// The within-constellation PRN.
216    pub prn: u16,
217    /// Space Vehicle Number, when present.
218    pub svn: Option<u16>,
219    /// Whether the satellite is usable per the active NANU (if any).
220    pub usable: bool,
221    /// Whether the row carried an active NANU.
222    pub active_nanu: bool,
223    /// NANU type code.
224    pub nanu_type: Option<String>,
225    /// NANU subject line.
226    pub nanu_subject: Option<String>,
227    /// Orbital plane letter.
228    pub plane: Option<String>,
229    /// Slot within the plane.
230    pub slot: Option<String>,
231    /// Block type.
232    pub block_type: Option<String>,
233    /// Clock type.
234    pub clock: Option<String>,
235}
236
237/// Validation report for a constellation catalog.
238#[derive(Debug, Clone, PartialEq, Eq, Default)]
239pub struct Validation {
240    /// Active+usable catalog SP3 ids absent from the compared product.
241    pub missing_sp3_ids: Vec<String>,
242    /// `(system, PRN)` pairs that appear in more than one record. Keyed by
243    /// system so a legitimate multi-system catalog (GPS PRN 1 and Galileo PRN 1)
244    /// is not reported as a false duplicate.
245    pub duplicate_prns: Vec<(GnssSystem, u16)>,
246    /// NORAD ids that appear in more than one record.
247    pub duplicate_norad_ids: Vec<u32>,
248    /// `(system, PRN)` pairs that are inactive or unusable.
249    pub inactive_unusable_prns: Vec<(GnssSystem, u16)>,
250    /// SP3 ids present in the product but absent from the active+usable catalog.
251    pub extra_sp3_ids: Vec<String>,
252}
253
254/// A single field change on a PRN that exists in both diffed snapshots.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct FieldChange<T> {
257    /// The constellation.
258    pub system: GnssSystem,
259    /// The PRN.
260    pub prn: u16,
261    /// The value in the previous snapshot.
262    pub from: T,
263    /// The value in the current snapshot.
264    pub to: T,
265}
266
267/// Change report between two catalog snapshots, keyed by `(system, prn)`.
268#[derive(Debug, Clone, PartialEq, Eq, Default)]
269pub struct Diff {
270    /// PRNs present only in the current snapshot.
271    pub added: Vec<Record>,
272    /// PRNs present only in the previous snapshot.
273    pub removed: Vec<Record>,
274    /// NORAD id reassignments on a held PRN.
275    pub norad_reassigned: Vec<FieldChange<u32>>,
276    /// SP3 id changes on a held PRN.
277    pub sp3_id_changed: Vec<FieldChange<String>>,
278    /// SVN changes on a held PRN.
279    pub svn_changed: Vec<FieldChange<Option<u16>>>,
280    /// GLONASS FDMA frequency-channel corrections on a held slot.
281    pub fdma_channel_changed: Vec<FieldChange<Option<i8>>>,
282    /// Activity flips on a held PRN.
283    pub activity_changed: Vec<FieldChange<bool>>,
284    /// Usability flips on a held PRN.
285    pub usability_changed: Vec<FieldChange<bool>>,
286}
287
288/// How the CSV `active` column renders booleans.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub enum BoolStyle {
291    /// `true` / `false` (the conventional CSV form).
292    #[default]
293    Lower,
294    /// `True` / `False` (for a consumer that reads Python booleans).
295    Title,
296}
297
298/// Render the canonical SP3/RINEX satellite token for a constellation + PRN
299/// (`(Gps, 7)` -> `"G07"`, `(Glonass, 13)` -> `"R13"`).
300#[must_use]
301pub fn gnss_sp3_id(system: GnssSystem, prn: u16) -> String {
302    format!("{}{prn:02}", system.letter())
303}
304
305/// The within-system identity an OMM `OBJECT_NAME` resolves to for a system.
306struct Identity {
307    /// The within-constellation PRN / orbital slot (the `nn` in the SP3 token).
308    prn: u16,
309    /// GLONASS FDMA channel, when applicable.
310    fdma_channel: Option<i8>,
311}
312
313/// An OMM record that [`from_celestrak_omm_lenient`] could not resolve to a
314/// [`Record`] for the requested system.
315///
316/// Carries the entry's identity (not just a count) so the caller can triage why
317/// it was skipped: a record from another constellation in a combined feed (QZSS,
318/// or anything else, living in the `gnss` group), versus a satellite of the
319/// requested system whose name does not yet resolve (a freshly launched
320/// GLONASS/Galileo not yet in the published slot/SVID table).
321#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct SkippedOmm {
323    /// The OMM `OBJECT_NAME`, when present.
324    pub object_name: Option<String>,
325    /// The OMM `NORAD_CAT_ID`.
326    pub norad_id: u32,
327}
328
329/// The result of a lenient constellation catalog build: the records that
330/// resolved, plus the OMM entries that did not.
331///
332/// Mirrors the partial-success convention of
333/// [`crate::astro::omm::OmmArray`] / [`crate::astro::sgp4::TleFile`], but keeps
334/// the skipped entries' identities (rather than a bare count) because, unlike a
335/// malformed JSON element, an unresolved OMM here carries a meaningful
336/// `OBJECT_NAME`/`NORAD_CAT_ID` the caller needs to act on.
337#[derive(Debug, Clone, PartialEq, Eq, Default)]
338pub struct Catalog {
339    /// Records built from resolvable OMM entries, sorted by `(system, prn)`.
340    pub records: Vec<Record>,
341    /// Entries whose `OBJECT_NAME` did not resolve to a PRN for the requested
342    /// system, in input order.
343    pub skipped: Vec<SkippedOmm>,
344}
345
346/// Build records for `system` from already-parsed CelesTrak OMM records, failing
347/// on the first unresolvable entry.
348///
349/// The OMM source carries no SVN, so records built from it alone have
350/// `svn: None`; GPS can be enriched afterwards with [`merge_navcen`]. Records
351/// are returned sorted by `(system, prn)`. Fails with
352/// [`ConstellationError::MissingPrn`] when an `OBJECT_NAME` cannot be resolved to
353/// a PRN for `system` (an unparseable name, or a GLONASS/Galileo satellite not
354/// in the published slot/SVID table).
355///
356/// Use this for a single-system feed already filtered to `system` (`gps-ops`,
357/// `glo-ops`, ...), where an unresolvable name is a genuine error. To ingest a
358/// raw combined feed (the `gnss` group carries QZSS plus the other systems, and
359/// freshly launched satellites resolve to `None`) without aborting, use
360/// [`from_celestrak_omm_lenient`].
361pub fn from_celestrak_omm(
362    system: GnssSystem,
363    omms: &[Omm],
364) -> Result<Vec<Record>, ConstellationError> {
365    let mut records = Vec::with_capacity(omms.len());
366    for omm in omms {
367        records.push(record_from_omm(system, omm)?);
368    }
369    records.sort_by_key(|r| (r.system, r.prn));
370    Ok(records)
371}
372
373/// Build records for `system` from already-parsed CelesTrak OMM records,
374/// skipping (rather than aborting on) entries that do not resolve.
375///
376/// The lenient sibling of [`from_celestrak_omm`]: every OMM whose `OBJECT_NAME`
377/// resolves to a PRN for `system` becomes a [`Record`]; every entry that does
378/// not is collected into [`Catalog::skipped`] with its identity. This is what a
379/// binding feeds a raw combined CelesTrak `gnss` feed: filter to one system by
380/// keeping `records` and discarding the `skipped` entries that belong to other
381/// constellations, while still seeing which satellites of `system` failed to
382/// resolve. Resolvable records are returned sorted by `(system, prn)`; no
383/// fabricated record is emitted for a skipped entry.
384#[must_use]
385pub fn from_celestrak_omm_lenient(system: GnssSystem, omms: &[Omm]) -> Catalog {
386    let mut records = Vec::with_capacity(omms.len());
387    let mut skipped = Vec::new();
388    for omm in omms {
389        match record_from_omm(system, omm) {
390            Ok(record) => records.push(record),
391            Err(_) => skipped.push(SkippedOmm {
392                object_name: omm.object_name.clone(),
393                norad_id: omm.norad_cat_id,
394            }),
395        }
396    }
397    records.sort_by_key(|r| (r.system, r.prn));
398    Catalog { records, skipped }
399}
400
401fn record_from_omm(system: GnssSystem, omm: &Omm) -> Result<Record, ConstellationError> {
402    let object_name = omm.object_name.as_deref();
403    let identity = system_identity(system, object_name)
404        .ok_or_else(|| ConstellationError::MissingPrn(omm.object_name.clone()))?;
405
406    Ok(Record {
407        system,
408        prn: identity.prn,
409        svn: None,
410        norad_id: omm.norad_cat_id,
411        sp3_id: gnss_sp3_id(system, identity.prn),
412        fdma_channel: identity.fdma_channel,
413        active: true,
414        usable: true,
415        source: RecordSource {
416            celestrak: Some(CelestrakSource {
417                group: celestrak_group(system).to_string(),
418                object_name: omm.object_name.clone(),
419                object_id: omm.object_id.clone(),
420                epoch: Some(epoch_iso8601(omm)),
421                block_type: block_type_from_object_name(system, object_name),
422            }),
423            navcen: None,
424            navcen_conflict: None,
425        },
426    })
427}
428
429/// Resolve the per-system within-constellation identity from an `OBJECT_NAME`.
430///
431/// Each constellation names its satellites differently in the CelesTrak feeds,
432/// so the adapter is dispatched on [`GnssSystem`]. Returns `None` when the name
433/// cannot be resolved to a valid PRN for the system.
434///
435/// NavIC/IRNSS and SBAS are **deliberately unsupported** here and always return
436/// `None` (no name will resolve), matching the module-level scope: NavIC OMM
437/// names (`IRNSS-1A`, `NVS-01`) carry no PRN and have no published
438/// build-id->PRN table comparable to Galileo's GSAT map, and SBAS PRNs (120..)
439/// are payload assignments not derivable from the geostationary host's name.
440/// Adding either would require a new identity source, not just a name parser; a
441/// caller passing `Navic`/`Sbas` gets an empty catalog rather than a fabricated
442/// record. This is intentional, not an oversight.
443fn system_identity(system: GnssSystem, name: Option<&str>) -> Option<Identity> {
444    match system {
445        GnssSystem::Gps => prn_from_object_name(name).map(|prn| Identity {
446            prn,
447            fdma_channel: None,
448        }),
449        GnssSystem::BeiDou => paren_letter_prn(name, 'C').map(|prn| Identity {
450            prn,
451            fdma_channel: None,
452        }),
453        GnssSystem::Qzss => qzss_slot_from_object_name(name).map(|prn| Identity {
454            prn,
455            fdma_channel: None,
456        }),
457        GnssSystem::Galileo => {
458            let gsat = gsat_from_object_name(name)?;
459            galileo_prn_for_gsat(gsat).map(|prn| Identity {
460                prn,
461                fdma_channel: None,
462            })
463        }
464        GnssSystem::Glonass => {
465            let number = paren_number(name)?;
466            let slot = glonass_slot_for_number(number)?;
467            Some(Identity {
468                prn: slot,
469                fdma_channel: glonass_fdma_channel(slot),
470            })
471        }
472        // NavIC/IRNSS and SBAS are out of scope (see the doc comment above): no
473        // name resolves, so these systems yield an empty catalog rather than a
474        // guessed PRN.
475        GnssSystem::Navic | GnssSystem::Sbas => None,
476    }
477}
478
479fn epoch_iso8601(omm: &Omm) -> String {
480    let e = &omm.epoch;
481    format!(
482        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
483        e.year, e.month, e.day, e.hour, e.minute, e.second, e.microsecond
484    )
485}
486
487/// Parse `(PRN nn)` from a CelesTrak object name, stripping leading zeros.
488///
489/// Matches the reference regex `\(PRN\s*0*([0-9]{1,3})\)` (case-insensitive),
490/// including its *search* semantics: every `(PRN` occurrence is tried, so a
491/// later valid `(PRN nn)` is found even if an earlier `(PRN ...)` does not
492/// parse. The PRN is up to three significant digits and must be positive.
493fn prn_from_object_name(name: Option<&str>) -> Option<u16> {
494    let name = name?;
495    let mut from = 0;
496    while let Some(rel) = find_ci(&name[from..], "(PRN") {
497        let after = from + rel + "(PRN".len();
498        if let Some(prn) = prn_at(&name[after..]) {
499            return Some(prn);
500        }
501        from = after;
502    }
503    None
504}
505
506/// Parse `\s*0*([0-9]{1,3})\)` at the start of `rest`.
507fn prn_at(rest: &str) -> Option<u16> {
508    let rest = rest.trim_start();
509    let bytes = rest.as_bytes();
510
511    let mut i = 0;
512    while i < bytes.len() && bytes[i] == b'0' {
513        i += 1;
514    }
515    let digit_start = i;
516    let mut count = 0;
517    while i < bytes.len() && bytes[i].is_ascii_digit() && count < 3 {
518        i += 1;
519        count += 1;
520    }
521    if i >= bytes.len() || bytes[i] != b')' || digit_start == i {
522        return None;
523    }
524    let value: u16 = rest[digit_start..i].parse().ok()?;
525    (value > 0).then_some(value)
526}
527
528/// Parse a parenthesized `(<letter>nn)` PRN from a CelesTrak object name.
529///
530/// BeiDou names the PRN inline, e.g. `BEIDOU-3 M1 (C19)`; the leading letter is
531/// the RINEX system letter. Reuses the GPS [`prn_at`] digit reader (leading
532/// zeros stripped, up to three significant digits, positive) and the same
533/// search semantics, so a later valid group wins over an earlier bad one.
534fn paren_letter_prn(name: Option<&str>, letter: char) -> Option<u16> {
535    let name = name?;
536    let needle = format!("({letter}");
537    let mut from = 0;
538    while let Some(rel) = find_ci(&name[from..], &needle) {
539        let after = from + rel + needle.len();
540        if let Some(prn) = prn_at(&name[after..]) {
541            return Some(prn);
542        }
543        from = after;
544    }
545    None
546}
547
548/// Parse the first parenthesized integer `(ddd)` from a CelesTrak object name.
549///
550/// GLONASS names carry the GLONASS (Uragan) number this way, e.g.
551/// `COSMOS 2456 (730)`.
552fn paren_number(name: Option<&str>) -> Option<u16> {
553    let name = name?;
554    let open = name.find('(')?;
555    let rest = &name[open + 1..];
556    let close = rest.find(')')?;
557    let digits = rest[..close].trim();
558    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
559        return None;
560    }
561    digits.parse().ok()
562}
563
564/// Parse the QZSS RINEX slot from a CelesTrak object name.
565///
566/// QZSS names carry the broadcast PRN, e.g. `QZS-2 (QZSS/PRN 194)`; the RINEX
567/// slot is `PRN - 192` (`J01`..`J09`), per RINEX 3.0x. Broadcast PRNs outside
568/// `193..=201` are rejected.
569fn qzss_slot_from_object_name(name: Option<&str>) -> Option<u16> {
570    let name = name?;
571    let mut from = 0;
572    while let Some(rel) = find_ci(&name[from..], "PRN") {
573        let after = from + rel + "PRN".len();
574        if let Some(prn) = leading_uint(&name[after..]) {
575            if (193..=201).contains(&prn) {
576                return Some(prn - 192);
577            }
578        }
579        from = after;
580    }
581    None
582}
583
584/// Parse the `GSATdddd` build id from a Galileo CelesTrak object name
585/// (`GSAT0210 (GALILEO 13)` -> `210`).
586fn gsat_from_object_name(name: Option<&str>) -> Option<u16> {
587    let name = name?;
588    let rel = find_ci(name, "GSAT")?;
589    leading_uint(&name[rel + "GSAT".len()..])
590}
591
592/// Read the leading run of ASCII digits (after optional whitespace) as a `u16`.
593fn leading_uint(rest: &str) -> Option<u16> {
594    let rest = rest.trim_start();
595    let end = rest
596        .find(|c: char| !c.is_ascii_digit())
597        .unwrap_or(rest.len());
598    rest.get(..end).filter(|s| !s.is_empty())?.parse().ok()
599}
600
601/// GSAT build id -> Galileo SVID (E-number).
602///
603/// The SVID is fixed per satellite at commissioning and is published in the EU
604/// GSC constellation information / Galileo metadata, so this is a stable
605/// identity table rather than a status snapshot. It carries no PRN in the OMM
606/// feed (the name is the `GSATdddd` build id), hence this lookup. Satellites
607/// with no SVID assigned yet (GIOVE prototypes, freshly launched FOC) are
608/// absent and resolve to `None`. Cross-checked against the broadcasting
609/// E-PRN set in the 2026-06-24 IGS broadcast navigation file. Source: EU GNSS
610/// Service Centre, <https://www.gsc-europa.eu/system-service-status/constellation-information>.
611#[must_use]
612pub fn galileo_prn_for_gsat(gsat: u16) -> Option<u16> {
613    let prn = match gsat {
614        101 => 11,
615        102 => 12,
616        103 => 19,
617        104 => 20,
618        201 => 18,
619        202 => 14,
620        203 => 26,
621        204 => 22,
622        205 => 24,
623        206 => 30,
624        207 => 7,
625        208 => 8,
626        209 => 9,
627        210 => 1,
628        211 => 2,
629        212 => 3,
630        213 => 4,
631        214 => 5,
632        215 => 21,
633        216 => 25,
634        217 => 27,
635        218 => 31,
636        219 => 36,
637        220 => 13,
638        221 => 15,
639        222 => 33,
640        223 => 34,
641        224 => 10,
642        225 => 29,
643        226 => 23,
644        227 => 6,
645        _ => return None,
646    };
647    Some(prn)
648}
649
650/// GLONASS (Uragan) number -> orbital slot (`1..=24`) for the operational
651/// constellation.
652///
653/// The OMM `OBJECT_NAME` carries the GLONASS number, not the slot, and slot
654/// occupancy rotates as satellites are replaced, so this is a point-in-time
655/// snapshot of the published IGS/MCC / IAC constellation status matching the
656/// committed `glonass_ops_sample.json` epoch (2026-06). Regenerate the two
657/// together when the constellation changes. Source: IAC GLONASS constellation
658/// status / List of GLONASS satellites; cross-checked against the CelesTrak
659/// `glo-ops` NORAD ids.
660#[must_use]
661pub fn glonass_slot_for_number(number: u16) -> Option<u16> {
662    let slot = match number {
663        730 => 1,
664        747 => 2,
665        744 => 3,
666        759 => 4,
667        756 => 5,
668        704 => 6,
669        745 => 7,
670        743 => 8,
671        702 => 9,
672        723 => 10,
673        705 => 11,
674        758 => 12,
675        721 => 13,
676        752 => 14,
677        757 => 15,
678        761 => 16,
679        751 => 17,
680        754 => 18,
681        707 => 19,
682        708 => 20,
683        755 => 21,
684        706 => 22,
685        732 => 23,
686        760 => 24,
687        _ => return None,
688    };
689    Some(slot)
690}
691
692/// GLONASS orbital slot (`1..=24`) -> FDMA L1/L2 frequency-channel number `k`.
693///
694/// This is the published IGS/MCC slot<->channel assignment: antipodal slots
695/// (same plane, 180 deg apart in argument of latitude) share a channel, so only
696/// 14 of the channels in `-7..=6` are in use. The mapping is stable over time
697/// (verified identical between the 2018 UNB/IAC published table and the
698/// 2026-06-24 IGS merged broadcast navigation file), and is the bit-exact golden
699/// for the FDMA datum, which appears in no OMM feed. Sources:
700/// - GLONASS Constellation Status, R. B. Langley, UNB (IAC Moscow / IGS),
701///   <https://gge.ext.unb.ca/Resources/GLONASSConstellationStatus.txt>.
702/// - IGS daily merged broadcast navigation (GLONASS frequency-number field).
703#[must_use]
704pub fn glonass_fdma_channel(slot: u16) -> Option<i8> {
705    let channel = match slot {
706        1 => 1,
707        2 => -4,
708        3 => 5,
709        4 => 6,
710        5 => 1,
711        6 => -4,
712        7 => 5,
713        8 => 6,
714        9 => -2,
715        10 => -7,
716        11 => 0,
717        12 => -1,
718        13 => -2,
719        14 => -7,
720        15 => 0,
721        16 => -1,
722        17 => 4,
723        18 => -3,
724        19 => 3,
725        20 => 2,
726        21 => 4,
727        22 => -3,
728        23 => 3,
729        24 => 2,
730        _ => return None,
731    };
732    Some(channel)
733}
734
735/// Parse the satellite block/generation from a CelesTrak object name token.
736///
737/// GPS mirrors the reference patterns, matched as whole words in the order
738/// `IIR-M`, `III`, `IIF`, `IIR` so `BIIRM` is not caught by `BIIR`. The other
739/// systems carry their generation in the name too (`BEIDOU-3S`, `BEIDOU-2`;
740/// Galileo IOV `GSAT01xx` vs FOC `GSAT02xx`); GLONASS does not, so it is `None`.
741fn block_type_from_object_name(system: GnssSystem, name: Option<&str>) -> Option<String> {
742    let name = name?;
743    match system {
744        GnssSystem::Gps => {
745            if contains_word_ci(name, "BIIRM") || contains_word_ci(name, "BIIR-M") {
746                Some("IIR-M".to_string())
747            } else if contains_word_ci(name, "BIII") {
748                Some("III".to_string())
749            } else if contains_word_ci(name, "BIIF") {
750                Some("IIF".to_string())
751            } else if contains_word_ci(name, "BIIR") {
752                Some("IIR".to_string())
753            } else {
754                None
755            }
756        }
757        GnssSystem::BeiDou => {
758            if contains_word_ci(name, "BEIDOU-3S") {
759                Some("BDS-3S".to_string())
760            } else if contains_word_ci(name, "BEIDOU-3") {
761                Some("BDS-3".to_string())
762            } else if contains_word_ci(name, "BEIDOU-2") {
763                Some("BDS-2".to_string())
764            } else {
765                None
766            }
767        }
768        GnssSystem::Galileo => match gsat_from_object_name(Some(name)) {
769            Some(gsat) if gsat < 200 => Some("IOV".to_string()),
770            Some(_) => Some("FOC".to_string()),
771            None => None,
772        },
773        _ => None,
774    }
775}
776
777/// Parse NAVCEN's GPS constellation status HTML from raw bytes.
778///
779/// The parser targets the Drupal table-field classes NAVCEN's public GPS
780/// constellation page uses, scanned without an HTML crate. Returns status rows
781/// sorted by PRN; merge them into CelesTrak records with [`merge_navcen`].
782pub fn parse_navcen(bytes: &[u8]) -> Result<Vec<NavcenStatus>, ConstellationError> {
783    let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
784
785    let mut statuses = Vec::new();
786    for row in tr_blocks(html) {
787        if find_ci(row, "views-field-field-gps-prn").is_none() || find_ci(row, "<td").is_none() {
788            continue;
789        }
790        statuses.push(navcen_status_from_row(row)?);
791    }
792
793    if statuses.is_empty() {
794        return Err(ConstellationError::NavcenNoRows);
795    }
796    statuses.sort_by_key(|s| s.prn);
797    Ok(statuses)
798}
799
800fn navcen_status_from_row(row: &str) -> Result<NavcenStatus, ConstellationError> {
801    let prn = navcen_required_int(row, "gps-prn")?;
802    let svn = navcen_optional_int(row, "gps-svn")?;
803    let nanu_type = navcen_text(row, "nanu-type");
804    let active_nanu = navcen_active(row);
805    let usable = !(active_nanu && unusable_nanu_type(nanu_type.as_deref()));
806
807    Ok(NavcenStatus {
808        system: GnssSystem::Gps,
809        prn,
810        svn,
811        usable,
812        active_nanu,
813        nanu_type: blank_to_none(nanu_type),
814        nanu_subject: blank_to_none(navcen_text(row, "nanu-subject")),
815        plane: blank_to_none(navcen_text(row, "gps-con-plane")),
816        slot: blank_to_none(navcen_text(row, "gps-con-slot")),
817        block_type: blank_to_none(navcen_text(row, "gps-con-block-type")),
818        clock: blank_to_none(navcen_text(row, "gps-con-clock")),
819    })
820}
821
822fn navcen_required_int(row: &str, field: &'static str) -> Result<u16, ConstellationError> {
823    let text = navcen_text(row, field);
824    parse_positive_int(text.as_deref().unwrap_or(""), field)
825}
826
827fn navcen_optional_int(row: &str, field: &'static str) -> Result<Option<u16>, ConstellationError> {
828    match navcen_text(row, field).as_deref() {
829        None | Some("") => Ok(None),
830        Some(text) => parse_positive_int(text, field).map(Some),
831    }
832}
833
834fn parse_positive_int(text: &str, field: &'static str) -> Result<u16, ConstellationError> {
835    let trimmed = text.trim();
836    match trimmed.parse::<u16>() {
837        Ok(value) if value > 0 => Ok(value),
838        _ => Err(ConstellationError::NavcenBadField {
839            field,
840            value: trimmed.to_string(),
841        }),
842    }
843}
844
845fn navcen_text(row: &str, field: &str) -> Option<String> {
846    let needle = format!("views-field-field-{field}");
847    td_inner(row, &needle).map(clean_html)
848}
849
850fn navcen_active(row: &str) -> bool {
851    td_inner(row, "nanu-active-check")
852        .map(clean_html)
853        .as_deref()
854        == Some("1")
855}
856
857fn unusable_nanu_type(nanu_type: Option<&str>) -> bool {
858    nanu_type.is_some_and(|text| {
859        let upper = text.trim().to_ascii_uppercase();
860        matches!(
861            upper.as_str(),
862            "UNUSABLE" | "DECOM" | "FCSTDV" | "FCSTMX" | "FCSTEXTD"
863        )
864    })
865}
866
867/// Merge NAVCEN status rows into normalized records by PRN.
868///
869/// NAVCEN does not publish NORAD ids, so CelesTrak stays the identity base. When
870/// a PRN exists in both sources and the block types are compatible, this fills
871/// `svn`, updates `usable`, and records the NAVCEN provenance. A NAVCEN row that
872/// matches the PRN but carries an incompatible block type (a PRN transition) is
873/// recorded under `navcen_conflict` rather than merged. Returns records sorted
874/// by `(system, prn)`.
875///
876/// NAVCEN's status page is GPS-only, so the overlay is keyed by `(system, PRN)`
877/// and only ever lands on GPS records. Keying by PRN alone would splice GPS SVN /
878/// usability / provenance onto a same-PRN record of another constellation
879/// (`R01`, `J01`, ...), corrupting cross-system identity.
880///
881/// As in the reference (`Map.new(statuses, &{&1.prn, &1})`), at most one status
882/// is kept per `(system, PRN)`; if the input carries duplicates the last wins.
883#[must_use]
884pub fn merge_navcen(records: &[Record], statuses: &[NavcenStatus]) -> Vec<Record> {
885    let mut by_key: std::collections::HashMap<(GnssSystem, u16), &NavcenStatus> =
886        std::collections::HashMap::with_capacity(statuses.len());
887    for status in statuses {
888        by_key.insert((status.system, status.prn), status);
889    }
890
891    let mut merged: Vec<Record> = records
892        .iter()
893        .map(|record| {
894            by_key
895                .get(&(record.system, record.prn))
896                .map_or_else(|| record.clone(), |status| merge_status(record, status))
897        })
898        .collect();
899    merged.sort_by_key(|r| (r.system, r.prn));
900    merged
901}
902
903fn merge_status(record: &Record, status: &NavcenStatus) -> Record {
904    let mut out = record.clone();
905    if navcen_compatible(record, status) {
906        out.svn = status.svn;
907        out.usable = status.usable;
908        out.source.navcen = Some(navcen_source(status));
909    } else {
910        out.source.navcen_conflict = Some(navcen_source(status));
911    }
912    out
913}
914
915fn navcen_source(status: &NavcenStatus) -> NavcenSource {
916    NavcenSource {
917        svn: status.svn,
918        block_type: status.block_type.clone(),
919        plane: status.plane.clone(),
920        slot: status.slot.clone(),
921        clock: status.clock.clone(),
922        nanu_type: status.nanu_type.clone(),
923        nanu_subject: status.nanu_subject.clone(),
924        active_nanu: status.active_nanu,
925    }
926}
927
928fn navcen_compatible(record: &Record, status: &NavcenStatus) -> bool {
929    let celestrak_block = record
930        .source
931        .celestrak
932        .as_ref()
933        .and_then(|c| c.block_type.as_deref());
934    let navcen_block = status
935        .block_type
936        .as_deref()
937        .map(|b| b.trim().to_ascii_uppercase());
938
939    match (celestrak_block, navcen_block) {
940        (Some(a), Some(b)) => a == b,
941        _ => true,
942    }
943}
944
945/// Export records as the compact mapping CSV.
946///
947/// The header is `prn,norad_cat_id,active,sp3_id`. The `active` column is `true`
948/// only when both `active` and `usable` hold. Records are sorted by
949/// `(system, prn)`; the system is encoded in the `sp3_id` letter, so equal PRNs
950/// across systems are ordered deterministically without a separate column.
951#[must_use]
952pub fn to_csv(records: &[Record], booleans: BoolStyle) -> String {
953    let mut sorted: Vec<&Record> = records.iter().collect();
954    sorted.sort_by_key(|r| (r.system, r.prn));
955
956    let mut out = String::from("prn,norad_cat_id,active,sp3_id\n");
957    for record in sorted {
958        let active = format_bool(operational(record), booleans);
959        let _ = writeln!(
960            out,
961            "{},{},{},{}",
962            record.prn, record.norad_id, active, record.sp3_id
963        );
964    }
965    out
966}
967
968fn format_bool(value: bool, style: BoolStyle) -> &'static str {
969    match (style, value) {
970        (BoolStyle::Lower, true) => "true",
971        (BoolStyle::Lower, false) => "false",
972        (BoolStyle::Title, true) => "True",
973        (BoolStyle::Title, false) => "False",
974    }
975}
976
977fn operational(record: &Record) -> bool {
978    record.active && record.usable
979}
980
981/// Validate catalog identity without an SP3 product.
982///
983/// Reports duplicate PRNs, duplicate NORAD ids, and PRNs that are inactive or
984/// unusable.
985#[must_use]
986pub fn validate(records: &[Record]) -> Validation {
987    validation(records, None)
988}
989
990/// Validate catalog identity against a loaded SP3 product.
991///
992/// `missing_sp3_ids` reports active+usable catalog ids absent from the product;
993/// `extra_sp3_ids` reports product ids absent from the active+usable catalog,
994/// restricted to the constellations the catalog covers (so a single-system
995/// catalog is not flagged against a multi-GNSS product's other systems).
996#[must_use]
997pub fn validate_against_sp3(records: &[Record], sp3: &Sp3) -> Validation {
998    let ids: Vec<String> = sp3
999        .header
1000        .satellites
1001        .iter()
1002        .map(ToString::to_string)
1003        .collect();
1004    validation(records, Some(&ids))
1005}
1006
1007/// Validate catalog identity against a plain list of SP3/RINEX satellite tokens.
1008#[must_use]
1009pub fn validate_against_sp3_ids(records: &[Record], sp3_ids: &[&str]) -> Validation {
1010    let ids: Vec<String> = sp3_ids.iter().map(|id| (*id).to_string()).collect();
1011    validation(records, Some(&ids))
1012}
1013
1014fn validation(records: &[Record], sp3_ids: Option<&[String]>) -> Validation {
1015    let mut report = Validation {
1016        missing_sp3_ids: Vec::new(),
1017        duplicate_prns: duplicates(records.iter().map(|r| (r.system, r.prn))),
1018        duplicate_norad_ids: duplicates(records.iter().map(|r| r.norad_id)),
1019        inactive_unusable_prns: inactive_unusable_prns(records),
1020        extra_sp3_ids: Vec::new(),
1021    };
1022
1023    if let Some(sp3_ids) = sp3_ids {
1024        // Only compare against the systems this catalog actually covers, so a
1025        // GPS-only catalog is not flagged for the Galileo/GLONASS ids in a
1026        // multi-GNSS product. The hardcoded `'G'` filter generalizes to the set
1027        // of system letters present in the records.
1028        let letters: std::collections::HashSet<char> =
1029            records.iter().map(|r| r.system.letter()).collect();
1030        let catalog: Vec<String> = records
1031            .iter()
1032            .filter(|r| operational(r))
1033            .map(|r| r.sp3_id.to_ascii_uppercase())
1034            .collect();
1035        let product: Vec<String> = sp3_ids
1036            .iter()
1037            .map(|id| id.to_ascii_uppercase())
1038            .filter(|id| id.chars().next().is_some_and(|c| letters.contains(&c)))
1039            .collect();
1040
1041        report.missing_sp3_ids = set_difference(&catalog, &product);
1042        report.extra_sp3_ids = set_difference(&product, &catalog);
1043    }
1044
1045    report
1046}
1047
1048fn duplicates<T>(values: impl Iterator<Item = T>) -> Vec<T>
1049where
1050    T: Ord + Copy,
1051{
1052    let mut seen: Vec<T> = values.collect();
1053    seen.sort_unstable();
1054    let mut out = Vec::new();
1055    let mut i = 0;
1056    while i < seen.len() {
1057        let mut j = i + 1;
1058        while j < seen.len() && seen[j] == seen[i] {
1059            j += 1;
1060        }
1061        if j - i > 1 {
1062            out.push(seen[i]);
1063        }
1064        i = j;
1065    }
1066    out
1067}
1068
1069fn inactive_unusable_prns(records: &[Record]) -> Vec<(GnssSystem, u16)> {
1070    let mut prns: Vec<(GnssSystem, u16)> = records
1071        .iter()
1072        .filter(|r| !operational(r))
1073        .map(|r| (r.system, r.prn))
1074        .collect();
1075    prns.sort_unstable();
1076    prns.dedup();
1077    prns
1078}
1079
1080fn set_difference(left: &[String], right: &[String]) -> Vec<String> {
1081    let mut out: Vec<String> = left
1082        .iter()
1083        .filter(|id| !right.contains(id))
1084        .cloned()
1085        .collect();
1086    out.sort();
1087    out.dedup();
1088    out
1089}
1090
1091/// Returns `true` when a validation report has no findings.
1092#[must_use]
1093pub fn is_valid(report: &Validation) -> bool {
1094    report.missing_sp3_ids.is_empty()
1095        && report.duplicate_prns.is_empty()
1096        && report.duplicate_norad_ids.is_empty()
1097        && report.inactive_unusable_prns.is_empty()
1098        && report.extra_sp3_ids.is_empty()
1099}
1100
1101/// Validate against a plain SP3 id list and fail unless the catalog is clean.
1102///
1103/// A build-time gate: returns `Ok(())` when the report has no findings, otherwise
1104/// [`ConstellationError::Sp3Validation`] describing them.
1105pub fn validate_against_sp3_ids_strict(
1106    records: &[Record],
1107    sp3_ids: &[&str],
1108) -> Result<(), ConstellationError> {
1109    let report = validate_against_sp3_ids(records, sp3_ids);
1110    if is_valid(&report) {
1111        Ok(())
1112    } else {
1113        Err(ConstellationError::Sp3Validation(describe_findings(
1114            &report,
1115        )))
1116    }
1117}
1118
1119fn describe_findings(report: &Validation) -> String {
1120    let mut parts = Vec::new();
1121    if !report.missing_sp3_ids.is_empty() {
1122        parts.push(format!("missing_sp3_ids: {:?}", report.missing_sp3_ids));
1123    }
1124    if !report.extra_sp3_ids.is_empty() {
1125        parts.push(format!("extra_sp3_ids: {:?}", report.extra_sp3_ids));
1126    }
1127    if !report.duplicate_prns.is_empty() {
1128        parts.push(format!("duplicate_prns: {:?}", report.duplicate_prns));
1129    }
1130    if !report.duplicate_norad_ids.is_empty() {
1131        parts.push(format!(
1132            "duplicate_norad_ids: {:?}",
1133            report.duplicate_norad_ids
1134        ));
1135    }
1136    if !report.inactive_unusable_prns.is_empty() {
1137        parts.push(format!(
1138            "inactive_unusable_prns: {:?}",
1139            report.inactive_unusable_prns
1140        ));
1141    }
1142    parts.join("; ")
1143}
1144
1145/// Compare two catalog snapshots by `(system, prn)` identity.
1146///
1147/// Assumes each input has at most one record per `(system, prn)`; run
1148/// [`validate`] first on hand-edited catalogs and treat duplicate findings as
1149/// malformed input rather than a constellation change.
1150#[must_use]
1151pub fn diff(previous: &[Record], current: &[Record]) -> Diff {
1152    let key = |r: &Record| (r.system, r.prn);
1153
1154    let added: Vec<Record> = current
1155        .iter()
1156        .filter(|c| !previous.iter().any(|p| key(p) == key(c)))
1157        .cloned()
1158        .collect();
1159    let removed: Vec<Record> = previous
1160        .iter()
1161        .filter(|p| !current.iter().any(|c| key(c) == key(p)))
1162        .cloned()
1163        .collect();
1164
1165    let mut added = added;
1166    let mut removed = removed;
1167    added.sort_by_key(|r| (r.system, r.prn));
1168    removed.sort_by_key(|r| (r.system, r.prn));
1169
1170    let mut common: Vec<(GnssSystem, u16)> = previous
1171        .iter()
1172        .filter_map(|p| current.iter().find(|c| key(c) == key(p)).map(|_| key(p)))
1173        .collect();
1174    common.sort_unstable();
1175
1176    let pairs: Vec<(&Record, &Record)> = common
1177        .iter()
1178        .map(|k| {
1179            let p = previous.iter().find(|r| key(r) == *k).expect("common key");
1180            let c = current.iter().find(|r| key(r) == *k).expect("common key");
1181            (p, c)
1182        })
1183        .collect();
1184
1185    Diff {
1186        added,
1187        removed,
1188        norad_reassigned: changes(&pairs, |r| r.norad_id),
1189        sp3_id_changed: changes(&pairs, |r| r.sp3_id.clone()),
1190        svn_changed: changes(&pairs, |r| r.svn),
1191        fdma_channel_changed: changes(&pairs, |r| r.fdma_channel),
1192        activity_changed: changes(&pairs, |r| r.active),
1193        usability_changed: changes(&pairs, |r| r.usable),
1194    }
1195}
1196
1197fn changes<T, F>(pairs: &[(&Record, &Record)], field: F) -> Vec<FieldChange<T>>
1198where
1199    T: PartialEq,
1200    F: Fn(&Record) -> T,
1201{
1202    pairs
1203        .iter()
1204        .filter_map(|(p, c)| {
1205            let from = field(p);
1206            let to = field(c);
1207            if from == to {
1208                None
1209            } else {
1210                Some(FieldChange {
1211                    system: p.system,
1212                    prn: p.prn,
1213                    from,
1214                    to,
1215                })
1216            }
1217        })
1218        .collect()
1219}
1220
1221/// Returns `true` when a diff has any findings.
1222#[must_use]
1223pub fn changed(diff: &Diff) -> bool {
1224    !diff.added.is_empty()
1225        || !diff.removed.is_empty()
1226        || !diff.norad_reassigned.is_empty()
1227        || !diff.sp3_id_changed.is_empty()
1228        || !diff.svn_changed.is_empty()
1229        || !diff.fdma_channel_changed.is_empty()
1230        || !diff.activity_changed.is_empty()
1231        || !diff.usability_changed.is_empty()
1232}
1233
1234// ── HTML/text scanning helpers (dependency-light) ────────────────────────────
1235
1236fn blank_to_none(value: Option<String>) -> Option<String> {
1237    value.filter(|v| !v.is_empty())
1238}
1239
1240/// Case-insensitive ASCII substring search returning the byte offset.
1241fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
1242    let hay = haystack.as_bytes();
1243    let need = needle.as_bytes();
1244    if need.is_empty() {
1245        return Some(0);
1246    }
1247    if hay.len() < need.len() {
1248        return None;
1249    }
1250    (0..=hay.len() - need.len()).find(|&i| {
1251        hay[i..i + need.len()]
1252            .iter()
1253            .zip(need)
1254            .all(|(a, b)| a.eq_ignore_ascii_case(b))
1255    })
1256}
1257
1258fn is_word_byte(b: u8) -> bool {
1259    b.is_ascii_alphanumeric() || b == b'_'
1260}
1261
1262/// Case-insensitive whole-word match, mirroring regex `\bword\b` boundaries.
1263fn contains_word_ci(haystack: &str, word: &str) -> bool {
1264    let hay = haystack.as_bytes();
1265    let need = word.as_bytes();
1266    let n = need.len();
1267    if n == 0 || hay.len() < n {
1268        return false;
1269    }
1270    (0..=hay.len() - n).any(|i| {
1271        let matched = hay[i..i + n]
1272            .iter()
1273            .zip(need)
1274            .all(|(a, b)| a.eq_ignore_ascii_case(b));
1275        if !matched {
1276            return false;
1277        }
1278        let left_ok = i == 0 || !is_word_byte(hay[i - 1]);
1279        let right_ok = i + n == hay.len() || !is_word_byte(hay[i + n]);
1280        left_ok && right_ok
1281    })
1282}
1283
1284/// Split HTML into the inner text of each `<tr>...</tr>` block.
1285fn tr_blocks(html: &str) -> Vec<&str> {
1286    let mut out = Vec::new();
1287    let mut rest = html;
1288    while let Some(start) = find_ci(rest, "<tr") {
1289        let Some(gt) = rest[start..].find('>') else {
1290            break;
1291        };
1292        let content_start = start + gt + 1;
1293        let Some(close) = find_ci(&rest[content_start..], "</tr>") else {
1294            break;
1295        };
1296        out.push(&rest[content_start..content_start + close]);
1297        rest = &rest[content_start + close + "</tr>".len()..];
1298    }
1299    out
1300}
1301
1302/// Inner text of the first `<td>` whose attributes contain `class_needle`.
1303fn td_inner<'a>(row: &'a str, class_needle: &str) -> Option<&'a str> {
1304    let mut rest = row;
1305    loop {
1306        let start = find_ci(rest, "<td")?;
1307        let gt = rest[start..].find('>')?;
1308        let attrs = &rest[start..start + gt];
1309        let content_start = start + gt + 1;
1310        let close = find_ci(&rest[content_start..], "</td>")?;
1311        let inner = &rest[content_start..content_start + close];
1312        if find_ci(attrs, class_needle).is_some() {
1313            return Some(inner);
1314        }
1315        rest = &rest[content_start + close + "</td>".len()..];
1316    }
1317}
1318
1319/// Strip tags, unescape entities, and collapse whitespace, matching the
1320/// reference `clean_html`.
1321fn clean_html(text: &str) -> String {
1322    let mut stripped = String::with_capacity(text.len());
1323    let mut in_tag = false;
1324    for c in text.chars() {
1325        match c {
1326            '<' => in_tag = true,
1327            '>' => in_tag = false,
1328            _ if !in_tag => stripped.push(c),
1329            _ => {}
1330        }
1331    }
1332    let unescaped = html_unescape(&stripped);
1333    unescaped.split_whitespace().collect::<Vec<_>>().join(" ")
1334}
1335
1336/// Decode HTML entities: the named set the reference handles plus numeric
1337/// character references (`&#160;`, `&#xA0;`). Numeric decoding is a superset of
1338/// the reference's named-only set, so it never changes a reference-covered case
1339/// but keeps generated markup (numeric `&nbsp;`, `&apos;`) from leaking literal
1340/// `&#160;` into a cell and breaking, for example, optional-integer parsing.
1341fn html_unescape(text: &str) -> String {
1342    let mut out = String::with_capacity(text.len());
1343    let mut rest = text;
1344    while let Some(amp) = rest.find('&') {
1345        out.push_str(&rest[..amp]);
1346        let tail = &rest[amp..];
1347        if let Some((decoded, consumed)) = decode_entity(tail) {
1348            out.push(decoded);
1349            rest = &tail[consumed..];
1350        } else {
1351            out.push('&');
1352            rest = &tail[1..];
1353        }
1354    }
1355    out.push_str(rest);
1356    out
1357}
1358
1359/// Decode a single entity at the start of `s` (which begins with `&`), returning
1360/// the decoded char and the number of bytes consumed, or `None` if `s` does not
1361/// start with a recognized entity.
1362fn decode_entity(s: &str) -> Option<(char, usize)> {
1363    for (entity, decoded) in [
1364        ("&amp;", '&'),
1365        ("&lt;", '<'),
1366        ("&gt;", '>'),
1367        ("&quot;", '"'),
1368        ("&#39;", '\''),
1369        ("&apos;", '\''),
1370        ("&nbsp;", ' '),
1371    ] {
1372        if s.starts_with(entity) {
1373            return Some((decoded, entity.len()));
1374        }
1375    }
1376
1377    // Numeric character reference: &#DDD; or &#xHHH;
1378    let body = s.strip_prefix("&#")?;
1379    let semi = body.find(';')?;
1380    let (digits, radix) = match body.strip_prefix(['x', 'X']) {
1381        Some(hex) => (&hex[..semi - 1], 16),
1382        None => (&body[..semi], 10),
1383    };
1384    if digits.is_empty() {
1385        return None;
1386    }
1387    let code = u32::from_str_radix(digits, radix).ok()?;
1388    let decoded = char::from_u32(code)?;
1389    Some((decoded, "&#".len() + semi + 1))
1390}
1391
1392#[cfg(test)]
1393mod tests {
1394    use super::*;
1395
1396    #[test]
1397    fn prn_parses_padded_and_multi_digit() {
1398        assert_eq!(prn_from_object_name(Some("GPS BIIF-8  (PRN 03)")), Some(3));
1399        assert_eq!(prn_from_object_name(Some("GPS BIII-10 (PRN 13)")), Some(13));
1400        assert_eq!(prn_from_object_name(Some("X (PRN 003)")), Some(3));
1401    }
1402
1403    #[test]
1404    fn prn_search_skips_unparseable_earlier_occurrence() {
1405        // A leading "(PRN ...)" that does not parse must not block a later valid
1406        // one, matching the reference regex's search semantics.
1407        assert_eq!(
1408            prn_from_object_name(Some("GPS (PRN X) BIIF (PRN 07)")),
1409            Some(7)
1410        );
1411        assert_eq!(prn_from_object_name(Some("GPS WITHOUT PRN")), None);
1412        assert_eq!(prn_from_object_name(Some("(PRN 000)")), None);
1413    }
1414
1415    #[test]
1416    fn html_unescape_decodes_named_and_numeric_entities() {
1417        assert_eq!(html_unescape("a &amp; b"), "a & b");
1418        assert_eq!(html_unescape("&#39;x&#39;"), "'x'");
1419        // Numeric references for NBSP (decimal and hex) decode to spaces.
1420        assert_eq!(html_unescape("&#160;"), "\u{a0}");
1421        assert_eq!(html_unescape("&#xA0;"), "\u{a0}");
1422        // An unrecognized "&" is left literal rather than dropped.
1423        assert_eq!(html_unescape("AT&T"), "AT&T");
1424    }
1425
1426    #[test]
1427    fn optional_int_treats_numeric_nbsp_cell_as_blank() {
1428        // A cell whose only content is a numeric NBSP cleans to whitespace and
1429        // collapses to "", so it is absent rather than a parse error.
1430        let row = r#"<td class="views-field-field-gps-svn">&#160;</td>"#;
1431        assert_eq!(navcen_optional_int(row, "gps-svn"), Ok(None));
1432    }
1433
1434    #[test]
1435    fn beidou_prn_parses_from_parenthesized_letter_group() {
1436        assert_eq!(paren_letter_prn(Some("BEIDOU-3 M1 (C19)"), 'C'), Some(19));
1437        assert_eq!(paren_letter_prn(Some("BEIDOU-2 G8 (C01)"), 'C'), Some(1));
1438        assert_eq!(paren_letter_prn(Some("BEIDOU-3 G2 (C60)"), 'C'), Some(60));
1439        assert_eq!(paren_letter_prn(Some("NO LETTER GROUP"), 'C'), None);
1440    }
1441
1442    #[test]
1443    fn qzss_slot_is_broadcast_prn_minus_192() {
1444        // RINEX 3.0x: J-slot = broadcast PRN - 192, valid only for 193..=201.
1445        assert_eq!(
1446            qzss_slot_from_object_name(Some("QZS-2 (QZSS/PRN 194)")),
1447            Some(2)
1448        );
1449        assert_eq!(
1450            qzss_slot_from_object_name(Some("QZS-3 (QZSS/PRN 199)")),
1451            Some(7)
1452        );
1453        assert_eq!(
1454            qzss_slot_from_object_name(Some("QZS-6 (QZSS/PRN 200)")),
1455            Some(8)
1456        );
1457        // Out-of-band broadcast PRN (e.g. an SBAS-style 122) is rejected.
1458        assert_eq!(qzss_slot_from_object_name(Some("X (PRN 122)")), None);
1459    }
1460
1461    #[test]
1462    fn galileo_gsat_parses_and_maps_to_svid() {
1463        assert_eq!(
1464            gsat_from_object_name(Some("GSAT0210 (GALILEO 13)")),
1465            Some(210)
1466        );
1467        assert_eq!(
1468            gsat_from_object_name(Some("GSAT0101 (GALILEO-PFM)")),
1469            Some(101)
1470        );
1471        assert_eq!(gsat_from_object_name(Some("COSMOS 2456 (730)")), None);
1472        // GSAT0210 ("GALILEO 13") is SVID E01, not E13 - the table, not the name.
1473        assert_eq!(galileo_prn_for_gsat(210), Some(1));
1474        assert_eq!(galileo_prn_for_gsat(211), Some(2));
1475        assert_eq!(galileo_prn_for_gsat(101), Some(11));
1476        assert_eq!(galileo_prn_for_gsat(228), None);
1477    }
1478
1479    #[test]
1480    fn glonass_number_resolves_to_slot_and_channel() {
1481        assert_eq!(paren_number(Some("COSMOS 2456 (730)")), Some(730));
1482        assert_eq!(glonass_slot_for_number(730), Some(1));
1483        assert_eq!(glonass_slot_for_number(721), Some(13));
1484        assert_eq!(glonass_slot_for_number(999), None);
1485        // FDMA channels: antipodal slots (180 deg apart in plane) share a
1486        // channel, e.g. slots 1 and 5 are both +1, 2 and 6 both -4.
1487        assert_eq!(glonass_fdma_channel(1), Some(1));
1488        assert_eq!(glonass_fdma_channel(5), Some(1));
1489        assert_eq!(glonass_fdma_channel(2), Some(-4));
1490        assert_eq!(glonass_fdma_channel(6), Some(-4));
1491        assert_eq!(glonass_fdma_channel(13), Some(-2));
1492        assert_eq!(glonass_fdma_channel(0), None);
1493        assert_eq!(glonass_fdma_channel(25), None);
1494    }
1495
1496    #[test]
1497    fn gnss_sp3_id_renders_per_system_token() {
1498        assert_eq!(gnss_sp3_id(GnssSystem::Gps, 7), "G07");
1499        assert_eq!(gnss_sp3_id(GnssSystem::Galileo, 7), "E07");
1500        assert_eq!(gnss_sp3_id(GnssSystem::Glonass, 13), "R13");
1501        assert_eq!(gnss_sp3_id(GnssSystem::BeiDou, 19), "C19");
1502        assert_eq!(gnss_sp3_id(GnssSystem::Qzss, 2), "J02");
1503    }
1504
1505    /// Minimal OMM carrying only the identity fields the constellation builders
1506    /// read (`OBJECT_NAME`, `NORAD_CAT_ID`); the orbital elements are unused here.
1507    fn omm_named(object_name: &str, norad_cat_id: u32) -> Omm {
1508        Omm {
1509            ccsds_omm_vers: String::new(),
1510            creation_date: None,
1511            originator: None,
1512            object_name: Some(object_name.to_string()),
1513            object_id: None,
1514            center_name: None,
1515            ref_frame: None,
1516            time_system: None,
1517            mean_element_theory: None,
1518            epoch: crate::astro::omm::OmmEpoch {
1519                year: 2026,
1520                month: 6,
1521                day: 24,
1522                hour: 0,
1523                minute: 0,
1524                second: 0,
1525                microsecond: 0,
1526            },
1527            mean_motion: 0.0,
1528            eccentricity: 0.0,
1529            inclination_deg: 0.0,
1530            ra_of_asc_node_deg: 0.0,
1531            arg_of_pericenter_deg: 0.0,
1532            mean_anomaly_deg: 0.0,
1533            ephemeris_type: 0,
1534            classification_type: String::new(),
1535            norad_cat_id,
1536            element_set_no: 0,
1537            rev_at_epoch: 0,
1538            bstar: 0.0,
1539            mean_motion_dot: 0.0,
1540            mean_motion_ddot: 0.0,
1541        }
1542    }
1543
1544    #[test]
1545    fn lenient_builder_returns_partial_success_with_skipped_identities() {
1546        // A raw combined `gnss`-style slice viewed as GPS: two resolvable GPS
1547        // names, one QZSS member (lives in the same combined group, no GPS PRN),
1548        // and one GPS-looking name with no parseable PRN block.
1549        let omms = [
1550            omm_named("GPS BIIF-8  (PRN 03)", 40294),
1551            omm_named("QZS-2 (QZSS/PRN 194)", 42738),
1552            omm_named("GPS BIII-1  (PRN 04)", 43873),
1553            omm_named("GPS WITHOUT PRN", 99999),
1554        ];
1555
1556        // Strict path aborts on the first unresolvable entry, naming it.
1557        assert_eq!(
1558            from_celestrak_omm(GnssSystem::Gps, &omms),
1559            Err(ConstellationError::MissingPrn(Some(
1560                "QZS-2 (QZSS/PRN 194)".to_string()
1561            )))
1562        );
1563
1564        // Lenient path keeps the resolvable GPS records (sorted by prn) and
1565        // reports each skipped entry's identity, in input order.
1566        let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1567        assert_eq!(
1568            catalog.records.iter().map(|r| r.prn).collect::<Vec<_>>(),
1569            vec![3, 4]
1570        );
1571        assert!(catalog.records.iter().all(|r| r.system == GnssSystem::Gps));
1572        assert_eq!(
1573            catalog.skipped,
1574            vec![
1575                SkippedOmm {
1576                    object_name: Some("QZS-2 (QZSS/PRN 194)".to_string()),
1577                    norad_id: 42738,
1578                },
1579                SkippedOmm {
1580                    object_name: Some("GPS WITHOUT PRN".to_string()),
1581                    norad_id: 99999,
1582                },
1583            ]
1584        );
1585    }
1586
1587    #[test]
1588    fn lenient_builder_partitions_a_realistic_combined_gnss_feed() {
1589        // The combined CelesTrak `gnss` group carries every system, each with its
1590        // own naming convention. Lenient build for one system must keep only that
1591        // system's records and skip the rest - the per-system identity adapters
1592        // are what distinguish them (only GPS uses the bare `(PRN nn)` form, QZSS
1593        // uses `(QZSS/PRN nnn)`, GLONASS a bare `(number)`, etc.).
1594        let feed = [
1595            omm_named("GPS BIIF-8  (PRN 03)", 40294),
1596            omm_named("COSMOS 2456 (730)", 37139), // GLONASS slot 1
1597            omm_named("GSAT0210 (GALILEO 13)", 41859), // Galileo E01
1598            omm_named("BEIDOU-3 M1 (C19)", 43001), // BeiDou C19
1599            omm_named("QZS-2 (QZSS/PRN 194)", 42738), // QZSS J02
1600        ];
1601
1602        let gps = from_celestrak_omm_lenient(GnssSystem::Gps, &feed);
1603        assert_eq!(
1604            gps.records
1605                .iter()
1606                .map(|r| r.sp3_id.as_str())
1607                .collect::<Vec<_>>(),
1608            vec!["G03"]
1609        );
1610        assert_eq!(gps.skipped.len(), 4, "the four non-GPS names are skipped");
1611
1612        let glonass = from_celestrak_omm_lenient(GnssSystem::Glonass, &feed);
1613        assert_eq!(
1614            glonass
1615                .records
1616                .iter()
1617                .map(|r| r.sp3_id.as_str())
1618                .collect::<Vec<_>>(),
1619            vec!["R01"]
1620        );
1621        assert_eq!(glonass.skipped.len(), 4);
1622
1623        // The partitions are disjoint: each system claims exactly one record and
1624        // skips the other four, so no name is double-counted across systems.
1625        for system in [
1626            GnssSystem::Gps,
1627            GnssSystem::Glonass,
1628            GnssSystem::Galileo,
1629            GnssSystem::BeiDou,
1630            GnssSystem::Qzss,
1631        ] {
1632            let cat = from_celestrak_omm_lenient(system, &feed);
1633            assert_eq!(cat.records.len(), 1, "{system:?}: one record");
1634            assert_eq!(cat.skipped.len(), 4, "{system:?}: four skipped");
1635            assert!(cat.records.iter().all(|r| r.system == system));
1636        }
1637    }
1638
1639    /// Build a minimal record for a given system/prn carrying a CelesTrak source
1640    /// (so block-type compatibility is exercised), used by the merge tests.
1641    fn record_for(system: GnssSystem, prn: u16, norad_id: u32) -> Record {
1642        Record {
1643            system,
1644            prn,
1645            svn: None,
1646            norad_id,
1647            sp3_id: gnss_sp3_id(system, prn),
1648            fdma_channel: None,
1649            active: true,
1650            usable: true,
1651            source: RecordSource::default(),
1652        }
1653    }
1654
1655    fn navcen_gps(prn: u16, svn: u16, usable: bool) -> NavcenStatus {
1656        NavcenStatus {
1657            system: GnssSystem::Gps,
1658            prn,
1659            svn: Some(svn),
1660            usable,
1661            active_nanu: !usable,
1662            nanu_type: None,
1663            nanu_subject: None,
1664            plane: None,
1665            slot: None,
1666            block_type: None,
1667            clock: None,
1668        }
1669    }
1670
1671    #[test]
1672    fn merge_navcen_does_not_cross_systems() {
1673        // GPS PRN 1 and GLONASS slot 1 (R01) share the integer PRN. A GPS-only
1674        // NAVCEN row for PRN 1 must merge onto the GPS record and leave R01/J01
1675        // untouched - keying by PRN alone corrupted the GLONASS/QZSS records.
1676        let records = [
1677            record_for(GnssSystem::Gps, 1, 40000),
1678            record_for(GnssSystem::Glonass, 1, 50000),
1679            record_for(GnssSystem::Qzss, 1, 60000),
1680        ];
1681        let statuses = [navcen_gps(1, 63, false)];
1682
1683        let merged = merge_navcen(&records, &statuses);
1684
1685        let gps = merged.iter().find(|r| r.system == GnssSystem::Gps).unwrap();
1686        assert_eq!(gps.svn, Some(63), "GPS record gets the NAVCEN SVN");
1687        assert!(!gps.usable, "GPS usability follows NAVCEN");
1688        assert!(gps.source.navcen.is_some());
1689
1690        for system in [GnssSystem::Glonass, GnssSystem::Qzss] {
1691            let other = merged.iter().find(|r| r.system == system).unwrap();
1692            assert_eq!(other.svn, None, "{system:?} must not inherit GPS SVN");
1693            assert!(other.usable, "{system:?} usability untouched");
1694            assert!(
1695                other.source.navcen.is_none(),
1696                "{system:?} must carry no NAVCEN provenance"
1697            );
1698        }
1699    }
1700
1701    #[test]
1702    fn merge_navcen_sorts_by_system_then_prn() {
1703        let records = [
1704            record_for(GnssSystem::Glonass, 2, 50002),
1705            record_for(GnssSystem::Gps, 5, 40005),
1706            record_for(GnssSystem::Gps, 1, 40001),
1707        ];
1708        let merged = merge_navcen(&records, &[]);
1709        let order: Vec<(GnssSystem, u16)> = merged.iter().map(|r| (r.system, r.prn)).collect();
1710        assert_eq!(
1711            order,
1712            vec![
1713                (GnssSystem::Gps, 1),
1714                (GnssSystem::Gps, 5),
1715                (GnssSystem::Glonass, 2),
1716            ]
1717        );
1718    }
1719
1720    #[test]
1721    fn lenient_builder_all_resolvable_has_empty_skipped() {
1722        let omms = [
1723            omm_named("GPS BIIF-8  (PRN 03)", 40294),
1724            omm_named("GPS BIII-1  (PRN 04)", 43873),
1725        ];
1726        let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1727        assert_eq!(catalog.records.len(), 2);
1728        assert!(catalog.skipped.is_empty());
1729        // Matches the strict builder exactly when nothing is skipped.
1730        assert_eq!(
1731            catalog.records,
1732            from_celestrak_omm(GnssSystem::Gps, &omms).unwrap()
1733        );
1734    }
1735}