1use crate::astro::omm::Omm;
62use crate::astro::passes::UtcInstant;
63use crate::ephemeris::Sp3;
64use crate::id::GnssSystem;
65use core::fmt::{self, Write as _};
66
67const fn celestrak_group(system: GnssSystem) -> &'static str {
73 match system {
74 GnssSystem::Gps => "gps-ops",
75 GnssSystem::Galileo => "galileo",
76 GnssSystem::Glonass => "glo-ops",
77 GnssSystem::BeiDou => "beidou",
78 GnssSystem::Qzss => "gnss",
79 GnssSystem::Navic | GnssSystem::Sbas => "gnss",
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ConstellationError {
90 MissingPrn(Option<String>),
93 NavcenNotUtf8,
95 NavcenNoRows,
97 NavcenBadField {
100 field: &'static str,
102 value: String,
104 },
105 Sp3Validation(String),
107}
108
109impl fmt::Display for ConstellationError {
110 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
111 match self {
112 ConstellationError::MissingPrn(Some(name)) => {
113 write!(f, "CelesTrak OBJECT_NAME has no PRN: {name:?}")
114 }
115 ConstellationError::MissingPrn(None) => {
116 write!(f, "CelesTrak record has no OBJECT_NAME")
117 }
118 ConstellationError::NavcenNotUtf8 => write!(f, "NAVCEN bytes are not valid UTF-8"),
119 ConstellationError::NavcenNoRows => write!(f, "NAVCEN HTML has no GPS rows"),
120 ConstellationError::NavcenBadField { field, value } => {
121 write!(f, "NAVCEN field {field} has invalid integer {value:?}")
122 }
123 ConstellationError::Sp3Validation(msg) => {
124 write!(f, "GNSS catalog failed SP3 validation: {msg}")
125 }
126 }
127 }
128}
129
130impl std::error::Error for ConstellationError {}
131
132#[derive(Debug, Clone, Default, PartialEq, Eq)]
140pub struct RecordSource {
141 pub celestrak: Option<CelestrakSource>,
143 pub navcen: Option<NavcenSource>,
145 pub navcen_conflict: Option<NavcenSource>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct CelestrakSource {
153 pub group: String,
155 pub object_name: Option<String>,
157 pub object_id: Option<String>,
159 pub epoch: Option<String>,
161 pub block_type: Option<String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct NavcenSource {
168 pub svn: Option<u16>,
170 pub block_type: Option<String>,
172 pub plane: Option<String>,
174 pub slot: Option<String>,
176 pub clock: Option<String>,
178 pub nanu_type: Option<String>,
180 pub nanu_subject: Option<String>,
182 pub active_nanu: bool,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Record {
189 pub system: GnssSystem,
191 pub prn: u16,
193 pub svn: Option<u16>,
195 pub norad_id: u32,
197 pub sp3_id: String,
199 pub fdma_channel: Option<i8>,
204 pub active: bool,
206 pub usable: bool,
208 pub source: RecordSource,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct NavcenStatus {
215 pub system: GnssSystem,
217 pub prn: u16,
219 pub svn: Option<u16>,
221 pub usable: bool,
223 pub active_nanu: bool,
225 pub nanu_type: Option<String>,
227 pub nanu_subject: Option<String>,
229 pub plane: Option<String>,
231 pub slot: Option<String>,
233 pub block_type: Option<String>,
235 pub clock: Option<String>,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct NavcenEffectiveInterval {
245 pub start_utc: UtcInstant,
247 pub end_utc: UtcInstant,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub enum NavcenTiming {
254 NotApplicable,
256 Parsed(NavcenEffectiveInterval),
258 Unparseable,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct NavcenAssessment {
271 pub status: NavcenStatus,
273 pub evaluated_at_utc: UtcInstant,
275 pub outage_start: Option<String>,
278 pub timing: NavcenTiming,
280}
281
282#[derive(Debug, Clone, PartialEq, Eq, Default)]
284pub struct Validation {
285 pub missing_sp3_ids: Vec<String>,
287 pub duplicate_prns: Vec<(GnssSystem, u16)>,
291 pub duplicate_norad_ids: Vec<u32>,
293 pub inactive_unusable_prns: Vec<(GnssSystem, u16)>,
295 pub extra_sp3_ids: Vec<String>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
301pub struct FieldChange<T> {
302 pub system: GnssSystem,
304 pub prn: u16,
306 pub from: T,
308 pub to: T,
310}
311
312#[derive(Debug, Clone, PartialEq, Eq, Default)]
314pub struct Diff {
315 pub added: Vec<Record>,
317 pub removed: Vec<Record>,
319 pub norad_reassigned: Vec<FieldChange<u32>>,
321 pub sp3_id_changed: Vec<FieldChange<String>>,
323 pub svn_changed: Vec<FieldChange<Option<u16>>>,
325 pub fdma_channel_changed: Vec<FieldChange<Option<i8>>>,
327 pub activity_changed: Vec<FieldChange<bool>>,
329 pub usability_changed: Vec<FieldChange<bool>>,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
335pub enum BoolStyle {
336 #[default]
338 Lower,
339 Title,
341}
342
343#[must_use]
346pub fn gnss_sp3_id(system: GnssSystem, prn: u16) -> String {
347 format!("{}{prn:02}", system.letter())
348}
349
350struct Identity {
352 prn: u16,
354 fdma_channel: Option<i8>,
356}
357
358#[derive(Debug, Clone, PartialEq, Eq)]
367pub struct SkippedOmm {
368 pub object_name: Option<String>,
370 pub norad_id: u32,
372}
373
374#[derive(Debug, Clone, PartialEq, Eq, Default)]
383pub struct Catalog {
384 pub records: Vec<Record>,
386 pub skipped: Vec<SkippedOmm>,
389}
390
391pub fn from_celestrak_omm(
407 system: GnssSystem,
408 omms: &[Omm],
409) -> Result<Vec<Record>, ConstellationError> {
410 let mut records = Vec::with_capacity(omms.len());
411 for omm in omms {
412 records.push(record_from_omm(system, omm)?);
413 }
414 records.sort_by_key(|r| (r.system, r.prn));
415 Ok(records)
416}
417
418#[must_use]
430pub fn from_celestrak_omm_lenient(system: GnssSystem, omms: &[Omm]) -> Catalog {
431 let mut records = Vec::with_capacity(omms.len());
432 let mut skipped = Vec::new();
433 for omm in omms {
434 match record_from_omm(system, omm) {
435 Ok(record) => records.push(record),
436 Err(_) => skipped.push(SkippedOmm {
437 object_name: omm.object_name.clone(),
438 norad_id: omm.norad_cat_id,
439 }),
440 }
441 }
442 records.sort_by_key(|r| (r.system, r.prn));
443 Catalog { records, skipped }
444}
445
446fn record_from_omm(system: GnssSystem, omm: &Omm) -> Result<Record, ConstellationError> {
447 let object_name = omm.object_name.as_deref();
448 let identity = system_identity(system, object_name)
449 .ok_or_else(|| ConstellationError::MissingPrn(omm.object_name.clone()))?;
450
451 Ok(Record {
452 system,
453 prn: identity.prn,
454 svn: None,
455 norad_id: omm.norad_cat_id,
456 sp3_id: gnss_sp3_id(system, identity.prn),
457 fdma_channel: identity.fdma_channel,
458 active: true,
459 usable: true,
460 source: RecordSource {
461 celestrak: Some(CelestrakSource {
462 group: celestrak_group(system).to_string(),
463 object_name: omm.object_name.clone(),
464 object_id: omm.object_id.clone(),
465 epoch: Some(epoch_iso8601(omm)),
466 block_type: block_type_from_object_name(system, object_name),
467 }),
468 navcen: None,
469 navcen_conflict: None,
470 },
471 })
472}
473
474fn system_identity(system: GnssSystem, name: Option<&str>) -> Option<Identity> {
489 match system {
490 GnssSystem::Gps => prn_from_object_name(name).map(|prn| Identity {
491 prn,
492 fdma_channel: None,
493 }),
494 GnssSystem::BeiDou => paren_letter_prn(name, 'C').map(|prn| Identity {
495 prn,
496 fdma_channel: None,
497 }),
498 GnssSystem::Qzss => qzss_slot_from_object_name(name).map(|prn| Identity {
499 prn,
500 fdma_channel: None,
501 }),
502 GnssSystem::Galileo => {
503 let gsat = gsat_from_object_name(name)?;
504 galileo_prn_for_gsat(gsat).map(|prn| Identity {
505 prn,
506 fdma_channel: None,
507 })
508 }
509 GnssSystem::Glonass => {
510 let number = paren_number(name)?;
511 let slot = glonass_slot_for_number(number)?;
512 Some(Identity {
513 prn: slot,
514 fdma_channel: glonass_fdma_channel(slot),
515 })
516 }
517 GnssSystem::Navic | GnssSystem::Sbas => None,
521 }
522}
523
524fn epoch_iso8601(omm: &Omm) -> String {
525 let e = &omm.epoch;
526 format!(
527 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:06}",
528 e.year, e.month, e.day, e.hour, e.minute, e.second, e.microsecond
529 )
530}
531
532fn prn_from_object_name(name: Option<&str>) -> Option<u16> {
539 let name = name?;
540 let mut from = 0;
541 while let Some(rel) = find_ci(&name[from..], "(PRN") {
542 let after = from + rel + "(PRN".len();
543 if let Some(prn) = prn_at(&name[after..]) {
544 return Some(prn);
545 }
546 from = after;
547 }
548 None
549}
550
551fn prn_at(rest: &str) -> Option<u16> {
553 let rest = rest.trim_start();
554 let bytes = rest.as_bytes();
555
556 let mut i = 0;
557 while i < bytes.len() && bytes[i] == b'0' {
558 i += 1;
559 }
560 let digit_start = i;
561 let mut count = 0;
562 while i < bytes.len() && bytes[i].is_ascii_digit() && count < 3 {
563 i += 1;
564 count += 1;
565 }
566 if i >= bytes.len() || bytes[i] != b')' || digit_start == i {
567 return None;
568 }
569 let value: u16 = rest[digit_start..i].parse().ok()?;
570 (value > 0).then_some(value)
571}
572
573fn paren_letter_prn(name: Option<&str>, letter: char) -> Option<u16> {
580 let name = name?;
581 let needle = format!("({letter}");
582 let mut from = 0;
583 while let Some(rel) = find_ci(&name[from..], &needle) {
584 let after = from + rel + needle.len();
585 if let Some(prn) = prn_at(&name[after..]) {
586 return Some(prn);
587 }
588 from = after;
589 }
590 None
591}
592
593fn paren_number(name: Option<&str>) -> Option<u16> {
598 let name = name?;
599 let open = name.find('(')?;
600 let rest = &name[open + 1..];
601 let close = rest.find(')')?;
602 let digits = rest[..close].trim();
603 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
604 return None;
605 }
606 digits.parse().ok()
607}
608
609fn qzss_slot_from_object_name(name: Option<&str>) -> Option<u16> {
615 let name = name?;
616 let mut from = 0;
617 while let Some(rel) = find_ci(&name[from..], "PRN") {
618 let after = from + rel + "PRN".len();
619 if let Some(prn) = leading_uint(&name[after..]) {
620 if (193..=201).contains(&prn) {
621 return Some(prn - 192);
622 }
623 }
624 from = after;
625 }
626 None
627}
628
629fn gsat_from_object_name(name: Option<&str>) -> Option<u16> {
632 let name = name?;
633 let rel = find_ci(name, "GSAT")?;
634 leading_uint(&name[rel + "GSAT".len()..])
635}
636
637fn leading_uint(rest: &str) -> Option<u16> {
639 let rest = rest.trim_start();
640 let end = rest
641 .find(|c: char| !c.is_ascii_digit())
642 .unwrap_or(rest.len());
643 rest.get(..end).filter(|s| !s.is_empty())?.parse().ok()
644}
645
646#[must_use]
657pub fn galileo_prn_for_gsat(gsat: u16) -> Option<u16> {
658 let prn = match gsat {
659 101 => 11,
660 102 => 12,
661 103 => 19,
662 104 => 20,
663 201 => 18,
664 202 => 14,
665 203 => 26,
666 204 => 22,
667 205 => 24,
668 206 => 30,
669 207 => 7,
670 208 => 8,
671 209 => 9,
672 210 => 1,
673 211 => 2,
674 212 => 3,
675 213 => 4,
676 214 => 5,
677 215 => 21,
678 216 => 25,
679 217 => 27,
680 218 => 31,
681 219 => 36,
682 220 => 13,
683 221 => 15,
684 222 => 33,
685 223 => 34,
686 224 => 10,
687 225 => 29,
688 226 => 23,
689 227 => 6,
690 _ => return None,
691 };
692 Some(prn)
693}
694
695#[must_use]
706pub fn glonass_slot_for_number(number: u16) -> Option<u16> {
707 let slot = match number {
708 730 => 1,
709 747 => 2,
710 744 => 3,
711 759 => 4,
712 756 => 5,
713 704 => 6,
714 745 => 7,
715 743 => 8,
716 702 => 9,
717 723 => 10,
718 705 => 11,
719 758 => 12,
720 721 => 13,
721 752 => 14,
722 757 => 15,
723 761 => 16,
724 751 => 17,
725 754 => 18,
726 707 => 19,
727 708 => 20,
728 755 => 21,
729 706 => 22,
730 732 => 23,
731 760 => 24,
732 _ => return None,
733 };
734 Some(slot)
735}
736
737#[must_use]
749pub fn glonass_fdma_channel(slot: u16) -> Option<i8> {
750 let channel = match slot {
751 1 => 1,
752 2 => -4,
753 3 => 5,
754 4 => 6,
755 5 => 1,
756 6 => -4,
757 7 => 5,
758 8 => 6,
759 9 => -2,
760 10 => -7,
761 11 => 0,
762 12 => -1,
763 13 => -2,
764 14 => -7,
765 15 => 0,
766 16 => -1,
767 17 => 4,
768 18 => -3,
769 19 => 3,
770 20 => 2,
771 21 => 4,
772 22 => -3,
773 23 => 3,
774 24 => 2,
775 _ => return None,
776 };
777 Some(channel)
778}
779
780fn block_type_from_object_name(system: GnssSystem, name: Option<&str>) -> Option<String> {
787 let name = name?;
788 match system {
789 GnssSystem::Gps => {
790 if contains_word_ci(name, "BIIRM") || contains_word_ci(name, "BIIR-M") {
791 Some("IIR-M".to_string())
792 } else if contains_word_ci(name, "BIII") {
793 Some("III".to_string())
794 } else if contains_word_ci(name, "BIIF") {
795 Some("IIF".to_string())
796 } else if contains_word_ci(name, "BIIR") {
797 Some("IIR".to_string())
798 } else {
799 None
800 }
801 }
802 GnssSystem::BeiDou => {
803 if contains_word_ci(name, "BEIDOU-3S") {
804 Some("BDS-3S".to_string())
805 } else if contains_word_ci(name, "BEIDOU-3") {
806 Some("BDS-3".to_string())
807 } else if contains_word_ci(name, "BEIDOU-2") {
808 Some("BDS-2".to_string())
809 } else {
810 None
811 }
812 }
813 GnssSystem::Galileo => match gsat_from_object_name(Some(name)) {
814 Some(gsat) if gsat < 200 => Some("IOV".to_string()),
815 Some(_) => Some("FOC".to_string()),
816 None => None,
817 },
818 _ => None,
819 }
820}
821
822pub fn parse_navcen(bytes: &[u8]) -> Result<Vec<NavcenStatus>, ConstellationError> {
828 let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
829
830 let mut statuses = Vec::new();
831 for row in navcen_rows(html) {
832 statuses.push(navcen_status_from_row(row)?);
833 }
834
835 if statuses.is_empty() {
836 return Err(ConstellationError::NavcenNoRows);
837 }
838 statuses.sort_by_key(|s| s.prn);
839 Ok(statuses)
840}
841
842pub fn parse_navcen_at(
856 bytes: &[u8],
857 evaluated_at_utc: UtcInstant,
858) -> Result<Vec<NavcenAssessment>, ConstellationError> {
859 let html = core::str::from_utf8(bytes).map_err(|_| ConstellationError::NavcenNotUtf8)?;
860
861 let mut assessments = Vec::new();
862 for row in navcen_rows(html) {
863 assessments.push(navcen_assessment_from_row(row, evaluated_at_utc)?);
864 }
865
866 if assessments.is_empty() {
867 return Err(ConstellationError::NavcenNoRows);
868 }
869 assessments.sort_by_key(|assessment| assessment.status.prn);
870 Ok(assessments)
871}
872
873fn navcen_status_from_row(row: &str) -> Result<NavcenStatus, ConstellationError> {
874 let prn = navcen_required_int(row, "gps-prn")?;
875 let svn = navcen_optional_int(row, "gps-svn")?;
876 let nanu_type = navcen_text(row, "nanu-type");
877 let active_nanu = navcen_active(row);
878 let usable = !(active_nanu && unusable_nanu_type(nanu_type.as_deref()));
879
880 Ok(NavcenStatus {
881 system: GnssSystem::Gps,
882 prn,
883 svn,
884 usable,
885 active_nanu,
886 nanu_type: blank_to_none(nanu_type),
887 nanu_subject: blank_to_none(navcen_text(row, "nanu-subject")),
888 plane: blank_to_none(navcen_text(row, "gps-con-plane")),
889 slot: blank_to_none(navcen_text(row, "gps-con-slot")),
890 block_type: blank_to_none(navcen_text(row, "gps-con-block-type")),
891 clock: blank_to_none(navcen_text(row, "gps-con-clock")),
892 })
893}
894
895fn navcen_assessment_from_row(
896 row: &str,
897 evaluated_at_utc: UtcInstant,
898) -> Result<NavcenAssessment, ConstellationError> {
899 let mut status = navcen_status_from_row(row)?;
900 let outage_start_cells = navcen_texts(row, "nanu-outage-start-date");
901 let outage_start = blank_to_none(Some(outage_start_cells.join(" | ")));
902 let timing = if outage_start_cells.len() > 1
903 && status
904 .nanu_type
905 .as_deref()
906 .is_some_and(|text| forecast_outage_type(&text.trim().to_ascii_uppercase()))
907 {
908 NavcenTiming::Unparseable
909 } else {
910 navcen_timing(
911 status.nanu_type.as_deref(),
912 status.nanu_subject.as_deref(),
913 outage_start.as_deref(),
914 )
915 };
916 status.usable = navcen_usable_at(
917 status.active_nanu,
918 status.nanu_type.as_deref(),
919 timing,
920 evaluated_at_utc,
921 );
922
923 Ok(NavcenAssessment {
924 status,
925 evaluated_at_utc,
926 outage_start,
927 timing,
928 })
929}
930
931fn navcen_usable_at(
932 active_nanu: bool,
933 nanu_type: Option<&str>,
934 timing: NavcenTiming,
935 evaluated_at_utc: UtcInstant,
936) -> bool {
937 if !active_nanu {
938 return true;
939 }
940 let Some(nanu_type) = nanu_type else {
941 return true;
942 };
943 let upper = nanu_type.trim().to_ascii_uppercase();
944 if matches!(upper.as_str(), "UNUSABLE" | "UNUSUFN" | "DECOM") {
945 return false;
946 }
947 if !forecast_outage_type(&upper) {
948 return true;
949 }
950 match timing {
951 NavcenTiming::Parsed(interval) => {
952 !(interval.start_utc <= evaluated_at_utc && evaluated_at_utc < interval.end_utc)
953 }
954 NavcenTiming::NotApplicable | NavcenTiming::Unparseable => true,
955 }
956}
957
958fn navcen_timing(
959 nanu_type: Option<&str>,
960 subject: Option<&str>,
961 outage_start: Option<&str>,
962) -> NavcenTiming {
963 let Some(nanu_type) = nanu_type else {
964 return NavcenTiming::NotApplicable;
965 };
966 let upper = nanu_type.trim().to_ascii_uppercase();
967 if !forecast_outage_type(&upper) {
968 return NavcenTiming::NotApplicable;
969 }
970
971 parse_navcen_interval(subject, outage_start)
972 .map_or(NavcenTiming::Unparseable, NavcenTiming::Parsed)
973}
974
975fn forecast_outage_type(upper_nanu_type: &str) -> bool {
976 matches!(upper_nanu_type, "FCSTDV" | "FCSTMX" | "FCSTEXTD")
977}
978
979fn parse_navcen_interval(
980 subject: Option<&str>,
981 outage_start: Option<&str>,
982) -> Option<NavcenEffectiveInterval> {
983 let (year, outage_day_of_year) = parse_navcen_outage_date(outage_start?)?;
984 let ((start_day, start_hour, start_minute), (end_day, end_hour, end_minute)) =
985 parse_jday_interval(subject?)?;
986 if start_day != outage_day_of_year || !valid_day_of_year(year, start_day) {
987 return None;
988 }
989
990 let end_year = if end_day >= start_day {
991 year
992 } else if start_day >= 335 && end_day <= 31 {
993 year.checked_add(1)?
994 } else {
995 return None;
996 };
997 if !valid_day_of_year(end_year, end_day) {
998 return None;
999 }
1000
1001 let start_utc = utc_from_ordinal(year, start_day, start_hour, start_minute)?;
1002 let end_utc = utc_from_ordinal(end_year, end_day, end_hour, end_minute)?;
1003 (end_utc > start_utc).then_some(NavcenEffectiveInterval { start_utc, end_utc })
1004}
1005
1006fn parse_navcen_outage_date(text: &str) -> Option<(i32, u16)> {
1007 let words: Vec<&str> = text.split_ascii_whitespace().collect();
1008 let mut parsed = None;
1009 for window in words.windows(3) {
1010 let Ok(day) = window[0].parse::<u8>() else {
1011 continue;
1012 };
1013 let Some(month) = month_number(window[1]) else {
1014 continue;
1015 };
1016 if window[2].len() != 4 || !window[2].bytes().all(|byte| byte.is_ascii_digit()) {
1017 continue;
1018 }
1019 let Ok(year @ 1980..=9999) = window[2].parse::<i32>() else {
1020 continue;
1021 };
1022 if UtcInstant::from_utc(year, i32::from(month), i32::from(day), 0, 0, 0, 0).is_none() {
1023 continue;
1024 }
1025 let Some(day_of_year) = ordinal_day(year, month, day) else {
1026 continue;
1027 };
1028 if parsed.replace((year, day_of_year)).is_some() {
1029 return None;
1030 }
1031 }
1032 parsed
1033}
1034
1035fn month_number(text: &str) -> Option<u8> {
1036 match text.to_ascii_uppercase().as_str() {
1037 "JAN" => Some(1),
1038 "FEB" => Some(2),
1039 "MAR" => Some(3),
1040 "APR" => Some(4),
1041 "MAY" => Some(5),
1042 "JUN" => Some(6),
1043 "JUL" => Some(7),
1044 "AUG" => Some(8),
1045 "SEP" => Some(9),
1046 "OCT" => Some(10),
1047 "NOV" => Some(11),
1048 "DEC" => Some(12),
1049 _ => None,
1050 }
1051}
1052
1053fn ordinal_day(year: i32, month: u8, day: u8) -> Option<u16> {
1054 const MONTH_DAYS: [u16; 12] = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1055 if month == 0 || month > 12 {
1056 return None;
1057 }
1058 let mut ordinal: u16 = MONTH_DAYS[..usize::from(month - 1)].iter().sum();
1059 if month > 2 && leap_year(year) {
1060 ordinal += 1;
1061 }
1062 ordinal.checked_add(u16::from(day))
1063}
1064
1065fn leap_year(year: i32) -> bool {
1066 year.rem_euclid(4) == 0 && (year.rem_euclid(100) != 0 || year.rem_euclid(400) == 0)
1067}
1068
1069fn valid_day_of_year(year: i32, day: u16) -> bool {
1070 day >= 1 && day <= if leap_year(year) { 366 } else { 365 }
1071}
1072
1073fn utc_from_ordinal(year: i32, day: u16, hour: u8, minute: u8) -> Option<UtcInstant> {
1074 if !valid_day_of_year(year, day) || hour >= 24 || minute >= 60 {
1075 return None;
1076 }
1077 let jan_1 = UtcInstant::from_utc(year, 1, 1, 0, 0, 0, 0)?;
1078 let day_us = i64::from(day - 1).checked_mul(86_400_000_000)?;
1079 let clock_us = (i64::from(hour) * 3_600 + i64::from(minute) * 60).checked_mul(1_000_000)?;
1080 Some(UtcInstant::from_unix_microseconds(
1081 jan_1
1082 .unix_microseconds()
1083 .checked_add(day_us)?
1084 .checked_add(clock_us)?,
1085 ))
1086}
1087
1088type JdayClock = (u16, u8, u8);
1089
1090fn parse_jday_interval(text: &str) -> Option<(JdayClock, JdayClock)> {
1091 let upper = text.to_ascii_uppercase();
1092 let bytes = upper.as_bytes();
1093 let mut cursor = 0;
1094 let mut clocks = [(0, 0, 0); 2];
1095
1096 for clock in &mut clocks {
1097 let relative = upper.get(cursor..)?.find("JDAY")?;
1098 cursor = cursor.checked_add(relative + 4)?;
1099 while bytes.get(cursor).is_some_and(u8::is_ascii_whitespace) {
1100 cursor += 1;
1101 }
1102 let day = parse_ascii_digits(bytes, &mut cursor, 3)?;
1103 if bytes.get(cursor) != Some(&b'/') {
1104 return None;
1105 }
1106 cursor += 1;
1107 let hhmm = parse_ascii_digits(bytes, &mut cursor, 4)?;
1108 if bytes.get(cursor).is_some_and(u8::is_ascii_digit) {
1109 return None;
1110 }
1111 let hour = u8::try_from(hhmm / 100).ok()?;
1112 let minute = u8::try_from(hhmm % 100).ok()?;
1113 if hour >= 24 || minute >= 60 {
1114 return None;
1115 }
1116 *clock = (u16::try_from(day).ok()?, hour, minute);
1117 }
1118
1119 if upper.get(cursor..)?.contains("JDAY") {
1120 return None;
1121 }
1122
1123 Some((clocks[0], clocks[1]))
1124}
1125
1126fn parse_ascii_digits(bytes: &[u8], cursor: &mut usize, count: usize) -> Option<u32> {
1127 let end = cursor.checked_add(count)?;
1128 let digits = bytes.get(*cursor..end)?;
1129 if !digits.iter().all(u8::is_ascii_digit) || bytes.get(end).is_some_and(u8::is_ascii_digit) {
1130 return None;
1131 }
1132 *cursor = end;
1133 core::str::from_utf8(digits).ok()?.parse().ok()
1134}
1135
1136fn navcen_required_int(row: &str, field: &'static str) -> Result<u16, ConstellationError> {
1137 let text = navcen_text(row, field);
1138 parse_positive_int(text.as_deref().unwrap_or(""), field)
1139}
1140
1141fn navcen_optional_int(row: &str, field: &'static str) -> Result<Option<u16>, ConstellationError> {
1142 match navcen_text(row, field).as_deref() {
1143 None | Some("") => Ok(None),
1144 Some(text) => parse_positive_int(text, field).map(Some),
1145 }
1146}
1147
1148fn parse_positive_int(text: &str, field: &'static str) -> Result<u16, ConstellationError> {
1149 let trimmed = text.trim();
1150 match trimmed.parse::<u16>() {
1151 Ok(value) if value > 0 => Ok(value),
1152 _ => Err(ConstellationError::NavcenBadField {
1153 field,
1154 value: trimmed.to_string(),
1155 }),
1156 }
1157}
1158
1159fn navcen_text(row: &str, field: &str) -> Option<String> {
1160 navcen_texts(row, field).into_iter().next()
1161}
1162
1163fn navcen_texts(row: &str, field: &str) -> Vec<String> {
1164 let needle = format!("views-field-field-{field}");
1165 td_inners(row, &needle)
1166 .into_iter()
1167 .map(clean_html)
1168 .collect()
1169}
1170
1171fn navcen_active(row: &str) -> bool {
1172 td_inner(row, "nanu-active-check")
1173 .map(clean_html)
1174 .as_deref()
1175 == Some("1")
1176}
1177
1178fn unusable_nanu_type(nanu_type: Option<&str>) -> bool {
1179 nanu_type.is_some_and(|text| {
1180 let upper = text.trim().to_ascii_uppercase();
1181 matches!(
1182 upper.as_str(),
1183 "UNUSABLE" | "DECOM" | "FCSTDV" | "FCSTMX" | "FCSTEXTD"
1184 )
1185 })
1186}
1187
1188#[must_use]
1205pub fn merge_navcen(records: &[Record], statuses: &[NavcenStatus]) -> Vec<Record> {
1206 let mut by_key: std::collections::HashMap<(GnssSystem, u16), &NavcenStatus> =
1207 std::collections::HashMap::with_capacity(statuses.len());
1208 for status in statuses {
1209 by_key.insert((status.system, status.prn), status);
1210 }
1211
1212 let mut merged: Vec<Record> = records
1213 .iter()
1214 .map(|record| {
1215 by_key
1216 .get(&(record.system, record.prn))
1217 .map_or_else(|| record.clone(), |status| merge_status(record, status))
1218 })
1219 .collect();
1220 merged.sort_by_key(|r| (r.system, r.prn));
1221 merged
1222}
1223
1224#[must_use]
1231pub fn merge_navcen_at(records: &[Record], assessments: &[NavcenAssessment]) -> Vec<Record> {
1232 let statuses: Vec<NavcenStatus> = assessments
1233 .iter()
1234 .map(|assessment| assessment.status.clone())
1235 .collect();
1236 merge_navcen(records, &statuses)
1237}
1238
1239fn merge_status(record: &Record, status: &NavcenStatus) -> Record {
1240 let mut out = record.clone();
1241 if navcen_compatible(record, status) {
1242 out.svn = status.svn;
1243 out.usable = status.usable;
1244 out.source.navcen = Some(navcen_source(status));
1245 } else {
1246 out.source.navcen_conflict = Some(navcen_source(status));
1247 }
1248 out
1249}
1250
1251fn navcen_source(status: &NavcenStatus) -> NavcenSource {
1252 NavcenSource {
1253 svn: status.svn,
1254 block_type: status.block_type.clone(),
1255 plane: status.plane.clone(),
1256 slot: status.slot.clone(),
1257 clock: status.clock.clone(),
1258 nanu_type: status.nanu_type.clone(),
1259 nanu_subject: status.nanu_subject.clone(),
1260 active_nanu: status.active_nanu,
1261 }
1262}
1263
1264fn navcen_compatible(record: &Record, status: &NavcenStatus) -> bool {
1265 let celestrak_block = record
1266 .source
1267 .celestrak
1268 .as_ref()
1269 .and_then(|c| c.block_type.as_deref());
1270 let navcen_block = status
1271 .block_type
1272 .as_deref()
1273 .map(|b| b.trim().to_ascii_uppercase());
1274
1275 match (celestrak_block, navcen_block) {
1276 (Some(a), Some(b)) => a == b,
1277 _ => true,
1278 }
1279}
1280
1281#[must_use]
1288pub fn to_csv(records: &[Record], booleans: BoolStyle) -> String {
1289 let mut sorted: Vec<&Record> = records.iter().collect();
1290 sorted.sort_by_key(|r| (r.system, r.prn));
1291
1292 let mut out = String::from("prn,norad_cat_id,active,sp3_id\n");
1293 for record in sorted {
1294 let active = format_bool(operational(record), booleans);
1295 let _ = writeln!(
1296 out,
1297 "{},{},{},{}",
1298 record.prn, record.norad_id, active, record.sp3_id
1299 );
1300 }
1301 out
1302}
1303
1304fn format_bool(value: bool, style: BoolStyle) -> &'static str {
1305 match (style, value) {
1306 (BoolStyle::Lower, true) => "true",
1307 (BoolStyle::Lower, false) => "false",
1308 (BoolStyle::Title, true) => "True",
1309 (BoolStyle::Title, false) => "False",
1310 }
1311}
1312
1313fn operational(record: &Record) -> bool {
1314 record.active && record.usable
1315}
1316
1317#[must_use]
1322pub fn validate(records: &[Record]) -> Validation {
1323 validation(records, None)
1324}
1325
1326#[must_use]
1333pub fn validate_against_sp3(records: &[Record], sp3: &Sp3) -> Validation {
1334 let ids: Vec<String> = sp3
1335 .header
1336 .satellites
1337 .iter()
1338 .map(ToString::to_string)
1339 .collect();
1340 validation(records, Some(&ids))
1341}
1342
1343#[must_use]
1345pub fn validate_against_sp3_ids(records: &[Record], sp3_ids: &[&str]) -> Validation {
1346 let ids: Vec<String> = sp3_ids.iter().map(|id| (*id).to_string()).collect();
1347 validation(records, Some(&ids))
1348}
1349
1350fn validation(records: &[Record], sp3_ids: Option<&[String]>) -> Validation {
1351 let mut report = Validation {
1352 missing_sp3_ids: Vec::new(),
1353 duplicate_prns: duplicates(records.iter().map(|r| (r.system, r.prn))),
1354 duplicate_norad_ids: duplicates(records.iter().map(|r| r.norad_id)),
1355 inactive_unusable_prns: inactive_unusable_prns(records),
1356 extra_sp3_ids: Vec::new(),
1357 };
1358
1359 if let Some(sp3_ids) = sp3_ids {
1360 let letters: std::collections::HashSet<char> =
1365 records.iter().map(|r| r.system.letter()).collect();
1366 let catalog: Vec<String> = records
1367 .iter()
1368 .filter(|r| operational(r))
1369 .map(|r| r.sp3_id.to_ascii_uppercase())
1370 .collect();
1371 let product: Vec<String> = sp3_ids
1372 .iter()
1373 .map(|id| id.to_ascii_uppercase())
1374 .filter(|id| id.chars().next().is_some_and(|c| letters.contains(&c)))
1375 .collect();
1376
1377 report.missing_sp3_ids = set_difference(&catalog, &product);
1378 report.extra_sp3_ids = set_difference(&product, &catalog);
1379 }
1380
1381 report
1382}
1383
1384fn duplicates<T>(values: impl Iterator<Item = T>) -> Vec<T>
1385where
1386 T: Ord + Copy,
1387{
1388 let mut seen: Vec<T> = values.collect();
1389 seen.sort_unstable();
1390 let mut out = Vec::new();
1391 let mut i = 0;
1392 while i < seen.len() {
1393 let mut j = i + 1;
1394 while j < seen.len() && seen[j] == seen[i] {
1395 j += 1;
1396 }
1397 if j - i > 1 {
1398 out.push(seen[i]);
1399 }
1400 i = j;
1401 }
1402 out
1403}
1404
1405fn inactive_unusable_prns(records: &[Record]) -> Vec<(GnssSystem, u16)> {
1406 let mut prns: Vec<(GnssSystem, u16)> = records
1407 .iter()
1408 .filter(|r| !operational(r))
1409 .map(|r| (r.system, r.prn))
1410 .collect();
1411 prns.sort_unstable();
1412 prns.dedup();
1413 prns
1414}
1415
1416fn set_difference(left: &[String], right: &[String]) -> Vec<String> {
1417 let mut out: Vec<String> = left
1418 .iter()
1419 .filter(|id| !right.contains(id))
1420 .cloned()
1421 .collect();
1422 out.sort();
1423 out.dedup();
1424 out
1425}
1426
1427#[must_use]
1429pub fn is_valid(report: &Validation) -> bool {
1430 report.missing_sp3_ids.is_empty()
1431 && report.duplicate_prns.is_empty()
1432 && report.duplicate_norad_ids.is_empty()
1433 && report.inactive_unusable_prns.is_empty()
1434 && report.extra_sp3_ids.is_empty()
1435}
1436
1437pub fn validate_against_sp3_ids_strict(
1442 records: &[Record],
1443 sp3_ids: &[&str],
1444) -> Result<(), ConstellationError> {
1445 let report = validate_against_sp3_ids(records, sp3_ids);
1446 if is_valid(&report) {
1447 Ok(())
1448 } else {
1449 Err(ConstellationError::Sp3Validation(describe_findings(
1450 &report,
1451 )))
1452 }
1453}
1454
1455fn describe_findings(report: &Validation) -> String {
1456 let mut parts = Vec::new();
1457 if !report.missing_sp3_ids.is_empty() {
1458 parts.push(format!("missing_sp3_ids: {:?}", report.missing_sp3_ids));
1459 }
1460 if !report.extra_sp3_ids.is_empty() {
1461 parts.push(format!("extra_sp3_ids: {:?}", report.extra_sp3_ids));
1462 }
1463 if !report.duplicate_prns.is_empty() {
1464 parts.push(format!("duplicate_prns: {:?}", report.duplicate_prns));
1465 }
1466 if !report.duplicate_norad_ids.is_empty() {
1467 parts.push(format!(
1468 "duplicate_norad_ids: {:?}",
1469 report.duplicate_norad_ids
1470 ));
1471 }
1472 if !report.inactive_unusable_prns.is_empty() {
1473 parts.push(format!(
1474 "inactive_unusable_prns: {:?}",
1475 report.inactive_unusable_prns
1476 ));
1477 }
1478 parts.join("; ")
1479}
1480
1481#[must_use]
1487pub fn diff(previous: &[Record], current: &[Record]) -> Diff {
1488 let key = |r: &Record| (r.system, r.prn);
1489
1490 let added: Vec<Record> = current
1491 .iter()
1492 .filter(|c| !previous.iter().any(|p| key(p) == key(c)))
1493 .cloned()
1494 .collect();
1495 let removed: Vec<Record> = previous
1496 .iter()
1497 .filter(|p| !current.iter().any(|c| key(c) == key(p)))
1498 .cloned()
1499 .collect();
1500
1501 let mut added = added;
1502 let mut removed = removed;
1503 added.sort_by_key(|r| (r.system, r.prn));
1504 removed.sort_by_key(|r| (r.system, r.prn));
1505
1506 let mut common: Vec<(GnssSystem, u16)> = previous
1507 .iter()
1508 .filter_map(|p| current.iter().find(|c| key(c) == key(p)).map(|_| key(p)))
1509 .collect();
1510 common.sort_unstable();
1511
1512 let pairs: Vec<(&Record, &Record)> = common
1513 .iter()
1514 .map(|k| {
1515 let p = previous.iter().find(|r| key(r) == *k).expect("common key");
1516 let c = current.iter().find(|r| key(r) == *k).expect("common key");
1517 (p, c)
1518 })
1519 .collect();
1520
1521 Diff {
1522 added,
1523 removed,
1524 norad_reassigned: changes(&pairs, |r| r.norad_id),
1525 sp3_id_changed: changes(&pairs, |r| r.sp3_id.clone()),
1526 svn_changed: changes(&pairs, |r| r.svn),
1527 fdma_channel_changed: changes(&pairs, |r| r.fdma_channel),
1528 activity_changed: changes(&pairs, |r| r.active),
1529 usability_changed: changes(&pairs, |r| r.usable),
1530 }
1531}
1532
1533fn changes<T, F>(pairs: &[(&Record, &Record)], field: F) -> Vec<FieldChange<T>>
1534where
1535 T: PartialEq,
1536 F: Fn(&Record) -> T,
1537{
1538 pairs
1539 .iter()
1540 .filter_map(|(p, c)| {
1541 let from = field(p);
1542 let to = field(c);
1543 if from == to {
1544 None
1545 } else {
1546 Some(FieldChange {
1547 system: p.system,
1548 prn: p.prn,
1549 from,
1550 to,
1551 })
1552 }
1553 })
1554 .collect()
1555}
1556
1557#[must_use]
1559pub fn changed(diff: &Diff) -> bool {
1560 !diff.added.is_empty()
1561 || !diff.removed.is_empty()
1562 || !diff.norad_reassigned.is_empty()
1563 || !diff.sp3_id_changed.is_empty()
1564 || !diff.svn_changed.is_empty()
1565 || !diff.fdma_channel_changed.is_empty()
1566 || !diff.activity_changed.is_empty()
1567 || !diff.usability_changed.is_empty()
1568}
1569
1570fn blank_to_none(value: Option<String>) -> Option<String> {
1573 value.filter(|v| !v.is_empty())
1574}
1575
1576fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
1578 let hay = haystack.as_bytes();
1579 let need = needle.as_bytes();
1580 if need.is_empty() {
1581 return Some(0);
1582 }
1583 if hay.len() < need.len() {
1584 return None;
1585 }
1586 (0..=hay.len() - need.len()).find(|&i| {
1587 hay[i..i + need.len()]
1588 .iter()
1589 .zip(need)
1590 .all(|(a, b)| a.eq_ignore_ascii_case(b))
1591 })
1592}
1593
1594fn is_word_byte(b: u8) -> bool {
1595 b.is_ascii_alphanumeric() || b == b'_'
1596}
1597
1598fn contains_word_ci(haystack: &str, word: &str) -> bool {
1600 let hay = haystack.as_bytes();
1601 let need = word.as_bytes();
1602 let n = need.len();
1603 if n == 0 || hay.len() < n {
1604 return false;
1605 }
1606 (0..=hay.len() - n).any(|i| {
1607 let matched = hay[i..i + n]
1608 .iter()
1609 .zip(need)
1610 .all(|(a, b)| a.eq_ignore_ascii_case(b));
1611 if !matched {
1612 return false;
1613 }
1614 let left_ok = i == 0 || !is_word_byte(hay[i - 1]);
1615 let right_ok = i + n == hay.len() || !is_word_byte(hay[i + n]);
1616 left_ok && right_ok
1617 })
1618}
1619
1620fn tr_blocks(html: &str) -> Vec<&str> {
1622 let mut out = Vec::new();
1623 let mut rest = html;
1624 while let Some(start) = find_ci(rest, "<tr") {
1625 let Some(gt) = rest[start..].find('>') else {
1626 break;
1627 };
1628 let content_start = start + gt + 1;
1629 let Some(close) = find_ci(&rest[content_start..], "</tr>") else {
1630 break;
1631 };
1632 out.push(&rest[content_start..content_start + close]);
1633 rest = &rest[content_start + close + "</tr>".len()..];
1634 }
1635 out
1636}
1637
1638fn navcen_rows(html: &str) -> impl Iterator<Item = &str> {
1639 tr_blocks(html).into_iter().filter(|row| {
1640 find_ci(row, "views-field-field-gps-prn").is_some() && find_ci(row, "<td").is_some()
1641 })
1642}
1643
1644fn td_inner<'a>(row: &'a str, class_needle: &str) -> Option<&'a str> {
1646 td_inners(row, class_needle).into_iter().next()
1647}
1648
1649fn td_inners<'a>(row: &'a str, class_needle: &str) -> Vec<&'a str> {
1651 let mut out = Vec::new();
1652 let mut rest = row;
1653 while let Some(start) = find_ci(rest, "<td") {
1654 let Some(gt) = rest[start..].find('>') else {
1655 break;
1656 };
1657 let attrs = &rest[start..start + gt];
1658 let content_start = start + gt + 1;
1659 let Some(close) = find_ci(&rest[content_start..], "</td>") else {
1660 break;
1661 };
1662 let inner = &rest[content_start..content_start + close];
1663 if find_ci(attrs, class_needle).is_some() {
1664 out.push(inner);
1665 }
1666 rest = &rest[content_start + close + "</td>".len()..];
1667 }
1668 out
1669}
1670
1671fn clean_html(text: &str) -> String {
1674 let mut stripped = String::with_capacity(text.len());
1675 let mut in_tag = false;
1676 for c in text.chars() {
1677 match c {
1678 '<' => in_tag = true,
1679 '>' => in_tag = false,
1680 _ if !in_tag => stripped.push(c),
1681 _ => {}
1682 }
1683 }
1684 let unescaped = html_unescape(&stripped);
1685 unescaped.split_whitespace().collect::<Vec<_>>().join(" ")
1686}
1687
1688fn html_unescape(text: &str) -> String {
1694 let mut out = String::with_capacity(text.len());
1695 let mut rest = text;
1696 while let Some(amp) = rest.find('&') {
1697 out.push_str(&rest[..amp]);
1698 let tail = &rest[amp..];
1699 if let Some((decoded, consumed)) = decode_entity(tail) {
1700 out.push(decoded);
1701 rest = &tail[consumed..];
1702 } else {
1703 out.push('&');
1704 rest = &tail[1..];
1705 }
1706 }
1707 out.push_str(rest);
1708 out
1709}
1710
1711fn decode_entity(s: &str) -> Option<(char, usize)> {
1715 for (entity, decoded) in [
1716 ("&", '&'),
1717 ("<", '<'),
1718 (">", '>'),
1719 (""", '"'),
1720 ("'", '\''),
1721 ("'", '\''),
1722 (" ", ' '),
1723 ] {
1724 if s.starts_with(entity) {
1725 return Some((decoded, entity.len()));
1726 }
1727 }
1728
1729 let body = s.strip_prefix("&#")?;
1731 let semi = body.find(';')?;
1732 let (digits, radix) = match body.strip_prefix(['x', 'X']) {
1733 Some(hex) => (&hex[..semi - 1], 16),
1734 None => (&body[..semi], 10),
1735 };
1736 if digits.is_empty() {
1737 return None;
1738 }
1739 let code = u32::from_str_radix(digits, radix).ok()?;
1740 let decoded = char::from_u32(code)?;
1741 Some((decoded, "&#".len() + semi + 1))
1742}
1743
1744#[cfg(test)]
1745mod tests {
1746 use super::*;
1747
1748 #[test]
1749 fn prn_parses_padded_and_multi_digit() {
1750 assert_eq!(prn_from_object_name(Some("GPS BIIF-8 (PRN 03)")), Some(3));
1751 assert_eq!(prn_from_object_name(Some("GPS BIII-10 (PRN 13)")), Some(13));
1752 assert_eq!(prn_from_object_name(Some("X (PRN 003)")), Some(3));
1753 }
1754
1755 #[test]
1756 fn prn_search_skips_unparseable_earlier_occurrence() {
1757 assert_eq!(
1760 prn_from_object_name(Some("GPS (PRN X) BIIF (PRN 07)")),
1761 Some(7)
1762 );
1763 assert_eq!(prn_from_object_name(Some("GPS WITHOUT PRN")), None);
1764 assert_eq!(prn_from_object_name(Some("(PRN 000)")), None);
1765 }
1766
1767 #[test]
1768 fn html_unescape_decodes_named_and_numeric_entities() {
1769 assert_eq!(html_unescape("a & b"), "a & b");
1770 assert_eq!(html_unescape("'x'"), "'x'");
1771 assert_eq!(html_unescape(" "), "\u{a0}");
1773 assert_eq!(html_unescape(" "), "\u{a0}");
1774 assert_eq!(html_unescape("AT&T"), "AT&T");
1776 }
1777
1778 #[test]
1779 fn optional_int_treats_numeric_nbsp_cell_as_blank() {
1780 let row = r#"<td class="views-field-field-gps-svn"> </td>"#;
1783 assert_eq!(navcen_optional_int(row, "gps-svn"), Ok(None));
1784 }
1785
1786 #[test]
1787 fn beidou_prn_parses_from_parenthesized_letter_group() {
1788 assert_eq!(paren_letter_prn(Some("BEIDOU-3 M1 (C19)"), 'C'), Some(19));
1789 assert_eq!(paren_letter_prn(Some("BEIDOU-2 G8 (C01)"), 'C'), Some(1));
1790 assert_eq!(paren_letter_prn(Some("BEIDOU-3 G2 (C60)"), 'C'), Some(60));
1791 assert_eq!(paren_letter_prn(Some("NO LETTER GROUP"), 'C'), None);
1792 }
1793
1794 #[test]
1795 fn qzss_slot_is_broadcast_prn_minus_192() {
1796 assert_eq!(
1798 qzss_slot_from_object_name(Some("QZS-2 (QZSS/PRN 194)")),
1799 Some(2)
1800 );
1801 assert_eq!(
1802 qzss_slot_from_object_name(Some("QZS-3 (QZSS/PRN 199)")),
1803 Some(7)
1804 );
1805 assert_eq!(
1806 qzss_slot_from_object_name(Some("QZS-6 (QZSS/PRN 200)")),
1807 Some(8)
1808 );
1809 assert_eq!(qzss_slot_from_object_name(Some("X (PRN 122)")), None);
1811 }
1812
1813 #[test]
1814 fn galileo_gsat_parses_and_maps_to_svid() {
1815 assert_eq!(
1816 gsat_from_object_name(Some("GSAT0210 (GALILEO 13)")),
1817 Some(210)
1818 );
1819 assert_eq!(
1820 gsat_from_object_name(Some("GSAT0101 (GALILEO-PFM)")),
1821 Some(101)
1822 );
1823 assert_eq!(gsat_from_object_name(Some("COSMOS 2456 (730)")), None);
1824 assert_eq!(galileo_prn_for_gsat(210), Some(1));
1826 assert_eq!(galileo_prn_for_gsat(211), Some(2));
1827 assert_eq!(galileo_prn_for_gsat(101), Some(11));
1828 assert_eq!(galileo_prn_for_gsat(228), None);
1829 }
1830
1831 #[test]
1832 fn glonass_number_resolves_to_slot_and_channel() {
1833 assert_eq!(paren_number(Some("COSMOS 2456 (730)")), Some(730));
1834 assert_eq!(glonass_slot_for_number(730), Some(1));
1835 assert_eq!(glonass_slot_for_number(721), Some(13));
1836 assert_eq!(glonass_slot_for_number(999), None);
1837 assert_eq!(glonass_fdma_channel(1), Some(1));
1840 assert_eq!(glonass_fdma_channel(5), Some(1));
1841 assert_eq!(glonass_fdma_channel(2), Some(-4));
1842 assert_eq!(glonass_fdma_channel(6), Some(-4));
1843 assert_eq!(glonass_fdma_channel(13), Some(-2));
1844 assert_eq!(glonass_fdma_channel(0), None);
1845 assert_eq!(glonass_fdma_channel(25), None);
1846 }
1847
1848 #[test]
1849 fn gnss_sp3_id_renders_per_system_token() {
1850 assert_eq!(gnss_sp3_id(GnssSystem::Gps, 7), "G07");
1851 assert_eq!(gnss_sp3_id(GnssSystem::Galileo, 7), "E07");
1852 assert_eq!(gnss_sp3_id(GnssSystem::Glonass, 13), "R13");
1853 assert_eq!(gnss_sp3_id(GnssSystem::BeiDou, 19), "C19");
1854 assert_eq!(gnss_sp3_id(GnssSystem::Qzss, 2), "J02");
1855 }
1856
1857 fn omm_named(object_name: &str, norad_cat_id: u32) -> Omm {
1860 Omm {
1861 ccsds_omm_vers: String::new(),
1862 creation_date: None,
1863 originator: None,
1864 object_name: Some(object_name.to_string()),
1865 object_id: None,
1866 center_name: None,
1867 ref_frame: None,
1868 time_system: None,
1869 mean_element_theory: None,
1870 epoch: crate::astro::omm::OmmEpoch {
1871 year: 2026,
1872 month: 6,
1873 day: 24,
1874 hour: 0,
1875 minute: 0,
1876 second: 0,
1877 microsecond: 0,
1878 femtosecond: 0,
1879 },
1880 mean_motion: 0.0,
1881 eccentricity: 0.0,
1882 inclination_deg: 0.0,
1883 ra_of_asc_node_deg: 0.0,
1884 arg_of_pericenter_deg: 0.0,
1885 mean_anomaly_deg: 0.0,
1886 ephemeris_type: 0,
1887 classification_type: String::new(),
1888 norad_cat_id,
1889 element_set_no: 0,
1890 rev_at_epoch: 0,
1891 bstar: 0.0,
1892 mean_motion_dot: 0.0,
1893 mean_motion_ddot: 0.0,
1894 exact_sgp4_epoch: None,
1895 quantize_tle_derived_fields: true,
1896 }
1897 }
1898
1899 #[test]
1900 fn lenient_builder_returns_partial_success_with_skipped_identities() {
1901 let omms = [
1905 omm_named("GPS BIIF-8 (PRN 03)", 40294),
1906 omm_named("QZS-2 (QZSS/PRN 194)", 42738),
1907 omm_named("GPS BIII-1 (PRN 04)", 43873),
1908 omm_named("GPS WITHOUT PRN", 99999),
1909 ];
1910
1911 assert_eq!(
1913 from_celestrak_omm(GnssSystem::Gps, &omms),
1914 Err(ConstellationError::MissingPrn(Some(
1915 "QZS-2 (QZSS/PRN 194)".to_string()
1916 )))
1917 );
1918
1919 let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
1922 assert_eq!(
1923 catalog.records.iter().map(|r| r.prn).collect::<Vec<_>>(),
1924 vec![3, 4]
1925 );
1926 assert!(catalog.records.iter().all(|r| r.system == GnssSystem::Gps));
1927 assert_eq!(
1928 catalog.skipped,
1929 vec![
1930 SkippedOmm {
1931 object_name: Some("QZS-2 (QZSS/PRN 194)".to_string()),
1932 norad_id: 42738,
1933 },
1934 SkippedOmm {
1935 object_name: Some("GPS WITHOUT PRN".to_string()),
1936 norad_id: 99999,
1937 },
1938 ]
1939 );
1940 }
1941
1942 #[test]
1943 fn lenient_builder_partitions_a_realistic_combined_gnss_feed() {
1944 let feed = [
1950 omm_named("GPS BIIF-8 (PRN 03)", 40294),
1951 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), ];
1956
1957 let gps = from_celestrak_omm_lenient(GnssSystem::Gps, &feed);
1958 assert_eq!(
1959 gps.records
1960 .iter()
1961 .map(|r| r.sp3_id.as_str())
1962 .collect::<Vec<_>>(),
1963 vec!["G03"]
1964 );
1965 assert_eq!(gps.skipped.len(), 4, "the four non-GPS names are skipped");
1966
1967 let glonass = from_celestrak_omm_lenient(GnssSystem::Glonass, &feed);
1968 assert_eq!(
1969 glonass
1970 .records
1971 .iter()
1972 .map(|r| r.sp3_id.as_str())
1973 .collect::<Vec<_>>(),
1974 vec!["R01"]
1975 );
1976 assert_eq!(glonass.skipped.len(), 4);
1977
1978 for system in [
1981 GnssSystem::Gps,
1982 GnssSystem::Glonass,
1983 GnssSystem::Galileo,
1984 GnssSystem::BeiDou,
1985 GnssSystem::Qzss,
1986 ] {
1987 let cat = from_celestrak_omm_lenient(system, &feed);
1988 assert_eq!(cat.records.len(), 1, "{system:?}: one record");
1989 assert_eq!(cat.skipped.len(), 4, "{system:?}: four skipped");
1990 assert!(cat.records.iter().all(|r| r.system == system));
1991 }
1992 }
1993
1994 fn record_for(system: GnssSystem, prn: u16, norad_id: u32) -> Record {
1997 Record {
1998 system,
1999 prn,
2000 svn: None,
2001 norad_id,
2002 sp3_id: gnss_sp3_id(system, prn),
2003 fdma_channel: None,
2004 active: true,
2005 usable: true,
2006 source: RecordSource::default(),
2007 }
2008 }
2009
2010 fn navcen_gps(prn: u16, svn: u16, usable: bool) -> NavcenStatus {
2011 NavcenStatus {
2012 system: GnssSystem::Gps,
2013 prn,
2014 svn: Some(svn),
2015 usable,
2016 active_nanu: !usable,
2017 nanu_type: None,
2018 nanu_subject: None,
2019 plane: None,
2020 slot: None,
2021 block_type: None,
2022 clock: None,
2023 }
2024 }
2025
2026 #[test]
2027 fn merge_navcen_does_not_cross_systems() {
2028 let records = [
2032 record_for(GnssSystem::Gps, 1, 40000),
2033 record_for(GnssSystem::Glonass, 1, 50000),
2034 record_for(GnssSystem::Qzss, 1, 60000),
2035 ];
2036 let statuses = [navcen_gps(1, 63, false)];
2037
2038 let merged = merge_navcen(&records, &statuses);
2039
2040 let gps = merged.iter().find(|r| r.system == GnssSystem::Gps).unwrap();
2041 assert_eq!(gps.svn, Some(63), "GPS record gets the NAVCEN SVN");
2042 assert!(!gps.usable, "GPS usability follows NAVCEN");
2043 assert!(gps.source.navcen.is_some());
2044
2045 for system in [GnssSystem::Glonass, GnssSystem::Qzss] {
2046 let other = merged.iter().find(|r| r.system == system).unwrap();
2047 assert_eq!(other.svn, None, "{system:?} must not inherit GPS SVN");
2048 assert!(other.usable, "{system:?} usability untouched");
2049 assert!(
2050 other.source.navcen.is_none(),
2051 "{system:?} must carry no NAVCEN provenance"
2052 );
2053 }
2054 }
2055
2056 #[test]
2057 fn merge_navcen_sorts_by_system_then_prn() {
2058 let records = [
2059 record_for(GnssSystem::Glonass, 2, 50002),
2060 record_for(GnssSystem::Gps, 5, 40005),
2061 record_for(GnssSystem::Gps, 1, 40001),
2062 ];
2063 let merged = merge_navcen(&records, &[]);
2064 let order: Vec<(GnssSystem, u16)> = merged.iter().map(|r| (r.system, r.prn)).collect();
2065 assert_eq!(
2066 order,
2067 vec![
2068 (GnssSystem::Gps, 1),
2069 (GnssSystem::Gps, 5),
2070 (GnssSystem::Glonass, 2),
2071 ]
2072 );
2073 }
2074
2075 #[test]
2076 fn navcen_interval_parser_accepts_valid_same_year_rollover_and_leap_intervals() {
2077 assert_eq!(
2078 parse_navcen_interval(
2079 Some("OUTAGE JDAY 205/0115 - JDAY 205/1315"),
2080 Some("24 JUL 2026")
2081 ),
2082 Some(NavcenEffectiveInterval {
2083 start_utc: UtcInstant::from_utc(2026, 7, 24, 1, 15, 0, 0).unwrap(),
2084 end_utc: UtcInstant::from_utc(2026, 7, 24, 13, 15, 0, 0).unwrap(),
2085 })
2086 );
2087 assert_eq!(
2088 parse_navcen_interval(
2089 Some("OUTAGE JDAY 365/2300 - JDAY 001/0100"),
2090 Some("31 DEC 2026")
2091 ),
2092 Some(NavcenEffectiveInterval {
2093 start_utc: UtcInstant::from_utc(2026, 12, 31, 23, 0, 0, 0).unwrap(),
2094 end_utc: UtcInstant::from_utc(2027, 1, 1, 1, 0, 0, 0).unwrap(),
2095 })
2096 );
2097 assert_eq!(
2098 parse_navcen_interval(
2099 Some("OUTAGE JDAY 060/0000 - JDAY 060/0100"),
2100 Some("29 FEB 2028")
2101 ),
2102 Some(NavcenEffectiveInterval {
2103 start_utc: UtcInstant::from_utc(2028, 2, 29, 0, 0, 0, 0).unwrap(),
2104 end_utc: UtcInstant::from_utc(2028, 2, 29, 1, 0, 0, 0).unwrap(),
2105 })
2106 );
2107 assert_eq!(
2108 parse_navcen_interval(
2109 Some("OUTAGE JDAY 365/2300 - JDAY 366/0100"),
2110 Some("30 DEC 2028")
2111 ),
2112 Some(NavcenEffectiveInterval {
2113 start_utc: UtcInstant::from_utc(2028, 12, 30, 23, 0, 0, 0).unwrap(),
2114 end_utc: UtcInstant::from_utc(2028, 12, 31, 1, 0, 0, 0).unwrap(),
2115 })
2116 );
2117 }
2118
2119 #[test]
2120 fn navcen_interval_parser_rejects_ambiguous_or_invalid_inputs() {
2121 assert_eq!(
2122 parse_navcen_outage_date("Outage Start 24 JUL 2026 UTC"),
2123 Some((2026, 205))
2124 );
2125 assert_eq!(
2126 parse_navcen_outage_date("24 JUL 2026 revised 25 JUL 2026"),
2127 None
2128 );
2129 assert_eq!(parse_navcen_outage_date("24 JUL 26"), None);
2130 assert_eq!(parse_navcen_outage_date("24 JUL 1979"), None);
2131 assert_eq!(
2132 parse_jday_interval("JDAY 205/0115 - JDAY 205/1315 - JDAY 205/1415"),
2133 None
2134 );
2135 assert_eq!(parse_jday_interval("JDAY 205/01150 - JDAY 205/1315"), None);
2136 assert_eq!(
2137 parse_navcen_interval(
2138 Some("OUTAGE JDAY 204/0115 - JDAY 205/1315"),
2139 Some("24 JUL 2026")
2140 ),
2141 None,
2142 "first JDAY must agree with Outage Start"
2143 );
2144 assert_eq!(
2145 parse_navcen_interval(
2146 Some("OUTAGE JDAY 205/1315 - JDAY 205/0115"),
2147 Some("24 JUL 2026")
2148 ),
2149 None,
2150 "reversed same-day interval"
2151 );
2152 assert_eq!(
2153 parse_navcen_interval(
2154 Some("OUTAGE JDAY 205/0115 - JDAY 205/0115"),
2155 Some("24 JUL 2026")
2156 ),
2157 None,
2158 "zero-length interval"
2159 );
2160 assert_eq!(
2161 parse_navcen_interval(
2162 Some("OUTAGE JDAY 205/2400 - JDAY 205/2500"),
2163 Some("24 JUL 2026")
2164 ),
2165 None,
2166 "invalid clock"
2167 );
2168 assert_eq!(
2169 parse_navcen_interval(
2170 Some("OUTAGE JDAY 205/0100 - JDAY 001/0200"),
2171 Some("24 JUL 2026")
2172 ),
2173 None,
2174 "only a December-to-January rollover is accepted"
2175 );
2176 assert_eq!(
2177 parse_navcen_interval(
2178 Some("OUTAGE JDAY 365/0100 - JDAY 366/0200"),
2179 Some("31 DEC 2026")
2180 ),
2181 None,
2182 "JDAY 366 is invalid in a non-leap year"
2183 );
2184 }
2185
2186 #[test]
2187 fn navcen_assessment_rejects_duplicate_outage_start_cells() {
2188 let row = r#"
2189 <td class="views-field-field-gps-prn">7</td>
2190 <td class="views-field-field-nanu-outage-start-date">24 JUL 2026</td>
2191 <td class="views-field-field-nanu-outage-start-date">25 JUL 2026</td>
2192 <td class="views-field-field-nanu-type">FCSTDV</td>
2193 <td class="views-field-field-nanu-subject">
2194 JDAY 205/0115 - JDAY 205/1315
2195 </td>
2196 <td class="nanu-active-check">1</td>
2197 "#;
2198 let assessment =
2199 navcen_assessment_from_row(row, UtcInstant::from_utc(2026, 7, 24, 2, 0, 0, 0).unwrap())
2200 .unwrap();
2201
2202 assert_eq!(assessment.timing, NavcenTiming::Unparseable);
2203 assert_eq!(
2204 assessment.outage_start.as_deref(),
2205 Some("24 JUL 2026 | 25 JUL 2026")
2206 );
2207 assert!(assessment.status.usable);
2208 }
2209
2210 #[test]
2211 fn navcen_assessment_applies_bounded_fcstextd_timing() {
2212 let row = r#"
2213 <td class="views-field-field-gps-prn">9</td>
2214 <td class="views-field-field-nanu-outage-start-date">24 JUL 2026</td>
2215 <td class="views-field-field-nanu-type">FCSTEXTD</td>
2216 <td class="views-field-field-nanu-subject">
2217 JDAY 205/0115 - JDAY 205/1315
2218 </td>
2219 <td class="nanu-active-check">1</td>
2220 "#;
2221 let assessment =
2222 navcen_assessment_from_row(row, UtcInstant::from_utc(2026, 7, 24, 2, 0, 0, 0).unwrap())
2223 .unwrap();
2224
2225 assert!(matches!(assessment.timing, NavcenTiming::Parsed(_)));
2226 assert!(!assessment.status.usable);
2227 }
2228
2229 #[test]
2230 fn only_legacy_outage_forecast_types_require_bounded_timing() {
2231 for nanu_type in ["FCSTDV", "FCSTMX", "FCSTEXTD"] {
2232 assert!(forecast_outage_type(nanu_type), "{nanu_type}");
2233 }
2234 for nanu_type in [
2235 "FCSTSUMM",
2236 "FCSTCANC",
2237 "FCSTUUFN",
2238 "FCSTRESCD",
2239 "UNUSABLE",
2240 "DECOM",
2241 ] {
2242 assert!(!forecast_outage_type(nanu_type), "{nanu_type}");
2243 }
2244 }
2245
2246 #[test]
2247 fn lenient_builder_all_resolvable_has_empty_skipped() {
2248 let omms = [
2249 omm_named("GPS BIIF-8 (PRN 03)", 40294),
2250 omm_named("GPS BIII-1 (PRN 04)", 43873),
2251 ];
2252 let catalog = from_celestrak_omm_lenient(GnssSystem::Gps, &omms);
2253 assert_eq!(catalog.records.len(), 2);
2254 assert!(catalog.skipped.is_empty());
2255 assert_eq!(
2257 catalog.records,
2258 from_celestrak_omm(GnssSystem::Gps, &omms).unwrap()
2259 );
2260 }
2261}