1use crate::astro::omm::Omm;
62use crate::ephemeris::Sp3;
63use crate::id::GnssSystem;
64use core::fmt::{self, Write as _};
65
66const 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#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum ConstellationError {
89 MissingPrn(Option<String>),
92 NavcenNotUtf8,
94 NavcenNoRows,
96 NavcenBadField {
99 field: &'static str,
101 value: String,
103 },
104 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
138pub struct RecordSource {
139 pub celestrak: Option<CelestrakSource>,
141 pub navcen: Option<NavcenSource>,
143 pub navcen_conflict: Option<NavcenSource>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct CelestrakSource {
151 pub group: String,
153 pub object_name: Option<String>,
155 pub object_id: Option<String>,
157 pub epoch: Option<String>,
159 pub block_type: Option<String>,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct NavcenSource {
166 pub svn: Option<u16>,
168 pub block_type: Option<String>,
170 pub plane: Option<String>,
172 pub slot: Option<String>,
174 pub clock: Option<String>,
176 pub nanu_type: Option<String>,
178 pub nanu_subject: Option<String>,
180 pub active_nanu: bool,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Record {
187 pub system: GnssSystem,
189 pub prn: u16,
191 pub svn: Option<u16>,
193 pub norad_id: u32,
195 pub sp3_id: String,
197 pub fdma_channel: Option<i8>,
202 pub active: bool,
204 pub usable: bool,
206 pub source: RecordSource,
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct NavcenStatus {
213 pub system: GnssSystem,
215 pub prn: u16,
217 pub svn: Option<u16>,
219 pub usable: bool,
221 pub active_nanu: bool,
223 pub nanu_type: Option<String>,
225 pub nanu_subject: Option<String>,
227 pub plane: Option<String>,
229 pub slot: Option<String>,
231 pub block_type: Option<String>,
233 pub clock: Option<String>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Default)]
239pub struct Validation {
240 pub missing_sp3_ids: Vec<String>,
242 pub duplicate_prns: Vec<(GnssSystem, u16)>,
246 pub duplicate_norad_ids: Vec<u32>,
248 pub inactive_unusable_prns: Vec<(GnssSystem, u16)>,
250 pub extra_sp3_ids: Vec<String>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct FieldChange<T> {
257 pub system: GnssSystem,
259 pub prn: u16,
261 pub from: T,
263 pub to: T,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Default)]
269pub struct Diff {
270 pub added: Vec<Record>,
272 pub removed: Vec<Record>,
274 pub norad_reassigned: Vec<FieldChange<u32>>,
276 pub sp3_id_changed: Vec<FieldChange<String>>,
278 pub svn_changed: Vec<FieldChange<Option<u16>>>,
280 pub fdma_channel_changed: Vec<FieldChange<Option<i8>>>,
282 pub activity_changed: Vec<FieldChange<bool>>,
284 pub usability_changed: Vec<FieldChange<bool>>,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
290pub enum BoolStyle {
291 #[default]
293 Lower,
294 Title,
296}
297
298#[must_use]
301pub fn gnss_sp3_id(system: GnssSystem, prn: u16) -> String {
302 format!("{}{prn:02}", system.letter())
303}
304
305struct Identity {
307 prn: u16,
309 fdma_channel: Option<i8>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
322pub struct SkippedOmm {
323 pub object_name: Option<String>,
325 pub norad_id: u32,
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Default)]
338pub struct Catalog {
339 pub records: Vec<Record>,
341 pub skipped: Vec<SkippedOmm>,
344}
345
346pub 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#[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
429fn 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 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
487fn 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
506fn 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
528fn 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
548fn 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
564fn 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
584fn 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
592fn 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#[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#[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#[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
735fn 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
777pub 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#[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#[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#[must_use]
986pub fn validate(records: &[Record]) -> Validation {
987 validation(records, None)
988}
989
990#[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#[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 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#[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
1101pub 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#[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#[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
1234fn blank_to_none(value: Option<String>) -> Option<String> {
1237 value.filter(|v| !v.is_empty())
1238}
1239
1240fn 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
1262fn 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
1284fn 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
1302fn 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
1319fn 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
1336fn 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
1359fn decode_entity(s: &str) -> Option<(char, usize)> {
1363 for (entity, decoded) in [
1364 ("&", '&'),
1365 ("<", '<'),
1366 (">", '>'),
1367 (""", '"'),
1368 ("'", '\''),
1369 ("'", '\''),
1370 (" ", ' '),
1371 ] {
1372 if s.starts_with(entity) {
1373 return Some((decoded, entity.len()));
1374 }
1375 }
1376
1377 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 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 & b"), "a & b");
1418 assert_eq!(html_unescape("'x'"), "'x'");
1419 assert_eq!(html_unescape(" "), "\u{a0}");
1421 assert_eq!(html_unescape(" "), "\u{a0}");
1422 assert_eq!(html_unescape("AT&T"), "AT&T");
1424 }
1425
1426 #[test]
1427 fn optional_int_treats_numeric_nbsp_cell_as_blank() {
1428 let row = r#"<td class="views-field-field-gps-svn"> </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 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 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 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 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 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 femtosecond: 0,
1527 },
1528 mean_motion: 0.0,
1529 eccentricity: 0.0,
1530 inclination_deg: 0.0,
1531 ra_of_asc_node_deg: 0.0,
1532 arg_of_pericenter_deg: 0.0,
1533 mean_anomaly_deg: 0.0,
1534 ephemeris_type: 0,
1535 classification_type: String::new(),
1536 norad_cat_id,
1537 element_set_no: 0,
1538 rev_at_epoch: 0,
1539 bstar: 0.0,
1540 mean_motion_dot: 0.0,
1541 mean_motion_ddot: 0.0,
1542 exact_sgp4_epoch: None,
1543 quantize_tle_derived_fields: true,
1544 }
1545 }
1546
1547 #[test]
1548 fn lenient_builder_returns_partial_success_with_skipped_identities() {
1549 let omms = [
1553 omm_named("GPS BIIF-8 (PRN 03)", 40294),
1554 omm_named("QZS-2 (QZSS/PRN 194)", 42738),
1555 omm_named("GPS BIII-1 (PRN 04)", 43873),
1556 omm_named("GPS WITHOUT PRN", 99999),
1557 ];
1558
1559 assert_eq!(
1561 from_celestrak_omm(GnssSystem::Gps, &omms),
1562 Err(ConstellationError::MissingPrn(Some(
1563 "QZS-2 (QZSS/PRN 194)".to_string()
1564 )))
1565 );
1566
1567 let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1570 assert_eq!(
1571 catalog.records.iter().map(|r| r.prn).collect::<Vec<_>>(),
1572 vec![3, 4]
1573 );
1574 assert!(catalog.records.iter().all(|r| r.system == GnssSystem::Gps));
1575 assert_eq!(
1576 catalog.skipped,
1577 vec![
1578 SkippedOmm {
1579 object_name: Some("QZS-2 (QZSS/PRN 194)".to_string()),
1580 norad_id: 42738,
1581 },
1582 SkippedOmm {
1583 object_name: Some("GPS WITHOUT PRN".to_string()),
1584 norad_id: 99999,
1585 },
1586 ]
1587 );
1588 }
1589
1590 #[test]
1591 fn lenient_builder_partitions_a_realistic_combined_gnss_feed() {
1592 let feed = [
1598 omm_named("GPS BIIF-8 (PRN 03)", 40294),
1599 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), ];
1604
1605 let gps = from_celestrak_omm_lenient(GnssSystem::Gps, &feed);
1606 assert_eq!(
1607 gps.records
1608 .iter()
1609 .map(|r| r.sp3_id.as_str())
1610 .collect::<Vec<_>>(),
1611 vec!["G03"]
1612 );
1613 assert_eq!(gps.skipped.len(), 4, "the four non-GPS names are skipped");
1614
1615 let glonass = from_celestrak_omm_lenient(GnssSystem::Glonass, &feed);
1616 assert_eq!(
1617 glonass
1618 .records
1619 .iter()
1620 .map(|r| r.sp3_id.as_str())
1621 .collect::<Vec<_>>(),
1622 vec!["R01"]
1623 );
1624 assert_eq!(glonass.skipped.len(), 4);
1625
1626 for system in [
1629 GnssSystem::Gps,
1630 GnssSystem::Glonass,
1631 GnssSystem::Galileo,
1632 GnssSystem::BeiDou,
1633 GnssSystem::Qzss,
1634 ] {
1635 let cat = from_celestrak_omm_lenient(system, &feed);
1636 assert_eq!(cat.records.len(), 1, "{system:?}: one record");
1637 assert_eq!(cat.skipped.len(), 4, "{system:?}: four skipped");
1638 assert!(cat.records.iter().all(|r| r.system == system));
1639 }
1640 }
1641
1642 fn record_for(system: GnssSystem, prn: u16, norad_id: u32) -> Record {
1645 Record {
1646 system,
1647 prn,
1648 svn: None,
1649 norad_id,
1650 sp3_id: gnss_sp3_id(system, prn),
1651 fdma_channel: None,
1652 active: true,
1653 usable: true,
1654 source: RecordSource::default(),
1655 }
1656 }
1657
1658 fn navcen_gps(prn: u16, svn: u16, usable: bool) -> NavcenStatus {
1659 NavcenStatus {
1660 system: GnssSystem::Gps,
1661 prn,
1662 svn: Some(svn),
1663 usable,
1664 active_nanu: !usable,
1665 nanu_type: None,
1666 nanu_subject: None,
1667 plane: None,
1668 slot: None,
1669 block_type: None,
1670 clock: None,
1671 }
1672 }
1673
1674 #[test]
1675 fn merge_navcen_does_not_cross_systems() {
1676 let records = [
1680 record_for(GnssSystem::Gps, 1, 40000),
1681 record_for(GnssSystem::Glonass, 1, 50000),
1682 record_for(GnssSystem::Qzss, 1, 60000),
1683 ];
1684 let statuses = [navcen_gps(1, 63, false)];
1685
1686 let merged = merge_navcen(&records, &statuses);
1687
1688 let gps = merged.iter().find(|r| r.system == GnssSystem::Gps).unwrap();
1689 assert_eq!(gps.svn, Some(63), "GPS record gets the NAVCEN SVN");
1690 assert!(!gps.usable, "GPS usability follows NAVCEN");
1691 assert!(gps.source.navcen.is_some());
1692
1693 for system in [GnssSystem::Glonass, GnssSystem::Qzss] {
1694 let other = merged.iter().find(|r| r.system == system).unwrap();
1695 assert_eq!(other.svn, None, "{system:?} must not inherit GPS SVN");
1696 assert!(other.usable, "{system:?} usability untouched");
1697 assert!(
1698 other.source.navcen.is_none(),
1699 "{system:?} must carry no NAVCEN provenance"
1700 );
1701 }
1702 }
1703
1704 #[test]
1705 fn merge_navcen_sorts_by_system_then_prn() {
1706 let records = [
1707 record_for(GnssSystem::Glonass, 2, 50002),
1708 record_for(GnssSystem::Gps, 5, 40005),
1709 record_for(GnssSystem::Gps, 1, 40001),
1710 ];
1711 let merged = merge_navcen(&records, &[]);
1712 let order: Vec<(GnssSystem, u16)> = merged.iter().map(|r| (r.system, r.prn)).collect();
1713 assert_eq!(
1714 order,
1715 vec![
1716 (GnssSystem::Gps, 1),
1717 (GnssSystem::Gps, 5),
1718 (GnssSystem::Glonass, 2),
1719 ]
1720 );
1721 }
1722
1723 #[test]
1724 fn lenient_builder_all_resolvable_has_empty_skipped() {
1725 let omms = [
1726 omm_named("GPS BIIF-8 (PRN 03)", 40294),
1727 omm_named("GPS BIII-1 (PRN 04)", 43873),
1728 ];
1729 let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1730 assert_eq!(catalog.records.len(), 2);
1731 assert!(catalog.skipped.is_empty());
1732 assert_eq!(
1734 catalog.records,
1735 from_celestrak_omm(GnssSystem::Gps, &omms).unwrap()
1736 );
1737 }
1738}