use crate::astro::omm::Omm;
use crate::ephemeris::Sp3;
use crate::id::GnssSystem;
use core::fmt;
const CELESTRAK_GPS_GROUP: &str = "gps-ops";
#[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 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<u16>,
pub duplicate_norad_ids: Vec<u32>,
pub inactive_unusable_prns: Vec<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 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 gps_sp3_id(prn: u16) -> String {
format!("{}{prn:02}", GnssSystem::Gps.letter())
}
pub fn from_celestrak_omm(omms: &[Omm]) -> Result<Vec<Record>, ConstellationError> {
let mut records = Vec::with_capacity(omms.len());
for omm in omms {
records.push(record_from_omm(omm)?);
}
records.sort_by_key(|r| (r.system, r.prn));
Ok(records)
}
fn record_from_omm(omm: &Omm) -> Result<Record, ConstellationError> {
let object_name = omm.object_name.as_deref();
let prn = prn_from_object_name(object_name)
.ok_or_else(|| ConstellationError::MissingPrn(omm.object_name.clone()))?;
Ok(Record {
system: GnssSystem::Gps,
prn,
svn: None,
norad_id: omm.norad_cat_id,
sp3_id: gps_sp3_id(prn),
active: true,
usable: true,
source: RecordSource {
celestrak: Some(CelestrakSource {
group: CELESTRAK_GPS_GROUP.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(object_name),
}),
navcen: None,
navcen_conflict: 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 block_type_from_object_name(name: Option<&str>) -> Option<String> {
let name = name?;
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
}
}
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 {
match nanu_type {
None => false,
Some(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_prn: std::collections::HashMap<u16, &NavcenStatus> =
std::collections::HashMap::with_capacity(statuses.len());
for status in statuses {
by_prn.insert(status.prn, status);
}
let mut merged: Vec<Record> = records
.iter()
.map(|record| match by_prn.get(&record.prn) {
Some(status) => merge_status(record, status),
None => record.clone(),
})
.collect();
merged.sort_by_key(|r| 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.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);
out.push_str(&format!(
"{},{},{},{}\n",
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.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 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.starts_with('G'))
.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<u16> {
let mut prns: Vec<u16> = records
.iter()
.filter(|r| !operational(r))
.map(|r| 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),
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.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));
}
}