use crate::astro::omm::Omm;
use crate::ephemeris::Sp3;
use crate::id::GnssSystem;
use core::fmt::{self, Write as _};
const fn celestrak_group(system: GnssSystem) -> &'static str {
match system {
GnssSystem::Gps => "gps-ops",
GnssSystem::Galileo => "galileo",
GnssSystem::Glonass => "glo-ops",
GnssSystem::BeiDou => "beidou",
GnssSystem::Qzss => "gnss",
GnssSystem::Navic | GnssSystem::Sbas => "gnss",
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConstellationError {
MissingPrn(Option<String>),
NavcenNotUtf8,
NavcenNoRows,
NavcenBadField {
field: &'static str,
value: String,
},
Sp3Validation(String),
}
impl fmt::Display for ConstellationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConstellationError::MissingPrn(Some(name)) => {
write!(f, "CelesTrak OBJECT_NAME has no PRN: {name:?}")
}
ConstellationError::MissingPrn(None) => {
write!(f, "CelesTrak record has no OBJECT_NAME")
}
ConstellationError::NavcenNotUtf8 => write!(f, "NAVCEN bytes are not valid UTF-8"),
ConstellationError::NavcenNoRows => write!(f, "NAVCEN HTML has no GPS rows"),
ConstellationError::NavcenBadField { field, value } => {
write!(f, "NAVCEN field {field} has invalid integer {value:?}")
}
ConstellationError::Sp3Validation(msg) => {
write!(f, "GNSS catalog failed SP3 validation: {msg}")
}
}
}
}
impl std::error::Error for ConstellationError {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RecordSource {
pub celestrak: Option<CelestrakSource>,
pub navcen: Option<NavcenSource>,
pub navcen_conflict: Option<NavcenSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CelestrakSource {
pub group: String,
pub object_name: Option<String>,
pub object_id: Option<String>,
pub epoch: Option<String>,
pub block_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavcenSource {
pub svn: Option<u16>,
pub block_type: Option<String>,
pub plane: Option<String>,
pub slot: Option<String>,
pub clock: Option<String>,
pub nanu_type: Option<String>,
pub nanu_subject: Option<String>,
pub active_nanu: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub system: GnssSystem,
pub prn: u16,
pub svn: Option<u16>,
pub norad_id: u32,
pub sp3_id: String,
pub fdma_channel: Option<i8>,
pub active: bool,
pub usable: bool,
pub source: RecordSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavcenStatus {
pub system: GnssSystem,
pub prn: u16,
pub svn: Option<u16>,
pub usable: bool,
pub active_nanu: bool,
pub nanu_type: Option<String>,
pub nanu_subject: Option<String>,
pub plane: Option<String>,
pub slot: Option<String>,
pub block_type: Option<String>,
pub clock: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Validation {
pub missing_sp3_ids: Vec<String>,
pub duplicate_prns: Vec<(GnssSystem, u16)>,
pub duplicate_norad_ids: Vec<u32>,
pub inactive_unusable_prns: Vec<(GnssSystem, u16)>,
pub extra_sp3_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldChange<T> {
pub system: GnssSystem,
pub prn: u16,
pub from: T,
pub to: T,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Diff {
pub added: Vec<Record>,
pub removed: Vec<Record>,
pub norad_reassigned: Vec<FieldChange<u32>>,
pub sp3_id_changed: Vec<FieldChange<String>>,
pub svn_changed: Vec<FieldChange<Option<u16>>>,
pub fdma_channel_changed: Vec<FieldChange<Option<i8>>>,
pub activity_changed: Vec<FieldChange<bool>>,
pub usability_changed: Vec<FieldChange<bool>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BoolStyle {
#[default]
Lower,
Title,
}
#[must_use]
pub fn gnss_sp3_id(system: GnssSystem, prn: u16) -> String {
format!("{}{prn:02}", system.letter())
}
struct Identity {
prn: u16,
fdma_channel: Option<i8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkippedOmm {
pub object_name: Option<String>,
pub norad_id: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Catalog {
pub records: Vec<Record>,
pub skipped: Vec<SkippedOmm>,
}
pub fn from_celestrak_omm(
system: GnssSystem,
omms: &[Omm],
) -> Result<Vec<Record>, ConstellationError> {
let mut records = Vec::with_capacity(omms.len());
for omm in omms {
records.push(record_from_omm(system, omm)?);
}
records.sort_by_key(|r| (r.system, r.prn));
Ok(records)
}
#[must_use]
pub fn from_celestrak_omm_lenient(system: GnssSystem, omms: &[Omm]) -> Catalog {
let mut records = Vec::with_capacity(omms.len());
let mut skipped = Vec::new();
for omm in omms {
match record_from_omm(system, omm) {
Ok(record) => records.push(record),
Err(_) => skipped.push(SkippedOmm {
object_name: omm.object_name.clone(),
norad_id: omm.norad_cat_id,
}),
}
}
records.sort_by_key(|r| (r.system, r.prn));
Catalog { records, skipped }
}
fn record_from_omm(system: GnssSystem, omm: &Omm) -> Result<Record, ConstellationError> {
let object_name = omm.object_name.as_deref();
let identity = system_identity(system, object_name)
.ok_or_else(|| ConstellationError::MissingPrn(omm.object_name.clone()))?;
Ok(Record {
system,
prn: identity.prn,
svn: None,
norad_id: omm.norad_cat_id,
sp3_id: gnss_sp3_id(system, identity.prn),
fdma_channel: identity.fdma_channel,
active: true,
usable: true,
source: RecordSource {
celestrak: Some(CelestrakSource {
group: celestrak_group(system).to_string(),
object_name: omm.object_name.clone(),
object_id: omm.object_id.clone(),
epoch: Some(epoch_iso8601(omm)),
block_type: block_type_from_object_name(system, object_name),
}),
navcen: None,
navcen_conflict: None,
},
})
}
fn system_identity(system: GnssSystem, name: Option<&str>) -> Option<Identity> {
match system {
GnssSystem::Gps => prn_from_object_name(name).map(|prn| Identity {
prn,
fdma_channel: None,
}),
GnssSystem::BeiDou => paren_letter_prn(name, 'C').map(|prn| Identity {
prn,
fdma_channel: None,
}),
GnssSystem::Qzss => qzss_slot_from_object_name(name).map(|prn| Identity {
prn,
fdma_channel: None,
}),
GnssSystem::Galileo => {
let gsat = gsat_from_object_name(name)?;
galileo_prn_for_gsat(gsat).map(|prn| Identity {
prn,
fdma_channel: None,
})
}
GnssSystem::Glonass => {
let number = paren_number(name)?;
let slot = glonass_slot_for_number(number)?;
Some(Identity {
prn: slot,
fdma_channel: glonass_fdma_channel(slot),
})
}
GnssSystem::Navic | GnssSystem::Sbas => None,
}
}
fn epoch_iso8601(omm: &Omm) -> String {
let e = &omm.epoch;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
e.year, e.month, e.day, e.hour, e.minute, e.second, e.microsecond
)
}
fn prn_from_object_name(name: Option<&str>) -> Option<u16> {
let name = name?;
let mut from = 0;
while let Some(rel) = find_ci(&name[from..], "(PRN") {
let after = from + rel + "(PRN".len();
if let Some(prn) = prn_at(&name[after..]) {
return Some(prn);
}
from = after;
}
None
}
fn prn_at(rest: &str) -> Option<u16> {
let rest = rest.trim_start();
let bytes = rest.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i] == b'0' {
i += 1;
}
let digit_start = i;
let mut count = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() && count < 3 {
i += 1;
count += 1;
}
if i >= bytes.len() || bytes[i] != b')' || digit_start == i {
return None;
}
let value: u16 = rest[digit_start..i].parse().ok()?;
(value > 0).then_some(value)
}
fn paren_letter_prn(name: Option<&str>, letter: char) -> Option<u16> {
let name = name?;
let needle = format!("({letter}");
let mut from = 0;
while let Some(rel) = find_ci(&name[from..], &needle) {
let after = from + rel + needle.len();
if let Some(prn) = prn_at(&name[after..]) {
return Some(prn);
}
from = after;
}
None
}
fn paren_number(name: Option<&str>) -> Option<u16> {
let name = name?;
let open = name.find('(')?;
let rest = &name[open + 1..];
let close = rest.find(')')?;
let digits = rest[..close].trim();
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
digits.parse().ok()
}
fn qzss_slot_from_object_name(name: Option<&str>) -> Option<u16> {
let name = name?;
let mut from = 0;
while let Some(rel) = find_ci(&name[from..], "PRN") {
let after = from + rel + "PRN".len();
if let Some(prn) = leading_uint(&name[after..]) {
if (193..=201).contains(&prn) {
return Some(prn - 192);
}
}
from = after;
}
None
}
fn gsat_from_object_name(name: Option<&str>) -> Option<u16> {
let name = name?;
let rel = find_ci(name, "GSAT")?;
leading_uint(&name[rel + "GSAT".len()..])
}
fn leading_uint(rest: &str) -> Option<u16> {
let rest = rest.trim_start();
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
rest.get(..end).filter(|s| !s.is_empty())?.parse().ok()
}
#[must_use]
pub fn galileo_prn_for_gsat(gsat: u16) -> Option<u16> {
let prn = match gsat {
101 => 11,
102 => 12,
103 => 19,
104 => 20,
201 => 18,
202 => 14,
203 => 26,
204 => 22,
205 => 24,
206 => 30,
207 => 7,
208 => 8,
209 => 9,
210 => 1,
211 => 2,
212 => 3,
213 => 4,
214 => 5,
215 => 21,
216 => 25,
217 => 27,
218 => 31,
219 => 36,
220 => 13,
221 => 15,
222 => 33,
223 => 34,
224 => 10,
225 => 29,
226 => 23,
227 => 6,
_ => return None,
};
Some(prn)
}
#[must_use]
pub fn glonass_slot_for_number(number: u16) -> Option<u16> {
let slot = match number {
730 => 1,
747 => 2,
744 => 3,
759 => 4,
756 => 5,
704 => 6,
745 => 7,
743 => 8,
702 => 9,
723 => 10,
705 => 11,
758 => 12,
721 => 13,
752 => 14,
757 => 15,
761 => 16,
751 => 17,
754 => 18,
707 => 19,
708 => 20,
755 => 21,
706 => 22,
732 => 23,
760 => 24,
_ => return None,
};
Some(slot)
}
#[must_use]
pub fn glonass_fdma_channel(slot: u16) -> Option<i8> {
let channel = match slot {
1 => 1,
2 => -4,
3 => 5,
4 => 6,
5 => 1,
6 => -4,
7 => 5,
8 => 6,
9 => -2,
10 => -7,
11 => 0,
12 => -1,
13 => -2,
14 => -7,
15 => 0,
16 => -1,
17 => 4,
18 => -3,
19 => 3,
20 => 2,
21 => 4,
22 => -3,
23 => 3,
24 => 2,
_ => return None,
};
Some(channel)
}
fn block_type_from_object_name(system: GnssSystem, name: Option<&str>) -> Option<String> {
let name = name?;
match system {
GnssSystem::Gps => {
if contains_word_ci(name, "BIIRM") || contains_word_ci(name, "BIIR-M") {
Some("IIR-M".to_string())
} else if contains_word_ci(name, "BIII") {
Some("III".to_string())
} else if contains_word_ci(name, "BIIF") {
Some("IIF".to_string())
} else if contains_word_ci(name, "BIIR") {
Some("IIR".to_string())
} else {
None
}
}
GnssSystem::BeiDou => {
if contains_word_ci(name, "BEIDOU-3S") {
Some("BDS-3S".to_string())
} else if contains_word_ci(name, "BEIDOU-3") {
Some("BDS-3".to_string())
} else if contains_word_ci(name, "BEIDOU-2") {
Some("BDS-2".to_string())
} else {
None
}
}
GnssSystem::Galileo => match gsat_from_object_name(Some(name)) {
Some(gsat) if gsat < 200 => Some("IOV".to_string()),
Some(_) => Some("FOC".to_string()),
None => None,
},
_ => None,
}
}
pub fn parse_navcen(bytes: &[u8]) -> Result<Vec<NavcenStatus>, ConstellationError> {
let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
let mut statuses = Vec::new();
for row in tr_blocks(html) {
if find_ci(row, "views-field-field-gps-prn").is_none() || find_ci(row, "<td").is_none() {
continue;
}
statuses.push(navcen_status_from_row(row)?);
}
if statuses.is_empty() {
return Err(ConstellationError::NavcenNoRows);
}
statuses.sort_by_key(|s| s.prn);
Ok(statuses)
}
fn navcen_status_from_row(row: &str) -> Result<NavcenStatus, ConstellationError> {
let prn = navcen_required_int(row, "gps-prn")?;
let svn = navcen_optional_int(row, "gps-svn")?;
let nanu_type = navcen_text(row, "nanu-type");
let active_nanu = navcen_active(row);
let usable = !(active_nanu && unusable_nanu_type(nanu_type.as_deref()));
Ok(NavcenStatus {
system: GnssSystem::Gps,
prn,
svn,
usable,
active_nanu,
nanu_type: blank_to_none(nanu_type),
nanu_subject: blank_to_none(navcen_text(row, "nanu-subject")),
plane: blank_to_none(navcen_text(row, "gps-con-plane")),
slot: blank_to_none(navcen_text(row, "gps-con-slot")),
block_type: blank_to_none(navcen_text(row, "gps-con-block-type")),
clock: blank_to_none(navcen_text(row, "gps-con-clock")),
})
}
fn navcen_required_int(row: &str, field: &'static str) -> Result<u16, ConstellationError> {
let text = navcen_text(row, field);
parse_positive_int(text.as_deref().unwrap_or(""), field)
}
fn navcen_optional_int(row: &str, field: &'static str) -> Result<Option<u16>, ConstellationError> {
match navcen_text(row, field).as_deref() {
None | Some("") => Ok(None),
Some(text) => parse_positive_int(text, field).map(Some),
}
}
fn parse_positive_int(text: &str, field: &'static str) -> Result<u16, ConstellationError> {
let trimmed = text.trim();
match trimmed.parse::<u16>() {
Ok(value) if value > 0 => Ok(value),
_ => Err(ConstellationError::NavcenBadField {
field,
value: trimmed.to_string(),
}),
}
}
fn navcen_text(row: &str, field: &str) -> Option<String> {
let needle = format!("views-field-field-{field}");
td_inner(row, &needle).map(clean_html)
}
fn navcen_active(row: &str) -> bool {
td_inner(row, "nanu-active-check")
.map(clean_html)
.as_deref()
== Some("1")
}
fn unusable_nanu_type(nanu_type: Option<&str>) -> bool {
nanu_type.is_some_and(|text| {
let upper = text.trim().to_ascii_uppercase();
matches!(
upper.as_str(),
"UNUSABLE" | "DECOM" | "FCSTDV" | "FCSTMX" | "FCSTEXTD"
)
})
}
#[must_use]
pub fn merge_navcen(records: &[Record], statuses: &[NavcenStatus]) -> Vec<Record> {
let mut by_key: std::collections::HashMap<(GnssSystem, u16), &NavcenStatus> =
std::collections::HashMap::with_capacity(statuses.len());
for status in statuses {
by_key.insert((status.system, status.prn), status);
}
let mut merged: Vec<Record> = records
.iter()
.map(|record| {
by_key
.get(&(record.system, record.prn))
.map_or_else(|| record.clone(), |status| merge_status(record, status))
})
.collect();
merged.sort_by_key(|r| (r.system, r.prn));
merged
}
fn merge_status(record: &Record, status: &NavcenStatus) -> Record {
let mut out = record.clone();
if navcen_compatible(record, status) {
out.svn = status.svn;
out.usable = status.usable;
out.source.navcen = Some(navcen_source(status));
} else {
out.source.navcen_conflict = Some(navcen_source(status));
}
out
}
fn navcen_source(status: &NavcenStatus) -> NavcenSource {
NavcenSource {
svn: status.svn,
block_type: status.block_type.clone(),
plane: status.plane.clone(),
slot: status.slot.clone(),
clock: status.clock.clone(),
nanu_type: status.nanu_type.clone(),
nanu_subject: status.nanu_subject.clone(),
active_nanu: status.active_nanu,
}
}
fn navcen_compatible(record: &Record, status: &NavcenStatus) -> bool {
let celestrak_block = record
.source
.celestrak
.as_ref()
.and_then(|c| c.block_type.as_deref());
let navcen_block = status
.block_type
.as_deref()
.map(|b| b.trim().to_ascii_uppercase());
match (celestrak_block, navcen_block) {
(Some(a), Some(b)) => a == b,
_ => true,
}
}
#[must_use]
pub fn to_csv(records: &[Record], booleans: BoolStyle) -> String {
let mut sorted: Vec<&Record> = records.iter().collect();
sorted.sort_by_key(|r| (r.system, r.prn));
let mut out = String::from("prn,norad_cat_id,active,sp3_id\n");
for record in sorted {
let active = format_bool(operational(record), booleans);
let _ = writeln!(
out,
"{},{},{},{}",
record.prn, record.norad_id, active, record.sp3_id
);
}
out
}
fn format_bool(value: bool, style: BoolStyle) -> &'static str {
match (style, value) {
(BoolStyle::Lower, true) => "true",
(BoolStyle::Lower, false) => "false",
(BoolStyle::Title, true) => "True",
(BoolStyle::Title, false) => "False",
}
}
fn operational(record: &Record) -> bool {
record.active && record.usable
}
#[must_use]
pub fn validate(records: &[Record]) -> Validation {
validation(records, None)
}
#[must_use]
pub fn validate_against_sp3(records: &[Record], sp3: &Sp3) -> Validation {
let ids: Vec<String> = sp3
.header
.satellites
.iter()
.map(ToString::to_string)
.collect();
validation(records, Some(&ids))
}
#[must_use]
pub fn validate_against_sp3_ids(records: &[Record], sp3_ids: &[&str]) -> Validation {
let ids: Vec<String> = sp3_ids.iter().map(|id| (*id).to_string()).collect();
validation(records, Some(&ids))
}
fn validation(records: &[Record], sp3_ids: Option<&[String]>) -> Validation {
let mut report = Validation {
missing_sp3_ids: Vec::new(),
duplicate_prns: duplicates(records.iter().map(|r| (r.system, r.prn))),
duplicate_norad_ids: duplicates(records.iter().map(|r| r.norad_id)),
inactive_unusable_prns: inactive_unusable_prns(records),
extra_sp3_ids: Vec::new(),
};
if let Some(sp3_ids) = sp3_ids {
let letters: std::collections::HashSet<char> =
records.iter().map(|r| r.system.letter()).collect();
let catalog: Vec<String> = records
.iter()
.filter(|r| operational(r))
.map(|r| r.sp3_id.to_ascii_uppercase())
.collect();
let product: Vec<String> = sp3_ids
.iter()
.map(|id| id.to_ascii_uppercase())
.filter(|id| id.chars().next().is_some_and(|c| letters.contains(&c)))
.collect();
report.missing_sp3_ids = set_difference(&catalog, &product);
report.extra_sp3_ids = set_difference(&product, &catalog);
}
report
}
fn duplicates<T>(values: impl Iterator<Item = T>) -> Vec<T>
where
T: Ord + Copy,
{
let mut seen: Vec<T> = values.collect();
seen.sort_unstable();
let mut out = Vec::new();
let mut i = 0;
while i < seen.len() {
let mut j = i + 1;
while j < seen.len() && seen[j] == seen[i] {
j += 1;
}
if j - i > 1 {
out.push(seen[i]);
}
i = j;
}
out
}
fn inactive_unusable_prns(records: &[Record]) -> Vec<(GnssSystem, u16)> {
let mut prns: Vec<(GnssSystem, u16)> = records
.iter()
.filter(|r| !operational(r))
.map(|r| (r.system, r.prn))
.collect();
prns.sort_unstable();
prns.dedup();
prns
}
fn set_difference(left: &[String], right: &[String]) -> Vec<String> {
let mut out: Vec<String> = left
.iter()
.filter(|id| !right.contains(id))
.cloned()
.collect();
out.sort();
out.dedup();
out
}
#[must_use]
pub fn is_valid(report: &Validation) -> bool {
report.missing_sp3_ids.is_empty()
&& report.duplicate_prns.is_empty()
&& report.duplicate_norad_ids.is_empty()
&& report.inactive_unusable_prns.is_empty()
&& report.extra_sp3_ids.is_empty()
}
pub fn validate_against_sp3_ids_strict(
records: &[Record],
sp3_ids: &[&str],
) -> Result<(), ConstellationError> {
let report = validate_against_sp3_ids(records, sp3_ids);
if is_valid(&report) {
Ok(())
} else {
Err(ConstellationError::Sp3Validation(describe_findings(
&report,
)))
}
}
fn describe_findings(report: &Validation) -> String {
let mut parts = Vec::new();
if !report.missing_sp3_ids.is_empty() {
parts.push(format!("missing_sp3_ids: {:?}", report.missing_sp3_ids));
}
if !report.extra_sp3_ids.is_empty() {
parts.push(format!("extra_sp3_ids: {:?}", report.extra_sp3_ids));
}
if !report.duplicate_prns.is_empty() {
parts.push(format!("duplicate_prns: {:?}", report.duplicate_prns));
}
if !report.duplicate_norad_ids.is_empty() {
parts.push(format!(
"duplicate_norad_ids: {:?}",
report.duplicate_norad_ids
));
}
if !report.inactive_unusable_prns.is_empty() {
parts.push(format!(
"inactive_unusable_prns: {:?}",
report.inactive_unusable_prns
));
}
parts.join("; ")
}
#[must_use]
pub fn diff(previous: &[Record], current: &[Record]) -> Diff {
let key = |r: &Record| (r.system, r.prn);
let added: Vec<Record> = current
.iter()
.filter(|c| !previous.iter().any(|p| key(p) == key(c)))
.cloned()
.collect();
let removed: Vec<Record> = previous
.iter()
.filter(|p| !current.iter().any(|c| key(c) == key(p)))
.cloned()
.collect();
let mut added = added;
let mut removed = removed;
added.sort_by_key(|r| (r.system, r.prn));
removed.sort_by_key(|r| (r.system, r.prn));
let mut common: Vec<(GnssSystem, u16)> = previous
.iter()
.filter_map(|p| current.iter().find(|c| key(c) == key(p)).map(|_| key(p)))
.collect();
common.sort_unstable();
let pairs: Vec<(&Record, &Record)> = common
.iter()
.map(|k| {
let p = previous.iter().find(|r| key(r) == *k).expect("common key");
let c = current.iter().find(|r| key(r) == *k).expect("common key");
(p, c)
})
.collect();
Diff {
added,
removed,
norad_reassigned: changes(&pairs, |r| r.norad_id),
sp3_id_changed: changes(&pairs, |r| r.sp3_id.clone()),
svn_changed: changes(&pairs, |r| r.svn),
fdma_channel_changed: changes(&pairs, |r| r.fdma_channel),
activity_changed: changes(&pairs, |r| r.active),
usability_changed: changes(&pairs, |r| r.usable),
}
}
fn changes<T, F>(pairs: &[(&Record, &Record)], field: F) -> Vec<FieldChange<T>>
where
T: PartialEq,
F: Fn(&Record) -> T,
{
pairs
.iter()
.filter_map(|(p, c)| {
let from = field(p);
let to = field(c);
if from == to {
None
} else {
Some(FieldChange {
system: p.system,
prn: p.prn,
from,
to,
})
}
})
.collect()
}
#[must_use]
pub fn changed(diff: &Diff) -> bool {
!diff.added.is_empty()
|| !diff.removed.is_empty()
|| !diff.norad_reassigned.is_empty()
|| !diff.sp3_id_changed.is_empty()
|| !diff.svn_changed.is_empty()
|| !diff.fdma_channel_changed.is_empty()
|| !diff.activity_changed.is_empty()
|| !diff.usability_changed.is_empty()
}
fn blank_to_none(value: Option<String>) -> Option<String> {
value.filter(|v| !v.is_empty())
}
fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
let hay = haystack.as_bytes();
let need = needle.as_bytes();
if need.is_empty() {
return Some(0);
}
if hay.len() < need.len() {
return None;
}
(0..=hay.len() - need.len()).find(|&i| {
hay[i..i + need.len()]
.iter()
.zip(need)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
})
}
fn is_word_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn contains_word_ci(haystack: &str, word: &str) -> bool {
let hay = haystack.as_bytes();
let need = word.as_bytes();
let n = need.len();
if n == 0 || hay.len() < n {
return false;
}
(0..=hay.len() - n).any(|i| {
let matched = hay[i..i + n]
.iter()
.zip(need)
.all(|(a, b)| a.eq_ignore_ascii_case(b));
if !matched {
return false;
}
let left_ok = i == 0 || !is_word_byte(hay[i - 1]);
let right_ok = i + n == hay.len() || !is_word_byte(hay[i + n]);
left_ok && right_ok
})
}
fn tr_blocks(html: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = html;
while let Some(start) = find_ci(rest, "<tr") {
let Some(gt) = rest[start..].find('>') else {
break;
};
let content_start = start + gt + 1;
let Some(close) = find_ci(&rest[content_start..], "</tr>") else {
break;
};
out.push(&rest[content_start..content_start + close]);
rest = &rest[content_start + close + "</tr>".len()..];
}
out
}
fn td_inner<'a>(row: &'a str, class_needle: &str) -> Option<&'a str> {
let mut rest = row;
loop {
let start = find_ci(rest, "<td")?;
let gt = rest[start..].find('>')?;
let attrs = &rest[start..start + gt];
let content_start = start + gt + 1;
let close = find_ci(&rest[content_start..], "</td>")?;
let inner = &rest[content_start..content_start + close];
if find_ci(attrs, class_needle).is_some() {
return Some(inner);
}
rest = &rest[content_start + close + "</td>".len()..];
}
}
fn clean_html(text: &str) -> String {
let mut stripped = String::with_capacity(text.len());
let mut in_tag = false;
for c in text.chars() {
match c {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => stripped.push(c),
_ => {}
}
}
let unescaped = html_unescape(&stripped);
unescaped.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn html_unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
if let Some((decoded, consumed)) = decode_entity(tail) {
out.push(decoded);
rest = &tail[consumed..];
} else {
out.push('&');
rest = &tail[1..];
}
}
out.push_str(rest);
out
}
fn decode_entity(s: &str) -> Option<(char, usize)> {
for (entity, decoded) in [
("&", '&'),
("<", '<'),
(">", '>'),
(""", '"'),
("'", '\''),
("'", '\''),
(" ", ' '),
] {
if s.starts_with(entity) {
return Some((decoded, entity.len()));
}
}
let body = s.strip_prefix("&#")?;
let semi = body.find(';')?;
let (digits, radix) = match body.strip_prefix(['x', 'X']) {
Some(hex) => (&hex[..semi - 1], 16),
None => (&body[..semi], 10),
};
if digits.is_empty() {
return None;
}
let code = u32::from_str_radix(digits, radix).ok()?;
let decoded = char::from_u32(code)?;
Some((decoded, "&#".len() + semi + 1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prn_parses_padded_and_multi_digit() {
assert_eq!(prn_from_object_name(Some("GPS BIIF-8 (PRN 03)")), Some(3));
assert_eq!(prn_from_object_name(Some("GPS BIII-10 (PRN 13)")), Some(13));
assert_eq!(prn_from_object_name(Some("X (PRN 003)")), Some(3));
}
#[test]
fn prn_search_skips_unparseable_earlier_occurrence() {
assert_eq!(
prn_from_object_name(Some("GPS (PRN X) BIIF (PRN 07)")),
Some(7)
);
assert_eq!(prn_from_object_name(Some("GPS WITHOUT PRN")), None);
assert_eq!(prn_from_object_name(Some("(PRN 000)")), None);
}
#[test]
fn html_unescape_decodes_named_and_numeric_entities() {
assert_eq!(html_unescape("a & b"), "a & b");
assert_eq!(html_unescape("'x'"), "'x'");
assert_eq!(html_unescape(" "), "\u{a0}");
assert_eq!(html_unescape(" "), "\u{a0}");
assert_eq!(html_unescape("AT&T"), "AT&T");
}
#[test]
fn optional_int_treats_numeric_nbsp_cell_as_blank() {
let row = r#"<td class="views-field-field-gps-svn"> </td>"#;
assert_eq!(navcen_optional_int(row, "gps-svn"), Ok(None));
}
#[test]
fn beidou_prn_parses_from_parenthesized_letter_group() {
assert_eq!(paren_letter_prn(Some("BEIDOU-3 M1 (C19)"), 'C'), Some(19));
assert_eq!(paren_letter_prn(Some("BEIDOU-2 G8 (C01)"), 'C'), Some(1));
assert_eq!(paren_letter_prn(Some("BEIDOU-3 G2 (C60)"), 'C'), Some(60));
assert_eq!(paren_letter_prn(Some("NO LETTER GROUP"), 'C'), None);
}
#[test]
fn qzss_slot_is_broadcast_prn_minus_192() {
assert_eq!(
qzss_slot_from_object_name(Some("QZS-2 (QZSS/PRN 194)")),
Some(2)
);
assert_eq!(
qzss_slot_from_object_name(Some("QZS-3 (QZSS/PRN 199)")),
Some(7)
);
assert_eq!(
qzss_slot_from_object_name(Some("QZS-6 (QZSS/PRN 200)")),
Some(8)
);
assert_eq!(qzss_slot_from_object_name(Some("X (PRN 122)")), None);
}
#[test]
fn galileo_gsat_parses_and_maps_to_svid() {
assert_eq!(
gsat_from_object_name(Some("GSAT0210 (GALILEO 13)")),
Some(210)
);
assert_eq!(
gsat_from_object_name(Some("GSAT0101 (GALILEO-PFM)")),
Some(101)
);
assert_eq!(gsat_from_object_name(Some("COSMOS 2456 (730)")), None);
assert_eq!(galileo_prn_for_gsat(210), Some(1));
assert_eq!(galileo_prn_for_gsat(211), Some(2));
assert_eq!(galileo_prn_for_gsat(101), Some(11));
assert_eq!(galileo_prn_for_gsat(228), None);
}
#[test]
fn glonass_number_resolves_to_slot_and_channel() {
assert_eq!(paren_number(Some("COSMOS 2456 (730)")), Some(730));
assert_eq!(glonass_slot_for_number(730), Some(1));
assert_eq!(glonass_slot_for_number(721), Some(13));
assert_eq!(glonass_slot_for_number(999), None);
assert_eq!(glonass_fdma_channel(1), Some(1));
assert_eq!(glonass_fdma_channel(5), Some(1));
assert_eq!(glonass_fdma_channel(2), Some(-4));
assert_eq!(glonass_fdma_channel(6), Some(-4));
assert_eq!(glonass_fdma_channel(13), Some(-2));
assert_eq!(glonass_fdma_channel(0), None);
assert_eq!(glonass_fdma_channel(25), None);
}
#[test]
fn gnss_sp3_id_renders_per_system_token() {
assert_eq!(gnss_sp3_id(GnssSystem::Gps, 7), "G07");
assert_eq!(gnss_sp3_id(GnssSystem::Galileo, 7), "E07");
assert_eq!(gnss_sp3_id(GnssSystem::Glonass, 13), "R13");
assert_eq!(gnss_sp3_id(GnssSystem::BeiDou, 19), "C19");
assert_eq!(gnss_sp3_id(GnssSystem::Qzss, 2), "J02");
}
fn omm_named(object_name: &str, norad_cat_id: u32) -> Omm {
Omm {
ccsds_omm_vers: String::new(),
creation_date: None,
originator: None,
object_name: Some(object_name.to_string()),
object_id: None,
center_name: None,
ref_frame: None,
time_system: None,
mean_element_theory: None,
epoch: crate::astro::omm::OmmEpoch {
year: 2026,
month: 6,
day: 24,
hour: 0,
minute: 0,
second: 0,
microsecond: 0,
},
mean_motion: 0.0,
eccentricity: 0.0,
inclination_deg: 0.0,
ra_of_asc_node_deg: 0.0,
arg_of_pericenter_deg: 0.0,
mean_anomaly_deg: 0.0,
ephemeris_type: 0,
classification_type: String::new(),
norad_cat_id,
element_set_no: 0,
rev_at_epoch: 0,
bstar: 0.0,
mean_motion_dot: 0.0,
mean_motion_ddot: 0.0,
}
}
#[test]
fn lenient_builder_returns_partial_success_with_skipped_identities() {
let omms = [
omm_named("GPS BIIF-8 (PRN 03)", 40294),
omm_named("QZS-2 (QZSS/PRN 194)", 42738),
omm_named("GPS BIII-1 (PRN 04)", 43873),
omm_named("GPS WITHOUT PRN", 99999),
];
assert_eq!(
from_celestrak_omm(GnssSystem::Gps, &omms),
Err(ConstellationError::MissingPrn(Some(
"QZS-2 (QZSS/PRN 194)".to_string()
)))
);
let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
assert_eq!(
catalog.records.iter().map(|r| r.prn).collect::<Vec<_>>(),
vec![3, 4]
);
assert!(catalog.records.iter().all(|r| r.system == GnssSystem::Gps));
assert_eq!(
catalog.skipped,
vec![
SkippedOmm {
object_name: Some("QZS-2 (QZSS/PRN 194)".to_string()),
norad_id: 42738,
},
SkippedOmm {
object_name: Some("GPS WITHOUT PRN".to_string()),
norad_id: 99999,
},
]
);
}
#[test]
fn lenient_builder_partitions_a_realistic_combined_gnss_feed() {
let feed = [
omm_named("GPS BIIF-8 (PRN 03)", 40294),
omm_named("COSMOS 2456 (730)", 37139), omm_named("GSAT0210 (GALILEO 13)", 41859), omm_named("BEIDOU-3 M1 (C19)", 43001), omm_named("QZS-2 (QZSS/PRN 194)", 42738), ];
let gps = from_celestrak_omm_lenient(GnssSystem::Gps, &feed);
assert_eq!(
gps.records
.iter()
.map(|r| r.sp3_id.as_str())
.collect::<Vec<_>>(),
vec!["G03"]
);
assert_eq!(gps.skipped.len(), 4, "the four non-GPS names are skipped");
let glonass = from_celestrak_omm_lenient(GnssSystem::Glonass, &feed);
assert_eq!(
glonass
.records
.iter()
.map(|r| r.sp3_id.as_str())
.collect::<Vec<_>>(),
vec!["R01"]
);
assert_eq!(glonass.skipped.len(), 4);
for system in [
GnssSystem::Gps,
GnssSystem::Glonass,
GnssSystem::Galileo,
GnssSystem::BeiDou,
GnssSystem::Qzss,
] {
let cat = from_celestrak_omm_lenient(system, &feed);
assert_eq!(cat.records.len(), 1, "{system:?}: one record");
assert_eq!(cat.skipped.len(), 4, "{system:?}: four skipped");
assert!(cat.records.iter().all(|r| r.system == system));
}
}
fn record_for(system: GnssSystem, prn: u16, norad_id: u32) -> Record {
Record {
system,
prn,
svn: None,
norad_id,
sp3_id: gnss_sp3_id(system, prn),
fdma_channel: None,
active: true,
usable: true,
source: RecordSource::default(),
}
}
fn navcen_gps(prn: u16, svn: u16, usable: bool) -> NavcenStatus {
NavcenStatus {
system: GnssSystem::Gps,
prn,
svn: Some(svn),
usable,
active_nanu: !usable,
nanu_type: None,
nanu_subject: None,
plane: None,
slot: None,
block_type: None,
clock: None,
}
}
#[test]
fn merge_navcen_does_not_cross_systems() {
let records = [
record_for(GnssSystem::Gps, 1, 40000),
record_for(GnssSystem::Glonass, 1, 50000),
record_for(GnssSystem::Qzss, 1, 60000),
];
let statuses = [navcen_gps(1, 63, false)];
let merged = merge_navcen(&records, &statuses);
let gps = merged.iter().find(|r| r.system == GnssSystem::Gps).unwrap();
assert_eq!(gps.svn, Some(63), "GPS record gets the NAVCEN SVN");
assert!(!gps.usable, "GPS usability follows NAVCEN");
assert!(gps.source.navcen.is_some());
for system in [GnssSystem::Glonass, GnssSystem::Qzss] {
let other = merged.iter().find(|r| r.system == system).unwrap();
assert_eq!(other.svn, None, "{system:?} must not inherit GPS SVN");
assert!(other.usable, "{system:?} usability untouched");
assert!(
other.source.navcen.is_none(),
"{system:?} must carry no NAVCEN provenance"
);
}
}
#[test]
fn merge_navcen_sorts_by_system_then_prn() {
let records = [
record_for(GnssSystem::Glonass, 2, 50002),
record_for(GnssSystem::Gps, 5, 40005),
record_for(GnssSystem::Gps, 1, 40001),
];
let merged = merge_navcen(&records, &[]);
let order: Vec<(GnssSystem, u16)> = merged.iter().map(|r| (r.system, r.prn)).collect();
assert_eq!(
order,
vec![
(GnssSystem::Gps, 1),
(GnssSystem::Gps, 5),
(GnssSystem::Glonass, 2),
]
);
}
#[test]
fn lenient_builder_all_resolvable_has_empty_skipped() {
let omms = [
omm_named("GPS BIIF-8 (PRN 03)", 40294),
omm_named("GPS BIII-1 (PRN 04)", 43873),
];
let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
assert_eq!(catalog.records.len(), 2);
assert!(catalog.skipped.is_empty());
assert_eq!(
catalog.records,
from_celestrak_omm(GnssSystem::Gps, &omms).unwrap()
);
}
}