1use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11use std::hash::{DefaultHasher, Hash, Hasher};
12
13use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
14use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
15use crate::astro::time::model::TimeScale;
16use crate::astro::time::scales::julian_day_number;
17use crate::terrain;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
21pub enum AnalysisCenter {
22 Igs,
24 CodRap,
26 CodPrd1,
28 CodPrd2,
30 Esa,
32 Cod,
34 Gfz,
36 IgsUlt,
38 CodUlt,
40 EsaUlt,
42 GfzUlt,
44 WumNrt,
46}
47
48impl AnalysisCenter {
49 #[must_use]
51 pub const fn code(self) -> &'static str {
52 match self {
53 Self::Igs => "igs",
54 Self::CodRap => "cod_rap",
55 Self::CodPrd1 => "cod_prd1",
56 Self::CodPrd2 => "cod_prd2",
57 Self::Esa => "esa",
58 Self::Cod => "cod",
59 Self::Gfz => "gfz",
60 Self::IgsUlt => "igs_ult",
61 Self::CodUlt => "cod_ult",
62 Self::EsaUlt => "esa_ult",
63 Self::GfzUlt => "gfz_ult",
64 Self::WumNrt => "wum_nrt",
65 }
66 }
67
68 #[must_use]
70 pub fn from_code(code: &str) -> Option<Self> {
71 match code {
72 "igs" => Some(Self::Igs),
73 "cod_rap" => Some(Self::CodRap),
74 "cod_prd1" => Some(Self::CodPrd1),
75 "cod_prd2" => Some(Self::CodPrd2),
76 "esa" => Some(Self::Esa),
77 "cod" => Some(Self::Cod),
78 "gfz" => Some(Self::Gfz),
79 "igs_ult" => Some(Self::IgsUlt),
80 "cod_ult" => Some(Self::CodUlt),
81 "esa_ult" => Some(Self::EsaUlt),
82 "gfz_ult" => Some(Self::GfzUlt),
83 "wum_nrt" => Some(Self::WumNrt),
84 _ => None,
85 }
86 }
87
88 #[must_use]
90 pub const fn publisher(self) -> ProductPublisher {
91 match self {
92 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
93 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
94 ProductPublisher::Code
95 }
96 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
97 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
98 Self::WumNrt => ProductPublisher::Whu,
99 }
100 }
101
102 #[must_use]
108 pub const fn solution_class(self) -> SolutionClass {
109 match self {
110 Self::Igs => SolutionClass::Broadcast,
111 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
112 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
113 Self::Esa | Self::Cod => SolutionClass::Final,
114 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
115 Self::WumNrt => SolutionClass::NearRealTime,
116 }
117 }
118
119 #[must_use]
121 pub const fn prediction_horizon_days(self) -> Option<u8> {
122 match self {
123 Self::CodPrd1 => Some(1),
124 Self::CodPrd2 => Some(2),
125 _ => None,
126 }
127 }
128}
129
130impl fmt::Display for AnalysisCenter {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(self.code())
133 }
134}
135
136impl FromStr for AnalysisCenter {
137 type Err = DataCatalogError;
138
139 fn from_str(s: &str) -> Result<Self, Self::Err> {
140 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
146pub enum ProductType {
147 Sp3,
149 Clk,
151 Nav,
153 Ionex,
155}
156
157impl ProductType {
158 #[must_use]
160 pub const fn code(self) -> &'static str {
161 match self {
162 Self::Sp3 => "sp3",
163 Self::Clk => "clk",
164 Self::Nav => "nav",
165 Self::Ionex => "ionex",
166 }
167 }
168
169 #[must_use]
171 pub fn from_code(code: &str) -> Option<Self> {
172 match code {
173 "sp3" => Some(Self::Sp3),
174 "clk" => Some(Self::Clk),
175 "nav" => Some(Self::Nav),
176 "ionex" => Some(Self::Ionex),
177 _ => None,
178 }
179 }
180}
181
182impl fmt::Display for ProductType {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 f.write_str(self.code())
185 }
186}
187
188impl FromStr for ProductType {
189 type Err = DataCatalogError;
190
191 fn from_str(s: &str) -> Result<Self, Self::Err> {
192 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
202pub enum ProductPublisher {
203 Igs,
205 Code,
207 Esa,
209 Gfz,
211 Whu,
213}
214
215impl ProductPublisher {
216 #[must_use]
218 pub const fn code(self) -> &'static str {
219 match self {
220 Self::Igs => "IGS",
221 Self::Code => "COD",
222 Self::Esa => "ESA",
223 Self::Gfz => "GFZ",
224 Self::Whu => "WUM",
225 }
226 }
227}
228
229impl fmt::Display for ProductPublisher {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 f.write_str(self.code())
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
237pub enum SolutionClass {
238 Final,
240 Rapid,
242 UltraRapid,
244 Predicted,
246 NearRealTime,
248 Broadcast,
250}
251
252impl SolutionClass {
253 #[must_use]
255 pub const fn code(self) -> &'static str {
256 match self {
257 Self::Final => "final",
258 Self::Rapid => "rapid",
259 Self::UltraRapid => "ultra_rapid",
260 Self::Predicted => "predicted",
261 Self::NearRealTime => "near_real_time",
262 Self::Broadcast => "broadcast",
263 }
264 }
265
266 #[must_use]
268 pub const fn filename_token(self) -> Option<&'static str> {
269 match self {
270 Self::Final => Some("FIN"),
271 Self::Rapid => Some("RAP"),
272 Self::UltraRapid => Some("ULT"),
273 Self::Predicted => Some("PRD"),
274 Self::NearRealTime => Some("NRT"),
275 Self::Broadcast => None,
276 }
277 }
278}
279
280impl fmt::Display for SolutionClass {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 f.write_str(self.code())
283 }
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
288pub enum ProductCampaign {
289 Operational,
291 MultiGnss,
293 MultiGnssExperiment,
295 Broadcast,
297}
298
299impl ProductCampaign {
300 #[must_use]
302 pub const fn code(self) -> &'static str {
303 match self {
304 Self::Operational => "OPS",
305 Self::MultiGnss => "MGN",
306 Self::MultiGnssExperiment => "MGX",
307 Self::Broadcast => "BRD",
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
314pub enum ProductFormat {
315 Sp3,
317 Ionex,
319 RinexClock,
321 RinexNavigation,
323}
324
325impl ProductFormat {
326 #[must_use]
328 pub const fn code(self) -> &'static str {
329 match self {
330 Self::Sp3 => "SP3",
331 Self::Ionex => "IONEX",
332 Self::RinexClock => "RINEX_CLK",
333 Self::RinexNavigation => "RINEX_NAV",
334 }
335 }
336}
337
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
343pub enum DistributionSource {
344 Direct,
346 NasaCddis,
348 LocalFile,
350 InMemory,
352}
353
354impl DistributionSource {
355 #[must_use]
357 pub const fn code(self) -> &'static str {
358 match self {
359 Self::Direct => "direct",
360 Self::NasaCddis => "nasa_cddis",
361 Self::LocalFile => "local_file",
362 Self::InMemory => "in_memory",
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
369pub enum SpaceWeatherProduct {
370 All,
372 Last5Years,
374}
375
376impl SpaceWeatherProduct {
377 #[must_use]
379 pub const fn code(self) -> &'static str {
380 match self {
381 Self::All => "sw_all",
382 Self::Last5Years => "sw_last5",
383 }
384 }
385
386 #[must_use]
388 pub fn from_code(code: &str) -> Option<Self> {
389 match code {
390 "sw_all" => Some(Self::All),
391 "sw_last5" => Some(Self::Last5Years),
392 _ => None,
393 }
394 }
395}
396
397impl fmt::Display for SpaceWeatherProduct {
398 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399 f.write_str(self.code())
400 }
401}
402
403impl FromStr for SpaceWeatherProduct {
404 type Err = DataCatalogError;
405
406 fn from_str(s: &str) -> Result<Self, Self::Err> {
407 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
408 }
409}
410
411#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
413pub enum ArchiveProtocol {
414 Http,
416 Https,
418 Ftp,
427}
428
429impl ArchiveProtocol {
430 #[must_use]
432 pub const fn as_str(self) -> &'static str {
433 match self {
434 Self::Http => "http",
435 Self::Https => "https",
436 Self::Ftp => "ftp",
437 }
438 }
439}
440
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
443pub enum ArchiveCompression {
444 Gzip,
446 UnixCompress,
448 None,
450}
451
452impl ArchiveCompression {
453 #[must_use]
455 pub const fn as_str(self) -> &'static str {
456 match self {
457 Self::Gzip => "gzip",
458 Self::UnixCompress => "unix_compress",
459 Self::None => "none",
460 }
461 }
462
463 const fn suffix(self) -> &'static str {
464 match self {
465 Self::Gzip => ".gz",
466 Self::UnixCompress => ".Z",
467 Self::None => "",
468 }
469 }
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
474pub enum ArchiveLayout {
475 GfzRapidWeek,
477 GfzUltraWeek,
479 GpsWeek,
481 BkgProductsWeek,
483 BkgBrdcYearDoy,
485 BkgObsYearDoy,
487 AiubCodeMgexYear,
489 AiubCodeYear,
491 AiubCodeRoot,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum ProductFilenameKind {
498 Sampled,
500 Nav,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq)]
506pub struct ProductTypeConvention {
507 pub product_type: ProductType,
509 pub content_code: &'static str,
511 pub extension: &'static str,
513 pub kind: ProductFilenameKind,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
519pub struct CenterProductConvention {
520 pub product_type: ProductType,
522 pub token: &'static str,
524 pub layout: ArchiveLayout,
526 pub span: &'static str,
528 pub default_sample: &'static str,
530 pub compression: ArchiveCompression,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub struct CenterCatalogEntry {
537 pub center: AnalysisCenter,
539 pub code: &'static str,
541 pub protocol: ArchiveProtocol,
543 pub host: &'static str,
545 pub root_url: &'static str,
547 pub products: &'static [CenterProductConvention],
549 pub issues: &'static [&'static str],
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub struct TerrainSourceEntry {
556 pub protocol: ArchiveProtocol,
558 pub host: &'static str,
560 pub compression: ArchiveCompression,
562 pub root_url: &'static str,
564}
565
566#[derive(Debug, Clone, Copy, PartialEq, Eq)]
568pub struct SpaceWeatherSourceEntry {
569 pub protocol: ArchiveProtocol,
571 pub host: &'static str,
573 pub compression: ArchiveCompression,
575 pub root_url: &'static str,
577}
578
579#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
581pub struct NoOpenMirrorProduct {
582 pub center: &'static str,
584 pub product_type: &'static str,
586}
587
588const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
589 ProductTypeConvention {
590 product_type: ProductType::Sp3,
591 content_code: "ORB",
592 extension: "SP3",
593 kind: ProductFilenameKind::Sampled,
594 },
595 ProductTypeConvention {
596 product_type: ProductType::Clk,
597 content_code: "CLK",
598 extension: "CLK",
599 kind: ProductFilenameKind::Sampled,
600 },
601 ProductTypeConvention {
602 product_type: ProductType::Nav,
603 content_code: "MN",
604 extension: "rnx",
605 kind: ProductFilenameKind::Nav,
606 },
607 ProductTypeConvention {
608 product_type: ProductType::Ionex,
609 content_code: "GIM",
610 extension: "INX",
611 kind: ProductFilenameKind::Sampled,
612 },
613];
614
615const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
616 product_type: ProductType::Ionex,
617 token: "COD0OPSRAP",
618 layout: ArchiveLayout::AiubCodeRoot,
619 span: "01D",
620 default_sample: "01H",
621 compression: ArchiveCompression::Gzip,
622}];
623
624const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
625 product_type: ProductType::Ionex,
626 token: "COD0OPSPRD",
627 layout: ArchiveLayout::AiubCodeRoot,
628 span: "01D",
629 default_sample: "01H",
630 compression: ArchiveCompression::Gzip,
631}];
632
633const WUM_NRT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
644 product_type: ProductType::Sp3,
645 token: "WUM0MGXNRT",
646 layout: ArchiveLayout::GpsWeek,
647 span: "02D",
648 default_sample: "05M",
649 compression: ArchiveCompression::Gzip,
650}];
651
652const WUM_NRT_ISSUES: [&str; 24] = [
654 "0000", "0100", "0200", "0300", "0400", "0500", "0600", "0700", "0800", "0900", "1000", "1100",
655 "1200", "1300", "1400", "1500", "1600", "1700", "1800", "1900", "2000", "2100", "2200", "2300",
656];
657
658const ESA_PRODUCTS: [CenterProductConvention; 3] = [
659 CenterProductConvention {
660 product_type: ProductType::Sp3,
661 token: "ESA0MGNFIN",
662 layout: ArchiveLayout::GpsWeek,
663 span: "01D",
664 default_sample: "05M",
665 compression: ArchiveCompression::Gzip,
666 },
667 CenterProductConvention {
668 product_type: ProductType::Clk,
669 token: "ESA0MGNFIN",
670 layout: ArchiveLayout::GpsWeek,
671 span: "01D",
672 default_sample: "30S",
673 compression: ArchiveCompression::Gzip,
674 },
675 CenterProductConvention {
676 product_type: ProductType::Ionex,
677 token: "ESA0OPSFIN",
678 layout: ArchiveLayout::GpsWeek,
679 span: "01D",
680 default_sample: "02H",
681 compression: ArchiveCompression::Gzip,
682 },
683];
684
685const COD_PRODUCTS: [CenterProductConvention; 3] = [
686 CenterProductConvention {
687 product_type: ProductType::Sp3,
688 token: "COD0MGXFIN",
689 layout: ArchiveLayout::AiubCodeMgexYear,
690 span: "01D",
691 default_sample: "05M",
692 compression: ArchiveCompression::Gzip,
693 },
694 CenterProductConvention {
695 product_type: ProductType::Clk,
696 token: "COD0MGXFIN",
697 layout: ArchiveLayout::AiubCodeMgexYear,
698 span: "01D",
699 default_sample: "30S",
700 compression: ArchiveCompression::Gzip,
701 },
702 CenterProductConvention {
703 product_type: ProductType::Ionex,
704 token: "COD0OPSFIN",
705 layout: ArchiveLayout::AiubCodeYear,
706 span: "01D",
707 default_sample: "01H",
708 compression: ArchiveCompression::Gzip,
709 },
710];
711
712const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
713 CenterProductConvention {
714 product_type: ProductType::Sp3,
715 token: "GFZ0OPSRAP",
716 layout: ArchiveLayout::GfzRapidWeek,
717 span: "01D",
718 default_sample: "05M",
719 compression: ArchiveCompression::Gzip,
720 },
721 CenterProductConvention {
722 product_type: ProductType::Clk,
723 token: "GFZ0OPSRAP",
724 layout: ArchiveLayout::GfzRapidWeek,
725 span: "01D",
726 default_sample: "30S",
727 compression: ArchiveCompression::Gzip,
728 },
729];
730
731const IGS_PRODUCTS: [CenterProductConvention; 2] = [
732 CenterProductConvention {
733 product_type: ProductType::Sp3,
734 token: "IGS0OPSFIN",
735 layout: ArchiveLayout::BkgProductsWeek,
736 span: "01D",
737 default_sample: "15M",
738 compression: ArchiveCompression::Gzip,
739 },
740 CenterProductConvention {
741 product_type: ProductType::Nav,
742 token: "BRDC00WRD",
743 layout: ArchiveLayout::BkgBrdcYearDoy,
744 span: "01D",
745 default_sample: "01D",
746 compression: ArchiveCompression::Gzip,
747 },
748];
749
750const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
751 product_type: ProductType::Sp3,
752 token: "IGS0OPSULT",
753 layout: ArchiveLayout::BkgProductsWeek,
754 span: "02D",
755 default_sample: "15M",
756 compression: ArchiveCompression::Gzip,
757}];
758
759const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
760 product_type: ProductType::Sp3,
761 token: "COD0OPSULT",
762 layout: ArchiveLayout::AiubCodeRoot,
763 span: "01D",
764 default_sample: "05M",
765 compression: ArchiveCompression::None,
766}];
767
768const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
769 product_type: ProductType::Sp3,
770 token: "ESA0OPSULT",
771 layout: ArchiveLayout::GpsWeek,
772 span: "02D",
773 default_sample: "05M",
774 compression: ArchiveCompression::Gzip,
775}];
776
777const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
778 product_type: ProductType::Sp3,
779 token: "GFZ0OPSULT",
780 layout: ArchiveLayout::GfzUltraWeek,
781 span: "02D",
782 default_sample: "05M",
783 compression: ArchiveCompression::Gzip,
784}];
785
786const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
787const COD_ULT_ISSUES: [&str; 1] = ["0000"];
788const GFZ_ULT_ISSUES: [&str; 8] = [
789 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
790];
791
792const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
796
797const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
803
804const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
807
808const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
813 year: 2021,
814 month: 5,
815 day: 18,
816};
817
818const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
820 year: 2014,
821 month: 1,
822 day: 5,
823};
824
825const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
827 year: 2020,
828 month: 5,
829 day: 13,
830};
831
832const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
834 year: 2022,
835 month: 10,
836 day: 4,
837};
838
839const WUM_NRT_SP3_START_DATE: ProductDate = ProductDate {
847 year: 2024,
848 month: 7,
849 day: 3,
850};
851
852const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
854 year: 2025,
855 month: 2,
856 day: 2,
857};
858const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
859
860const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
862 year: 2020,
863 month: 10,
864 day: 6,
865};
866
867const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
869 year: 2021,
870 month: 5,
871 day: 16,
872};
873
874const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
880 year: 2021,
881 month: 5,
882 day: 15,
883};
884
885const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
892 year: 2022,
893 month: 9,
894 day: 7,
895};
896const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
897 year: 2022,
898 month: 9,
899 day: 8,
900};
901
902#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
909#[non_exhaustive]
910pub enum Sp3ContentStartConvention {
911 FilenameEpoch,
913 FilenameEpochMinusOneDay,
915}
916
917impl Sp3ContentStartConvention {
918 #[must_use]
920 pub const fn code(self) -> &'static str {
921 match self {
922 Self::FilenameEpoch => "filename_epoch",
923 Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
924 }
925 }
926
927 #[must_use]
930 pub const fn content_start_offset_s(self) -> i64 {
931 match self {
932 Self::FilenameEpoch => 0,
933 Self::FilenameEpochMinusOneDay => -86_400,
934 }
935 }
936}
937
938const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
945 (
946 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
947 "0000",
948 Sp3ContentStartConvention::FilenameEpoch,
949 ),
950 (
951 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
952 "0300",
953 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
954 ),
955 (
956 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
957 "0600",
958 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
959 ),
960 (
961 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
962 "0900",
963 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
964 ),
965 (
966 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
967 "1200",
968 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
969 ),
970 (
971 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
972 "1500",
973 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
974 ),
975 (
976 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
977 "1800",
978 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
979 ),
980 (
981 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
982 "2100",
983 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
984 ),
985 (
986 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
987 "0000",
988 Sp3ContentStartConvention::FilenameEpoch,
989 ),
990 (
991 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
992 "0300",
993 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
994 ),
995 (
996 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
997 "0600",
998 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
999 ),
1000 (
1001 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1002 "0900",
1003 Sp3ContentStartConvention::FilenameEpoch,
1004 ),
1005 (
1006 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1007 "1200",
1008 Sp3ContentStartConvention::FilenameEpoch,
1009 ),
1010 (
1011 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1012 "1500",
1013 Sp3ContentStartConvention::FilenameEpoch,
1014 ),
1015 (
1016 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1017 "1800",
1018 Sp3ContentStartConvention::FilenameEpoch,
1019 ),
1020 (
1021 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1022 "2100",
1023 Sp3ContentStartConvention::FilenameEpoch,
1024 ),
1025];
1026
1027const CENTER_ORDER: [AnalysisCenter; 12] = [
1028 AnalysisCenter::CodRap,
1029 AnalysisCenter::CodPrd1,
1030 AnalysisCenter::CodPrd2,
1031 AnalysisCenter::Igs,
1032 AnalysisCenter::Esa,
1033 AnalysisCenter::Cod,
1034 AnalysisCenter::Gfz,
1035 AnalysisCenter::IgsUlt,
1036 AnalysisCenter::CodUlt,
1037 AnalysisCenter::EsaUlt,
1038 AnalysisCenter::GfzUlt,
1039 AnalysisCenter::WumNrt,
1040];
1041
1042const CATALOG: [CenterCatalogEntry; 12] = [
1043 CenterCatalogEntry {
1044 center: AnalysisCenter::CodRap,
1045 code: "cod_rap",
1046 protocol: ArchiveProtocol::Https,
1047 host: "www.aiub.unibe.ch",
1048 root_url: "https://www.aiub.unibe.ch/download",
1049 products: &COD_RAP_PRODUCTS,
1050 issues: &[],
1051 },
1052 CenterCatalogEntry {
1053 center: AnalysisCenter::CodPrd1,
1054 code: "cod_prd1",
1055 protocol: ArchiveProtocol::Https,
1056 host: "www.aiub.unibe.ch",
1057 root_url: "https://www.aiub.unibe.ch/download",
1058 products: &COD_PRD_PRODUCTS,
1059 issues: &[],
1060 },
1061 CenterCatalogEntry {
1062 center: AnalysisCenter::CodPrd2,
1063 code: "cod_prd2",
1064 protocol: ArchiveProtocol::Https,
1065 host: "www.aiub.unibe.ch",
1066 root_url: "https://www.aiub.unibe.ch/download",
1067 products: &COD_PRD_PRODUCTS,
1068 issues: &[],
1069 },
1070 CenterCatalogEntry {
1071 center: AnalysisCenter::Igs,
1072 code: "igs",
1073 protocol: ArchiveProtocol::Https,
1074 host: "igs.bkg.bund.de",
1075 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1076 products: &IGS_PRODUCTS,
1077 issues: &[],
1078 },
1079 CenterCatalogEntry {
1080 center: AnalysisCenter::Esa,
1081 code: "esa",
1082 protocol: ArchiveProtocol::Https,
1083 host: "navigation-office.esa.int",
1084 root_url: "https://navigation-office.esa.int/products/gnss-products",
1085 products: &ESA_PRODUCTS,
1086 issues: &[],
1087 },
1088 CenterCatalogEntry {
1089 center: AnalysisCenter::Cod,
1090 code: "cod",
1091 protocol: ArchiveProtocol::Https,
1092 host: "www.aiub.unibe.ch",
1093 root_url: "https://www.aiub.unibe.ch/download",
1094 products: &COD_PRODUCTS,
1095 issues: &[],
1096 },
1097 CenterCatalogEntry {
1098 center: AnalysisCenter::Gfz,
1099 code: "gfz",
1100 protocol: ArchiveProtocol::Https,
1101 host: "isdc-data.gfz.de",
1102 root_url: "https://isdc-data.gfz.de/gnss/products",
1103 products: &GFZ_PRODUCTS,
1104 issues: &[],
1105 },
1106 CenterCatalogEntry {
1107 center: AnalysisCenter::IgsUlt,
1108 code: "igs_ult",
1109 protocol: ArchiveProtocol::Https,
1110 host: "igs.bkg.bund.de",
1111 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1112 products: &IGS_ULT_PRODUCTS,
1113 issues: &OPSULT_ISSUES,
1114 },
1115 CenterCatalogEntry {
1116 center: AnalysisCenter::CodUlt,
1117 code: "cod_ult",
1118 protocol: ArchiveProtocol::Https,
1119 host: "www.aiub.unibe.ch",
1120 root_url: "https://www.aiub.unibe.ch/download",
1124 products: &COD_ULT_PRODUCTS,
1125 issues: &COD_ULT_ISSUES,
1126 },
1127 CenterCatalogEntry {
1128 center: AnalysisCenter::EsaUlt,
1129 code: "esa_ult",
1130 protocol: ArchiveProtocol::Https,
1131 host: "navigation-office.esa.int",
1132 root_url: "https://navigation-office.esa.int/products/gnss-products",
1133 products: &ESA_ULT_PRODUCTS,
1134 issues: &OPSULT_ISSUES,
1135 },
1136 CenterCatalogEntry {
1137 center: AnalysisCenter::GfzUlt,
1138 code: "gfz_ult",
1139 protocol: ArchiveProtocol::Https,
1140 host: "isdc-data.gfz.de",
1141 root_url: "https://isdc-data.gfz.de/gnss/products",
1142 products: &GFZ_ULT_PRODUCTS,
1143 issues: &GFZ_ULT_ISSUES,
1144 },
1145 CenterCatalogEntry {
1146 center: AnalysisCenter::WumNrt,
1147 code: "wum_nrt",
1148 protocol: ArchiveProtocol::Ftp,
1149 host: "igs.gnsswhu.cn",
1150 root_url: "ftp://igs.gnsswhu.cn/pub/gps/products/mgex",
1151 products: &WUM_NRT_PRODUCTS,
1152 issues: &WUM_NRT_ISSUES,
1153 },
1154];
1155
1156const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1157 protocol: ArchiveProtocol::Https,
1158 host: "s3.amazonaws.com",
1159 compression: ArchiveCompression::Gzip,
1160 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1161};
1162
1163const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1164 protocol: ArchiveProtocol::Https,
1165 host: "celestrak.org",
1166 compression: ArchiveCompression::None,
1167 root_url: "https://celestrak.org/SpaceData",
1168};
1169
1170const ALLOWED_HOSTS: [&str; 11] = [
1171 "www.aiub.unibe.ch",
1172 "download.aiub.unibe.ch",
1173 "zhw-b.s3.cloud.switch.ch",
1174 "navigation-office.esa.int",
1175 "isdc-data.gfz.de",
1176 "igs.bkg.bund.de",
1177 "igs.gnsswhu.cn",
1178 "s3.amazonaws.com",
1179 "celestrak.org",
1180 "cddis.nasa.gov",
1181 "urs.earthdata.nasa.gov",
1182];
1183
1184const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1185 NoOpenMirrorProduct {
1186 center: "grg",
1187 product_type: "sp3",
1188 },
1189 NoOpenMirrorProduct {
1190 center: "grg",
1191 product_type: "clk",
1192 },
1193 NoOpenMirrorProduct {
1194 center: "wum",
1195 product_type: "sp3",
1196 },
1197 NoOpenMirrorProduct {
1198 center: "wum",
1199 product_type: "clk",
1200 },
1201 NoOpenMirrorProduct {
1202 center: "grg_ult",
1203 product_type: "sp3",
1204 },
1205 NoOpenMirrorProduct {
1206 center: "grg_ult",
1207 product_type: "clk",
1208 },
1209 NoOpenMirrorProduct {
1210 center: "igs",
1211 product_type: "ionex",
1212 },
1213];
1214
1215#[derive(Debug, Clone, PartialEq, Eq)]
1217pub enum DataCatalogError {
1218 UnknownCenter(String),
1220 UnknownProductType(String),
1222 UnsupportedProduct {
1224 center: AnalysisCenter,
1226 product_type: ProductType,
1228 },
1229 UnsupportedDistribution {
1231 source: DistributionSource,
1233 product_type: ProductType,
1235 },
1236 UnsupportedProductEra {
1238 center: AnalysisCenter,
1240 product_type: ProductType,
1242 date: ProductDate,
1244 },
1245 UnsupportedDistributionEra {
1247 source: DistributionSource,
1249 center: AnalysisCenter,
1251 product_type: ProductType,
1253 date: ProductDate,
1255 },
1256 NoDistributionSources,
1258 InvalidOfficialFilename(String),
1260 InconsistentProductIdentity {
1262 field: &'static str,
1264 },
1265 NoOpenMirror {
1267 center: String,
1269 product_type: String,
1271 },
1272 InvalidDate {
1274 year: i32,
1276 month: u8,
1278 day: u8,
1280 },
1281 DateOutOfRange,
1283 DateBeforeGpsEpoch(ProductDate),
1285 InvalidGpsDayOfWeek(u8),
1287 InvalidSample(String),
1289 UnsupportedSample {
1291 center: AnalysisCenter,
1293 product_type: ProductType,
1295 sample: String,
1297 },
1298 InvalidSpan(String),
1300 InvalidIssue(String),
1302 MissingIssue {
1304 center: AnalysisCenter,
1306 },
1307 UnexpectedIssue {
1309 center: AnalysisCenter,
1311 },
1312 UnsupportedIssue {
1314 center: AnalysisCenter,
1316 issue: String,
1318 },
1319 InvalidDateTime {
1321 hour: u8,
1323 minute: u8,
1325 second: u8,
1327 },
1328 NoUltraIssue,
1330 NoAvailableUltraIssue,
1332 UnsupportedNominalSchedule {
1335 center: AnalysisCenter,
1337 product_type: ProductType,
1339 },
1340 UnrecognizedArchiveListing {
1344 reason: String,
1346 },
1347 InvalidStation(String),
1349 InvalidCoordinate {
1351 lat_deg_bits: u64,
1353 lon_deg_bits: u64,
1355 },
1356 InvalidTileIndex {
1358 lat_index: i32,
1360 lon_index: i32,
1362 },
1363 InvalidTileId(String),
1365}
1366
1367impl fmt::Display for DataCatalogError {
1368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1369 match self {
1370 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1371 Self::UnknownProductType(product_type) => {
1372 write!(f, "unknown product type {product_type:?}")
1373 }
1374 Self::UnsupportedProduct {
1375 center,
1376 product_type,
1377 } => write!(f, "{center} does not serve {product_type}"),
1378 Self::UnsupportedDistribution {
1379 source,
1380 product_type,
1381 } => write!(
1382 f,
1383 "distributor {} does not serve {product_type}",
1384 source.code()
1385 ),
1386 Self::UnsupportedProductEra {
1387 center,
1388 product_type,
1389 date,
1390 } => write!(
1391 f,
1392 "{center}/{product_type} has no cataloged naming convention for {date}"
1393 ),
1394 Self::UnsupportedDistributionEra {
1395 source,
1396 center,
1397 product_type,
1398 date,
1399 } => write!(
1400 f,
1401 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1402 source.code()
1403 ),
1404 Self::NoDistributionSources => {
1405 write!(f, "exact product request has no distributors")
1406 }
1407 Self::InvalidOfficialFilename(filename) => {
1408 write!(f, "invalid official product filename {filename:?}")
1409 }
1410 Self::InconsistentProductIdentity { field } => {
1411 write!(
1412 f,
1413 "product identity field {field:?} disagrees with its official filename"
1414 )
1415 }
1416 Self::NoOpenMirror {
1417 center,
1418 product_type,
1419 } => write!(f, "{center}/{product_type} has no open mirror"),
1420 Self::InvalidDate { year, month, day } => {
1421 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1422 }
1423 Self::DateOutOfRange => write!(f, "product date is out of range"),
1424 Self::DateBeforeGpsEpoch(date) => {
1425 write!(f, "product date {date} is before the GPS week epoch")
1426 }
1427 Self::InvalidGpsDayOfWeek(day) => {
1428 write!(f, "invalid GPS day-of-week {day}")
1429 }
1430 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1431 Self::UnsupportedSample {
1432 center,
1433 product_type,
1434 sample,
1435 } => write!(
1436 f,
1437 "{center}/{product_type} does not publish sample interval {sample:?}"
1438 ),
1439 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1440 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1441 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1442 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1443 Self::UnsupportedIssue { center, issue } => {
1444 write!(f, "{center} does not publish issue {issue:?}")
1445 }
1446 Self::InvalidDateTime {
1447 hour,
1448 minute,
1449 second,
1450 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1451 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1452 Self::NoAvailableUltraIssue => {
1453 write!(f, "no available ultra-rapid issue at or before target")
1454 }
1455 Self::UnsupportedNominalSchedule {
1456 center,
1457 product_type,
1458 } => write!(
1459 f,
1460 "{center}/{product_type} has no nominal due-time schedule"
1461 ),
1462 Self::UnrecognizedArchiveListing { reason } => {
1463 write!(f, "unrecognized archive listing: {reason}")
1464 }
1465 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1466 Self::InvalidCoordinate {
1467 lat_deg_bits,
1468 lon_deg_bits,
1469 } => write!(
1470 f,
1471 "invalid terrain coordinate lat={} lon={}",
1472 f64::from_bits(*lat_deg_bits),
1473 f64::from_bits(*lon_deg_bits)
1474 ),
1475 Self::InvalidTileIndex {
1476 lat_index,
1477 lon_index,
1478 } => write!(
1479 f,
1480 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1481 ),
1482 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1483 }
1484 }
1485}
1486
1487impl std::error::Error for DataCatalogError {}
1488
1489#[derive(Debug, Clone, PartialEq, Eq)]
1491pub enum HgtConversionError {
1492 BadLength {
1494 expected: usize,
1496 got: usize,
1498 },
1499 InvalidTileIndex {
1501 lat_index: i32,
1503 lon_index: i32,
1505 },
1506}
1507
1508impl fmt::Display for HgtConversionError {
1509 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1510 match self {
1511 Self::BadLength { expected, got } => {
1512 write!(
1513 f,
1514 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1515 )
1516 }
1517 Self::InvalidTileIndex {
1518 lat_index,
1519 lon_index,
1520 } => write!(
1521 f,
1522 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1523 ),
1524 }
1525 }
1526}
1527
1528impl std::error::Error for HgtConversionError {}
1529
1530const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1531const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1532const MIN_TERRAIN_LON_INDEX: i32 = -180;
1533const MAX_TERRAIN_LON_INDEX: i32 = 179;
1534const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1535const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1536const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1537const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1538const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1539const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1540const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1541const DTED_SRTM1_LEN: usize =
1542 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1543
1544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1546pub struct ProductDate {
1547 pub year: i32,
1549 pub month: u8,
1551 pub day: u8,
1553}
1554
1555impl ProductDate {
1556 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1558 let days = days_in_month(i64::from(year), i64::from(month));
1559 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1560 return Err(DataCatalogError::InvalidDate { year, month, day });
1561 }
1562 Ok(Self { year, month, day })
1563 }
1564
1565 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1567 if day_of_week > 6 {
1568 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1569 }
1570 let epoch_jdn =
1571 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1572 let offset_days = i64::from(week)
1573 .checked_mul(7)
1574 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1575 .ok_or(DataCatalogError::DateOutOfRange)?;
1576 product_date_from_jdn(
1577 epoch_jdn
1578 .checked_add(offset_days)
1579 .ok_or(DataCatalogError::DateOutOfRange)?,
1580 )
1581 }
1582
1583 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1585 week_from_calendar(
1586 TimeScale::Gpst,
1587 i64::from(self.year),
1588 i64::from(self.month),
1589 i64::from(self.day),
1590 )
1591 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1592 }
1593
1594 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1596 let epoch_jdn =
1597 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1598 let days = self
1599 .julian_day_number()
1600 .checked_sub(epoch_jdn)
1601 .ok_or(DataCatalogError::DateOutOfRange)?;
1602 if days < 0 {
1603 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1604 }
1605 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1606 }
1607
1608 #[must_use]
1610 pub fn day_of_year(self) -> u16 {
1611 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1612 }
1613
1614 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1615 product_date_from_jdn(
1616 self.julian_day_number()
1617 .checked_add(days)
1618 .ok_or(DataCatalogError::DateOutOfRange)?,
1619 )
1620 }
1621
1622 fn julian_day_number(self) -> i64 {
1623 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1624 }
1625}
1626
1627impl fmt::Display for ProductDate {
1628 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1629 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1630 }
1631}
1632
1633#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1635pub struct ProductDateTime {
1636 pub date: ProductDate,
1638 pub hour: u8,
1640 pub minute: u8,
1642 pub second: u8,
1644}
1645
1646impl ProductDateTime {
1647 pub fn new(
1649 date: ProductDate,
1650 hour: u8,
1651 minute: u8,
1652 second: u8,
1653 ) -> Result<Self, DataCatalogError> {
1654 if hour > 23 || minute > 59 || second > 59 {
1655 return Err(DataCatalogError::InvalidDateTime {
1656 hour,
1657 minute,
1658 second,
1659 });
1660 }
1661 Ok(Self {
1662 date,
1663 hour,
1664 minute,
1665 second,
1666 })
1667 }
1668
1669 fn ordering_minutes(self) -> i64 {
1670 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1671 }
1672
1673 fn ordering_seconds(self) -> i64 {
1674 self.date.julian_day_number() * 86_400
1675 + i64::from(self.hour) * 3_600
1676 + i64::from(self.minute) * 60
1677 + i64::from(self.second)
1678 }
1679}
1680
1681impl fmt::Display for ProductDateTime {
1682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1683 write!(
1684 f,
1685 "{}T{:02}:{:02}:{:02}Z",
1686 self.date, self.hour, self.minute, self.second
1687 )
1688 }
1689}
1690
1691#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1693pub struct NominalCoverageInterval {
1694 pub from: ProductDateTime,
1696 pub until: ProductDateTime,
1698}
1699
1700#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1706pub struct NominalCoverage {
1707 pub observed: Option<NominalCoverageInterval>,
1709 pub predicted: Option<NominalCoverageInterval>,
1711}
1712
1713#[derive(Debug, Clone, PartialEq, Eq)]
1715pub struct NominalIssue {
1716 pub identity: ProductIdentity,
1718 pub due_at: ProductDateTime,
1720 pub covers: NominalCoverage,
1722}
1723
1724#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1726pub struct UltraIssue {
1727 pub date: ProductDate,
1729 pub issue: String,
1731}
1732
1733impl UltraIssue {
1734 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1736 validate_issue(issue)?;
1737 Ok(Self {
1738 date,
1739 issue: issue.to_string(),
1740 })
1741 }
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct UltraSp3Location {
1747 pub pattern: String,
1749 pub span: String,
1751 pub sample: String,
1753 pub filename: String,
1755 pub url: String,
1757 pub compression: ArchiveCompression,
1759}
1760
1761#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1767pub struct ProductIdentity {
1768 pub family: ProductType,
1770 pub analysis_center: AnalysisCenter,
1772 pub publisher: ProductPublisher,
1774 pub solution: SolutionClass,
1776 pub campaign: ProductCampaign,
1778 pub version: u8,
1780 pub date: ProductDate,
1786 pub issue: Option<String>,
1788 pub span: String,
1790 pub sample: String,
1792 pub official_filename: String,
1794 pub format: ProductFormat,
1796 pub format_version: Option<String>,
1802 pub prediction_horizon_days: Option<u8>,
1804}
1805
1806impl ProductIdentity {
1807 pub fn validate(&self) -> Result<(), DataCatalogError> {
1813 validate_official_filename(&self.official_filename)?;
1814 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1815 validate_sample(&self.sample)?;
1816 validate_span(&self.span)?;
1817 if let Some(issue) = self.issue.as_deref() {
1818 validate_issue(issue)?;
1819 }
1820
1821 let convention = product_convention(self.analysis_center, self.family)?;
1825 validate_product_date(self.analysis_center, self.family, self.date)?;
1826 if self.span != convention.span {
1827 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1828 }
1829 validate_catalog_sample(
1830 self.analysis_center,
1831 self.family,
1832 self.date,
1833 &self.sample,
1834 self.issue.as_deref(),
1835 )?;
1836
1837 if self.format != product_format(self.family) {
1838 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1839 }
1840
1841 if self
1842 .format_version
1843 .as_deref()
1844 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1845 {
1846 return Err(DataCatalogError::InconsistentProductIdentity {
1847 field: "format_version",
1848 });
1849 }
1850
1851 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1852 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1853 (_, SolutionClass::Predicted, _) => false,
1854 (_, _, None) => true,
1855 (_, _, Some(_)) => false,
1856 };
1857 if !horizon_valid {
1858 return Err(DataCatalogError::InconsistentProductIdentity {
1859 field: "prediction_horizon_days",
1860 });
1861 }
1862 let descriptor = product_type_convention(self.family);
1863 let legacy_igs_final =
1864 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1865 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1866 let entry = center_catalog(self.analysis_center)
1867 .expect("validated analysis center has a catalog entry");
1868 let issue_valid = if entry.issues.is_empty() {
1869 self.issue.as_deref() == Some("0000")
1870 } else {
1871 self.issue
1872 .as_deref()
1873 .is_some_and(|issue| entry.issues.contains(&issue))
1874 };
1875 if !issue_valid {
1876 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1877 }
1878 }
1879 let expected = if legacy_igs_final {
1880 let fields_valid = self.publisher == ProductPublisher::Igs
1881 && self.solution == SolutionClass::Final
1882 && self.campaign == ProductCampaign::Operational
1883 && self.version == 0
1884 && self.issue.as_deref() == Some("0000")
1885 && self.span == convention.span
1886 && self.sample == convention.default_sample;
1887 if !fields_valid {
1888 return Err(DataCatalogError::InconsistentProductIdentity {
1889 field: "legacy_igs_final",
1890 });
1891 }
1892 format!(
1893 "igs{:04}{}.sp3",
1894 self.date.gps_week()?,
1895 self.date.gps_day_of_week()?
1896 )
1897 } else {
1898 match descriptor.kind {
1899 ProductFilenameKind::Sampled => {
1900 let solution_token = self.solution.filename_token().ok_or(
1901 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1902 )?;
1903 format!(
1904 "{}{}{}{}_{}_{}_{}_{}.{}",
1905 self.publisher.code(),
1906 self.version,
1907 self.campaign.code(),
1908 solution_token,
1909 date_block(self.date, self.issue.as_deref()),
1910 self.span,
1911 self.sample,
1912 descriptor.content_code,
1913 descriptor.extension
1914 )
1915 }
1916 ProductFilenameKind::Nav => {
1917 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1918 && self.solution == SolutionClass::Broadcast
1919 && self.campaign == ProductCampaign::Broadcast
1920 && self.version == 0
1921 && self.issue.is_none()
1922 && self.span == "01D"
1923 && self.sample == "01D";
1924 if !nav_fields_valid {
1925 return Err(DataCatalogError::InconsistentProductIdentity {
1926 field: "broadcast_navigation",
1927 });
1928 }
1929 format!(
1930 "BRDC00WRD_R_{}_{}_{}.{}",
1931 date_block(self.date, None),
1932 self.span,
1933 descriptor.content_code,
1934 descriptor.extension
1935 )
1936 }
1937 }
1938 };
1939 if expected != self.official_filename {
1940 return Err(DataCatalogError::InconsistentProductIdentity {
1941 field: "official_filename",
1942 });
1943 }
1944 if self.publisher != self.analysis_center.publisher()
1945 || self.solution != product_solution_class(self.analysis_center, self.family)?
1946 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1947 {
1948 return Err(DataCatalogError::InconsistentProductIdentity {
1949 field: "analysis_center",
1950 });
1951 }
1952
1953 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1954 let expected_catalog_filename = format!(
1955 "{}_{}_{}_{}_{}.{}",
1956 convention.token,
1957 date_block(self.date, self.issue.as_deref()),
1958 self.span,
1959 self.sample,
1960 descriptor.content_code,
1961 descriptor.extension
1962 );
1963 if expected_catalog_filename != self.official_filename {
1964 return Err(DataCatalogError::InconsistentProductIdentity {
1965 field: "analysis_center",
1966 });
1967 }
1968 }
1969 Ok(())
1970 }
1971
1972 pub fn key(&self) -> Result<String, DataCatalogError> {
1974 use sha2::{Digest, Sha256};
1975
1976 let canonical = self.canonical_bytes()?;
1977 let digest = Sha256::digest(canonical);
1978 Ok(format!(
1979 "{}-{}-{}",
1980 self.publisher.code().to_ascii_lowercase(),
1981 self.solution.code(),
1982 digest[..10]
1983 .iter()
1984 .map(|byte| format!("{byte:02x}"))
1985 .collect::<String>()
1986 ))
1987 }
1988
1989 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1995 self.validate()?;
1996 let date = format!(
1997 "{:04}-{:02}-{:02}",
1998 self.date.year, self.date.month, self.date.day
1999 );
2000 let version = self.version.to_string();
2001 let prediction = self
2002 .prediction_horizon_days
2003 .map(|days| days.to_string())
2004 .unwrap_or_default();
2005 let fields = [
2006 self.family.code(),
2007 self.analysis_center.code(),
2008 self.publisher.code(),
2009 self.solution.code(),
2010 self.campaign.code(),
2011 version.as_str(),
2012 date.as_str(),
2013 self.issue.as_deref().unwrap_or_default(),
2014 self.span.as_str(),
2015 self.sample.as_str(),
2016 self.official_filename.as_str(),
2017 self.format.code(),
2018 self.format_version.as_deref().unwrap_or_default(),
2019 prediction.as_str(),
2020 ];
2021 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
2022 return Err(DataCatalogError::InconsistentProductIdentity {
2023 field: "canonical_encoding",
2024 });
2025 }
2026 Ok(fields.join("\0").into_bytes())
2027 }
2028
2029 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
2031 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
2032 }
2033}
2034
2035pub(crate) fn exact_sp3_content_start_offset_s(
2041 identity: &ProductIdentity,
2042) -> Result<i64, DataCatalogError> {
2043 identity.validate()?;
2044 if identity.family != ProductType::Sp3 {
2045 return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
2046 }
2047
2048 let entry =
2049 center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
2050 let catalog_issue = if entry.issues.is_empty() {
2054 None
2055 } else {
2056 identity.issue.as_deref()
2057 };
2058 Ok(
2059 sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
2060 .content_start_offset_s(),
2061 )
2062}
2063
2064pub fn sp3_content_start_convention(
2072 center: AnalysisCenter,
2073 date: ProductDate,
2074 issue: Option<&str>,
2075) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2076 ProductDate::new(date.year, date.month, date.day)?;
2077 product_convention(center, ProductType::Sp3)?;
2078 validate_product_date(center, ProductType::Sp3, date)?;
2079 validate_issue_for_center(center, issue)?;
2080
2081 sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2082 DataCatalogError::UnsupportedIssue {
2083 center,
2084 issue: issue.unwrap_or_default().to_owned(),
2085 }
2086 })
2087}
2088
2089fn sp3_content_start_convention_inner(
2090 center: AnalysisCenter,
2091 date: ProductDate,
2092 issue: Option<&str>,
2093) -> Option<Sp3ContentStartConvention> {
2094 if center != AnalysisCenter::GfzUlt {
2095 return Some(Sp3ContentStartConvention::FilenameEpoch);
2096 }
2097 if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2098 return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2099 }
2100 if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2101 return Some(Sp3ContentStartConvention::FilenameEpoch);
2102 }
2103
2104 let issue = issue?;
2105 GFZ_ULTRA_START_TRANSITION
2106 .iter()
2107 .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2108 .map(|(_, _, convention)| *convention)
2109}
2110
2111#[derive(Debug, Clone, PartialEq, Eq)]
2113pub struct DistributionLocation {
2114 pub source: DistributionSource,
2116 pub original_url: Option<String>,
2118 pub archive_filename: String,
2120 pub compression: ArchiveCompression,
2122}
2123
2124#[derive(Debug, Clone, PartialEq, Eq)]
2126pub struct ProductRequest {
2127 pub identity: ProductIdentity,
2129 pub distributors: Vec<DistributionSource>,
2131}
2132
2133#[derive(Debug, Clone, PartialEq, Eq)]
2135pub enum ExactProductSetError {
2136 EmptyExpected,
2138 InvalidExpected {
2140 index: usize,
2142 source: DataCatalogError,
2144 },
2145 InvalidAvailable {
2147 index: usize,
2149 source: DataCatalogError,
2151 },
2152 Mismatch {
2154 missing: Vec<ProductIdentity>,
2156 unexpected: Vec<ProductIdentity>,
2158 duplicate_expected: Vec<ProductIdentity>,
2160 duplicate_available: Vec<ProductIdentity>,
2162 },
2163}
2164
2165impl fmt::Display for ExactProductSetError {
2166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2167 match self {
2168 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2169 Self::InvalidExpected { index, source } => {
2170 write!(f, "expected product {index} is invalid: {source}")
2171 }
2172 Self::InvalidAvailable { index, source } => {
2173 write!(f, "available product {index} is invalid: {source}")
2174 }
2175 Self::Mismatch {
2176 missing,
2177 unexpected,
2178 duplicate_expected,
2179 duplicate_available,
2180 } => write!(
2181 f,
2182 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2183 identity_list(missing),
2184 identity_list(unexpected),
2185 identity_list(duplicate_expected),
2186 identity_list(duplicate_available),
2187 ),
2188 }
2189 }
2190}
2191
2192impl std::error::Error for ExactProductSetError {}
2193
2194pub fn validate_exact_product_set(
2208 expected: &[ProductIdentity],
2209 available: &[ProductIdentity],
2210) -> Result<(), ExactProductSetError> {
2211 if expected.is_empty() {
2212 return Err(ExactProductSetError::EmptyExpected);
2213 }
2214 for (index, identity) in expected.iter().enumerate() {
2215 identity
2216 .validate()
2217 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2218 }
2219 for (index, identity) in available.iter().enumerate() {
2220 identity
2221 .validate()
2222 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2223 }
2224
2225 let expected_counts = identity_counts(expected);
2226 let available_counts = identity_counts(available);
2227 let missing = unique_matching(expected, |identity| {
2228 !available_counts.contains_key(identity)
2229 });
2230 let unexpected = unique_matching(available, |identity| {
2231 !expected_counts.contains_key(identity)
2232 });
2233 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2234 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2235
2236 if missing.is_empty()
2237 && unexpected.is_empty()
2238 && duplicate_expected.is_empty()
2239 && duplicate_available.is_empty()
2240 {
2241 Ok(())
2242 } else {
2243 Err(ExactProductSetError::Mismatch {
2244 missing,
2245 unexpected,
2246 duplicate_expected,
2247 duplicate_available,
2248 })
2249 }
2250}
2251
2252fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2253 let mut counts = HashMap::with_capacity(identities.len());
2254 for identity in identities {
2255 *counts.entry(identity).or_insert(0) += 1;
2256 }
2257 counts
2258}
2259
2260fn unique_matching(
2261 identities: &[ProductIdentity],
2262 mut predicate: impl FnMut(&ProductIdentity) -> bool,
2263) -> Vec<ProductIdentity> {
2264 let mut seen = HashSet::with_capacity(identities.len());
2265 identities
2266 .iter()
2267 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2268 .cloned()
2269 .collect()
2270}
2271
2272fn identity_list(identities: &[ProductIdentity]) -> String {
2273 if identities.is_empty() {
2274 return "none".to_string();
2275 }
2276 identities
2277 .iter()
2278 .map(|identity| {
2279 identity
2280 .key()
2281 .unwrap_or_else(|_| identity.official_filename.clone())
2282 })
2283 .collect::<Vec<_>>()
2284 .join(", ")
2285}
2286
2287impl ProductRequest {
2288 pub fn new(
2290 identity: ProductIdentity,
2291 distributors: Vec<DistributionSource>,
2292 ) -> Result<Self, DataCatalogError> {
2293 if distributors.is_empty() {
2294 return Err(DataCatalogError::NoDistributionSources);
2295 }
2296 identity.validate()?;
2297 Ok(Self {
2298 identity,
2299 distributors,
2300 })
2301 }
2302}
2303
2304#[derive(Debug, Clone, PartialEq, Eq)]
2306pub struct ProductSpec {
2307 pub center: AnalysisCenter,
2309 pub product_type: ProductType,
2311 pub date: ProductDate,
2313 pub sample: String,
2315 pub issue: Option<String>,
2317}
2318
2319impl ProductSpec {
2320 pub fn new(
2322 center: AnalysisCenter,
2323 product_type: ProductType,
2324 date: ProductDate,
2325 sample: &str,
2326 issue: Option<&str>,
2327 ) -> Result<Self, DataCatalogError> {
2328 ProductDate::new(date.year, date.month, date.day)?;
2329 validate_product(center, product_type, date, sample, issue)?;
2330 Ok(Self {
2331 center,
2332 product_type,
2333 date,
2334 sample: sample.to_string(),
2335 issue: issue.map(ToOwned::to_owned),
2336 })
2337 }
2338
2339 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2341 self.date.gps_week()
2342 }
2343
2344 #[must_use]
2346 pub fn day_of_year(&self) -> u16 {
2347 self.date.day_of_year()
2348 }
2349
2350 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2356 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2357 let convention = validate_product(
2358 self.center,
2359 self.product_type,
2360 self.date,
2361 &self.sample,
2362 self.issue.as_deref(),
2363 )?;
2364 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2365 return Ok(format!(
2366 "igs{:04}{}.sp3",
2367 self.date.gps_week()?,
2368 self.date.gps_day_of_week()?
2369 ));
2370 }
2371 let descriptor = product_type_convention(self.product_type);
2372 Ok(match descriptor.kind {
2373 ProductFilenameKind::Sampled => format!(
2374 "{}_{}_{}_{}_{}.{}",
2375 convention.token,
2376 date_block(self.date, self.issue.as_deref()),
2377 convention.span,
2378 self.sample,
2379 descriptor.content_code,
2380 descriptor.extension
2381 ),
2382 ProductFilenameKind::Nav => format!(
2383 "{}_R_{}_{}_{}.{}",
2384 convention.token,
2385 date_block(self.date, None),
2386 convention.span,
2387 descriptor.content_code,
2388 descriptor.extension
2389 ),
2390 })
2391 }
2392
2393 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2395 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2396 let convention = validate_product(
2397 self.center,
2398 self.product_type,
2399 self.date,
2400 &self.sample,
2401 self.issue.as_deref(),
2402 )?;
2403 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2404 return Err(DataCatalogError::UnsupportedDistributionEra {
2405 source: DistributionSource::Direct,
2406 center: self.center,
2407 product_type: self.product_type,
2408 date: self.date,
2409 });
2410 }
2411 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2412 let filename = self.canonical_filename()?;
2413 let compression = product_archive_compression(
2414 self.center,
2415 self.product_type,
2416 self.date,
2417 convention.compression,
2418 )?;
2419 Ok(format!(
2420 "{}/{}/{}{}",
2421 entry.root_url,
2422 product_dir_path(self.center, convention.layout, self.date)?,
2423 filename,
2424 compression.suffix()
2425 ))
2426 }
2427
2428 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2430 let convention = validate_product(
2431 self.center,
2432 self.product_type,
2433 self.date,
2434 &self.sample,
2435 self.issue.as_deref(),
2436 )?;
2437 let descriptor = product_type_convention(self.product_type);
2438 let campaign = match descriptor.kind {
2439 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2440 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2441 Some("OPS") => ProductCampaign::Operational,
2442 Some("MGN") => ProductCampaign::MultiGnss,
2443 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2444 _ => {
2445 return Err(DataCatalogError::InconsistentProductIdentity {
2446 field: "campaign",
2447 });
2448 }
2449 },
2450 };
2451 let identity = ProductIdentity {
2452 family: self.product_type,
2453 analysis_center: self.center,
2454 publisher: self.center.publisher(),
2455 solution: product_solution_class(self.center, self.product_type)?,
2456 campaign,
2457 version: 0,
2458 date: self.date,
2459 issue: match descriptor.kind {
2460 ProductFilenameKind::Sampled => {
2461 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2462 }
2463 ProductFilenameKind::Nav => None,
2464 },
2465 span: convention.span.to_string(),
2466 sample: self.sample.clone(),
2467 official_filename: self.canonical_filename()?,
2468 format: product_format(self.product_type),
2469 format_version: None,
2470 prediction_horizon_days: self.center.prediction_horizon_days(),
2471 };
2472 identity.validate()?;
2473 Ok(identity)
2474 }
2475
2476 pub fn distribution_location(
2478 &self,
2479 source: DistributionSource,
2480 ) -> Result<DistributionLocation, DataCatalogError> {
2481 let identity = self.identity()?;
2482 distribution_location_for_identity(&identity, source)
2483 }
2484}
2485
2486#[derive(Debug, Clone, PartialEq, Eq)]
2488pub struct StationObservationSpec {
2489 pub station: String,
2491 pub date: ProductDate,
2493 pub sample: String,
2495}
2496
2497impl StationObservationSpec {
2498 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2500 validate_station(station)?;
2501 validate_sample(sample)?;
2502 Ok(Self {
2503 station: station.to_string(),
2504 date,
2505 sample: sample.to_string(),
2506 })
2507 }
2508
2509 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2511 station_obs_filename(&self.station, self.date, &self.sample)
2512 }
2513
2514 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2516 station_obs_url(&self.station, self.date, &self.sample)
2517 }
2518}
2519
2520#[must_use]
2522pub const fn catalog() -> &'static [CenterCatalogEntry] {
2523 &CATALOG
2524}
2525
2526#[must_use]
2528pub const fn centers() -> &'static [AnalysisCenter] {
2529 &CENTER_ORDER
2530}
2531
2532#[must_use]
2534pub const fn product_types() -> &'static [ProductTypeConvention] {
2535 &PRODUCT_TYPE_CONVENTIONS
2536}
2537
2538#[must_use]
2540pub const fn allowed_hosts() -> &'static [&'static str] {
2541 &ALLOWED_HOSTS
2542}
2543
2544#[must_use]
2546pub const fn skadi_source_entry() -> TerrainSourceEntry {
2547 SKADI_SOURCE
2548}
2549
2550#[must_use]
2552pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2553 CELESTRAK_SPACE_WEATHER_SOURCE
2554}
2555
2556#[must_use]
2558pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2559 match product {
2560 SpaceWeatherProduct::All => "SW-All.csv",
2561 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2562 }
2563}
2564
2565#[must_use]
2567pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2568 format!(
2569 "{}/{}",
2570 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2571 space_weather_filename(product)
2572 )
2573}
2574
2575#[must_use]
2577pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2578 format!("space-weather/{}", space_weather_filename(product))
2579}
2580
2581pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2583 validate_terrain_tile_index(lat_index, lon_index)?;
2584 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2585 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2586 Ok(format!(
2587 "{lat_hemi}{:02}{lon_hemi}{:03}",
2588 lat_index.abs(),
2589 lon_index.abs()
2590 ))
2591}
2592
2593pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2595 validate_terrain_lat_index(lat_index)?;
2596 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2597 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2598}
2599
2600pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2602 let band = skadi_band(lat_index)?;
2603 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2604 Ok(format!(
2605 "{}/skadi/{}/{}.hgt{}",
2606 SKADI_SOURCE.root_url,
2607 band,
2608 tile_id,
2609 SKADI_SOURCE.compression.suffix()
2610 ))
2611}
2612
2613pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2615 validate_terrain_tile_index(lat_index, lon_index)?;
2616 Ok(format!(
2617 "{}_{}{}",
2618 terrain::format_lat(lat_index),
2619 terrain::format_lon(lon_index),
2620 terrain::DTED_SUFFIX
2621 ))
2622}
2623
2624pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2626 validate_terrain_tile_index(lat_index, lon_index)?;
2627 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2628}
2629
2630pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2632 Ok(format!(
2633 "{}/{}",
2634 dted_block_dir(lat_index, lon_index)?,
2635 dted_tile_filename(lat_index, lon_index)?
2636 ))
2637}
2638
2639pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2641 let bytes = id.as_bytes();
2642 if bytes.len() != 7
2643 || !matches!(bytes[0], b'N' | b'S')
2644 || !matches!(bytes[3], b'E' | b'W')
2645 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2646 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2647 {
2648 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2649 }
2650
2651 let lat_abs = id[1..3]
2652 .parse::<i32>()
2653 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2654 let lon_abs = id[4..7]
2655 .parse::<i32>()
2656 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2657 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2658 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2659 }
2660
2661 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2662 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2663 validate_terrain_tile_index(lat_index, lon_index)?;
2664 Ok((lat_index, lon_index))
2665}
2666
2667pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2669 if !lat_deg.is_finite()
2670 || !lon_deg.is_finite()
2671 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2672 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2673 {
2674 return Err(DataCatalogError::InvalidCoordinate {
2675 lat_deg_bits: lat_deg.to_bits(),
2676 lon_deg_bits: lon_deg.to_bits(),
2677 });
2678 }
2679
2680 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2681 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2682 lat_index = MAX_TERRAIN_LAT_INDEX;
2683 }
2684 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2685 lon_index = MAX_TERRAIN_LON_INDEX;
2686 }
2687 validate_terrain_tile_index(lat_index, lon_index)?;
2688 Ok((lat_index, lon_index))
2689}
2690
2691pub fn hgt_to_dted(
2699 lat_index: i32,
2700 lon_index: i32,
2701 hgt: &[u8],
2702) -> Result<Vec<u8>, HgtConversionError> {
2703 validate_hgt_tile_index(lat_index, lon_index)?;
2704 if hgt.len() != SRTM1_HGT_LEN {
2705 return Err(HgtConversionError::BadLength {
2706 expected: SRTM1_HGT_LEN,
2707 got: hgt.len(),
2708 });
2709 }
2710
2711 let mut out = vec![b' '; DTED_SRTM1_LEN];
2712 out[0..4].copy_from_slice(b"UHL1");
2713 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2714 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2715 out[47..51].copy_from_slice(b"3601");
2716 out[51..55].copy_from_slice(b"3601");
2717
2718 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2719 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2720 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2721 out[block_start] = terrain::DATA_SENTINEL;
2722
2723 let count = (lon_posting as u32).to_be_bytes();
2724 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2725 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2726 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2727
2728 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2729 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2730 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2731 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2732 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2733 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2734 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2735 }
2736
2737 let checksum = out[block_start..checksum_start]
2738 .iter()
2739 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2740 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2741 }
2742
2743 debug_assert_eq!(out.len(), 25_981_042);
2744 Ok(out)
2745}
2746
2747#[must_use]
2749pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2750 &NO_OPEN_MIRRORS
2751}
2752
2753pub fn open_mirror(
2755 center: AnalysisCenter,
2756 product_type: ProductType,
2757) -> Result<(), DataCatalogError> {
2758 open_mirror_code(center.code(), product_type.code())
2759}
2760
2761pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2763 if NO_OPEN_MIRRORS
2764 .iter()
2765 .any(|entry| entry.center == center && entry.product_type == product_type)
2766 {
2767 Err(DataCatalogError::NoOpenMirror {
2768 center: center.to_string(),
2769 product_type: product_type.to_string(),
2770 })
2771 } else {
2772 Ok(())
2773 }
2774}
2775
2776#[must_use]
2778pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2779 CATALOG.iter().find(|entry| entry.center == center)
2780}
2781
2782pub fn product_convention(
2784 center: AnalysisCenter,
2785 product_type: ProductType,
2786) -> Result<&'static CenterProductConvention, DataCatalogError> {
2787 open_mirror(center, product_type)?;
2788 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2789 entry
2790 .products
2791 .iter()
2792 .find(|product| product.product_type == product_type)
2793 .ok_or(DataCatalogError::UnsupportedProduct {
2794 center,
2795 product_type,
2796 })
2797}
2798
2799pub fn product_solution_class(
2807 center: AnalysisCenter,
2808 product_type: ProductType,
2809) -> Result<SolutionClass, DataCatalogError> {
2810 product_convention(center, product_type)?;
2811 Ok(match (center, product_type) {
2812 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2813 _ => center.solution_class(),
2814 })
2815}
2816
2817pub fn default_sample(
2823 center: AnalysisCenter,
2824 product_type: ProductType,
2825) -> Result<&'static str, DataCatalogError> {
2826 Ok(product_convention(center, product_type)?.default_sample)
2827}
2828
2829pub fn default_sample_for_date(
2837 center: AnalysisCenter,
2838 product_type: ProductType,
2839 date: ProductDate,
2840) -> Result<&'static str, DataCatalogError> {
2841 default_sample_for_product_issue(center, product_type, date, None)
2842}
2843
2844pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2846 date.gps_week()
2847}
2848
2849#[must_use]
2851pub fn day_of_year(date: ProductDate) -> u16 {
2852 date.day_of_year()
2853}
2854
2855pub fn product(
2857 center: AnalysisCenter,
2858 product_type: ProductType,
2859 date: ProductDate,
2860 sample: Option<&str>,
2861 issue: Option<&str>,
2862) -> Result<ProductSpec, DataCatalogError> {
2863 let sample = match sample {
2864 Some(sample) => sample,
2865 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2866 };
2867 ProductSpec::new(center, product_type, date, sample, issue)
2868}
2869
2870pub fn canonical_filename(
2872 center: AnalysisCenter,
2873 product_type: ProductType,
2874 date: ProductDate,
2875 sample: Option<&str>,
2876 issue: Option<&str>,
2877) -> Result<String, DataCatalogError> {
2878 product(center, product_type, date, sample, issue)?.canonical_filename()
2879}
2880
2881pub fn archive_url(
2883 center: AnalysisCenter,
2884 product_type: ProductType,
2885 date: ProductDate,
2886 sample: Option<&str>,
2887 issue: Option<&str>,
2888) -> Result<String, DataCatalogError> {
2889 product(center, product_type, date, sample, issue)?.archive_url()
2890}
2891
2892pub fn product_identity(
2894 center: AnalysisCenter,
2895 product_type: ProductType,
2896 date: ProductDate,
2897 sample: Option<&str>,
2898 issue: Option<&str>,
2899) -> Result<ProductIdentity, DataCatalogError> {
2900 product(center, product_type, date, sample, issue)?.identity()
2901}
2902
2903pub fn distribution_location(
2905 center: AnalysisCenter,
2906 product_type: ProductType,
2907 date: ProductDate,
2908 sample: Option<&str>,
2909 issue: Option<&str>,
2910 source: DistributionSource,
2911) -> Result<DistributionLocation, DataCatalogError> {
2912 product(center, product_type, date, sample, issue)?.distribution_location(source)
2913}
2914
2915pub fn distribution_location_for_identity(
2922 identity: &ProductIdentity,
2923 source: DistributionSource,
2924) -> Result<DistributionLocation, DataCatalogError> {
2925 identity.validate()?;
2926 match source {
2927 DistributionSource::Direct => {
2928 let convention = product_convention(identity.analysis_center, identity.family)?;
2929 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2930 {
2931 return Err(DataCatalogError::UnsupportedDistributionEra {
2932 source,
2933 center: identity.analysis_center,
2934 product_type: identity.family,
2935 date: identity.date,
2936 });
2937 }
2938 let entry = center_catalog(identity.analysis_center)
2939 .expect("validated analysis center has a catalog entry");
2940 let compression = product_archive_compression(
2941 identity.analysis_center,
2942 identity.family,
2943 identity.date,
2944 convention.compression,
2945 )?;
2946 let url = format!(
2947 "{}/{}/{}{}",
2948 entry.root_url,
2949 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2950 identity.official_filename,
2951 compression.suffix()
2952 );
2953 Ok(DistributionLocation {
2954 source,
2955 original_url: Some(url),
2956 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2957 compression,
2958 })
2959 }
2960 DistributionSource::NasaCddis => {
2961 validate_cddis_distribution_era(identity)?;
2962 let compression = product_archive_compression(
2963 identity.analysis_center,
2964 identity.family,
2965 identity.date,
2966 ArchiveCompression::Gzip,
2967 )?;
2968 Ok(DistributionLocation {
2969 source,
2970 original_url: Some(cddis_archive_url(identity)?),
2971 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2972 compression,
2973 })
2974 }
2975 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2976 source,
2977 original_url: None,
2978 archive_filename: identity.official_filename.clone(),
2979 compression: ArchiveCompression::None,
2980 }),
2981 }
2982}
2983
2984pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2993 identity.validate()?;
2994 validate_cddis_distribution_era(identity)?;
2995 match identity.family {
2996 ProductType::Sp3 => {
2997 let compression = product_archive_compression(
2998 identity.analysis_center,
2999 identity.family,
3000 identity.date,
3001 ArchiveCompression::Gzip,
3002 )?;
3003 Ok(format!(
3004 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
3005 identity.date.gps_week()?,
3006 identity.official_filename,
3007 compression.suffix()
3008 ))
3009 }
3010 ProductType::Ionex => Ok(format!(
3011 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
3012 identity.date.year,
3013 identity.date.day_of_year(),
3014 identity.official_filename
3015 )),
3016 product_type => Err(DataCatalogError::UnsupportedDistribution {
3017 source: DistributionSource::NasaCddis,
3018 product_type,
3019 }),
3020 }
3021}
3022
3023pub fn mgex_clk(
3025 center: AnalysisCenter,
3026 date: ProductDate,
3027 sample: Option<&str>,
3028) -> Result<ProductSpec, DataCatalogError> {
3029 product(center, ProductType::Clk, date, sample, None)
3030}
3031
3032pub fn mgex_nav(
3034 center: AnalysisCenter,
3035 date: ProductDate,
3036 sample: Option<&str>,
3037) -> Result<ProductSpec, DataCatalogError> {
3038 product(center, ProductType::Nav, date, sample, None)
3039}
3040
3041pub fn mgex_ionex(
3043 center: AnalysisCenter,
3044 date: ProductDate,
3045 sample: Option<&str>,
3046) -> Result<ProductSpec, DataCatalogError> {
3047 product(center, ProductType::Ionex, date, sample, None)
3048}
3049
3050pub fn rapid_ionex(
3052 date: ProductDate,
3053 sample: Option<&str>,
3054) -> Result<ProductSpec, DataCatalogError> {
3055 product(
3056 AnalysisCenter::CodRap,
3057 ProductType::Ionex,
3058 date,
3059 sample,
3060 None,
3061 )
3062}
3063
3064#[must_use]
3066pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
3067 match center {
3068 AnalysisCenter::CodPrd2 => 1,
3069 _ => 0,
3070 }
3071}
3072
3073pub fn predicted_ionex(
3075 center: AnalysisCenter,
3076 date: ProductDate,
3077 sample: Option<&str>,
3078) -> Result<ProductSpec, DataCatalogError> {
3079 match center {
3080 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3081 let target = date.add_days(predicted_day_offset(center))?;
3082 product(center, ProductType::Ionex, target, sample, None)
3083 }
3084 other => Err(DataCatalogError::UnsupportedProduct {
3085 center: other,
3086 product_type: ProductType::Ionex,
3087 }),
3088 }
3089}
3090
3091pub fn mgex_sp3(
3093 center: AnalysisCenter,
3094 date: ProductDate,
3095 sample: Option<&str>,
3096) -> Result<ProductSpec, DataCatalogError> {
3097 product(center, ProductType::Sp3, date, sample, None)
3098}
3099
3100pub fn ops_ultra_sp3(
3102 center: AnalysisCenter,
3103 date: ProductDate,
3104 sample: Option<&str>,
3105 issue: Option<&str>,
3106) -> Result<ProductSpec, DataCatalogError> {
3107 let issue = issue.unwrap_or("0000");
3108 product(center, ProductType::Sp3, date, sample, Some(issue))
3109}
3110
3111pub fn ultra_sp3_locations(
3121 center: AnalysisCenter,
3122 date: ProductDate,
3123 issue: &str,
3124) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3125 validate_issue_for_center(center, Some(issue))?;
3126 validate_product_date(center, ProductType::Sp3, date)?;
3127 match center {
3128 AnalysisCenter::IgsUlt
3129 | AnalysisCenter::CodUlt
3130 | AnalysisCenter::EsaUlt
3131 | AnalysisCenter::GfzUlt
3132 | AnalysisCenter::WumNrt => {}
3133 other => {
3134 return Err(DataCatalogError::UnsupportedProduct {
3135 center: other,
3136 product_type: ProductType::Sp3,
3137 })
3138 }
3139 };
3140 let default_sample =
3141 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3142 let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3143 samples.sort_by_key(|sample| *sample != default_sample);
3144
3145 samples
3146 .into_iter()
3147 .map(|sample| {
3148 let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3152 let identity = spec.identity()?;
3153 let filename = spec.canonical_filename()?;
3154 let url = spec.archive_url()?;
3155 let convention = product_convention(center, ProductType::Sp3)?;
3156 let compression = product_archive_compression(
3157 center,
3158 ProductType::Sp3,
3159 date,
3160 convention.compression,
3161 )?;
3162 Ok(UltraSp3Location {
3163 pattern: if sample == default_sample {
3164 format!("primary_{}_{}", identity.span, sample)
3165 } else {
3166 format!("alternate_{}_{}", identity.span, sample)
3167 },
3168 span: identity.span,
3169 sample: sample.to_string(),
3170 url,
3171 filename,
3172 compression,
3173 })
3174 })
3175 .collect()
3176}
3177
3178pub fn ops_ultra_clk(
3180 center: AnalysisCenter,
3181 date: ProductDate,
3182 sample: Option<&str>,
3183 issue: Option<&str>,
3184) -> Result<ProductSpec, DataCatalogError> {
3185 let issue = issue.unwrap_or("0000");
3186 product(center, ProductType::Clk, date, sample, Some(issue))
3187}
3188
3189pub fn latest_ops_ultra_sp3(
3191 center: AnalysisCenter,
3192 target: ProductDateTime,
3193 sample: Option<&str>,
3194 available_issues: Option<&[UltraIssue]>,
3195) -> Result<ProductSpec, DataCatalogError> {
3196 let selected = latest_ultra_issue(center, target, available_issues)?;
3197 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3198}
3199
3200pub fn ultra_issue_candidates(
3202 center: AnalysisCenter,
3203 target: ProductDateTime,
3204) -> Result<Vec<UltraIssue>, DataCatalogError> {
3205 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3206 let _ = product_convention(center, ProductType::Sp3)?;
3207 if entry.issues.is_empty() {
3208 return Err(DataCatalogError::UnsupportedProduct {
3209 center,
3210 product_type: ProductType::Sp3,
3211 });
3212 }
3213 validate_product_date(center, ProductType::Sp3, target.date)?;
3214
3215 let mut candidates = Vec::new();
3216 for date in [target.date, target.date.add_days(-1)?] {
3217 match validate_product_date(center, ProductType::Sp3, date) {
3218 Ok(()) => {}
3219 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3220 Err(error) => return Err(error),
3221 }
3222 for issue in entry.issues.iter().rev() {
3223 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3224 candidates.push(UltraIssue::new(date, issue)?);
3225 }
3226 }
3227 }
3228 Ok(candidates)
3229}
3230
3231pub fn latest_ultra_issue(
3233 center: AnalysisCenter,
3234 target: ProductDateTime,
3235 available_issues: Option<&[UltraIssue]>,
3236) -> Result<UltraIssue, DataCatalogError> {
3237 let candidates = ultra_issue_candidates(center, target)?;
3238 if candidates.is_empty() {
3239 return Err(DataCatalogError::NoUltraIssue);
3240 }
3241 if let Some(available) = available_issues {
3242 candidates
3243 .into_iter()
3244 .find(|candidate| {
3245 available
3246 .iter()
3247 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3248 })
3249 .ok_or(DataCatalogError::NoAvailableUltraIssue)
3250 } else {
3251 Ok(candidates[0].clone())
3252 }
3253}
3254
3255pub fn predicted_ionex_line_candidates(
3286 map_date: ProductDate,
3287 sample: Option<&str>,
3288) -> Result<Vec<ProductSpec>, DataCatalogError> {
3289 let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3290 let two_day_production_date = map_date.add_days(-1)?;
3291 let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3292 if one_day.date != map_date || two_day.date != map_date {
3296 return Err(DataCatalogError::InconsistentProductIdentity {
3297 field: "predicted_ionex_map_date",
3298 });
3299 }
3300 Ok(vec![one_day, two_day])
3301}
3302
3303pub fn gim_date_candidates(
3305 center: AnalysisCenter,
3306 target: ProductDate,
3307 lookback: u32,
3308) -> Result<Vec<ProductDate>, DataCatalogError> {
3309 let _ = product_convention(center, ProductType::Ionex)?;
3310 let base = target.add_days(predicted_day_offset(center))?;
3311 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3312 for back in 0..=lookback {
3313 out.push(base.add_days(-i64::from(back))?);
3314 }
3315 Ok(out)
3316}
3317
3318#[derive(Debug, Clone, PartialEq, Eq)]
3349pub struct PublishedObject {
3350 pub path: String,
3352 pub observed_at: Option<String>,
3354}
3355
3356#[derive(Debug, Clone, PartialEq, Eq)]
3359pub struct PublishedProduct {
3360 pub date: ProductDate,
3362 pub issue: String,
3364 pub filename: String,
3366 pub observed_at: Option<String>,
3368}
3369
3370#[derive(Default)]
3406struct ListingIndex {
3407 buckets: HashMap<u64, Vec<u32>>,
3408}
3409
3410impl ListingIndex {
3411 fn push(
3417 &mut self,
3418 seen: &mut Vec<PublishedObject>,
3419 hash_of: fn(&str) -> u64,
3420 path: String,
3421 observed_at: Option<String>,
3422 ) {
3423 let candidates = self.buckets.entry(hash_of(&path)).or_default();
3424 if let Some(&at) = candidates
3425 .iter()
3426 .find(|&&at| seen[at as usize].path == path)
3427 {
3428 let existing = &mut seen[at as usize];
3429 if existing.observed_at.is_none() {
3430 existing.observed_at = observed_at;
3431 }
3432 } else {
3433 candidates.push(seen.len() as u32);
3434 seen.push(PublishedObject { path, observed_at });
3435 }
3436 }
3437}
3438
3439fn default_path_hash(path: &str) -> u64 {
3440 let mut hasher = DefaultHasher::new();
3441 path.hash(&mut hasher);
3442 hasher.finish()
3443}
3444
3445pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3446 let mut seen: Vec<PublishedObject> = Vec::new();
3447 let mut positions: ListingIndex = ListingIndex::default();
3459 let mut push = |path: String, observed_at: Option<String>| {
3460 positions.push(&mut seen, default_path_hash, path, observed_at);
3461 };
3462 let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3463 reason: reason.to_string(),
3464 };
3465
3466 let non_empty: Vec<&str> = body
3467 .lines()
3468 .map(str::trim_end)
3469 .filter(|line| !line.trim().is_empty())
3470 .collect();
3471 if non_empty.is_empty() {
3472 return Err(unrecognized("empty body"));
3473 }
3474 let has_markup = body.contains('<');
3475
3476 if !has_markup && non_empty[0].matches(';').count() >= 3 {
3478 for line in &non_empty {
3479 if line.matches(';').count() < 3 {
3480 return Err(unrecognized("CSV row without its four fields"));
3481 }
3482 let mut fields = line.split(';');
3483 let (Some(path), Some(_bytes), Some(observed)) =
3484 (fields.next(), fields.next(), fields.next())
3485 else {
3486 return Err(unrecognized("CSV row without its four fields"));
3487 };
3488 if path.is_empty() {
3489 return Err(unrecognized("CSV row without an archive path"));
3490 }
3491 if path.ends_with('/') {
3499 continue;
3500 }
3501 let observed_at =
3502 (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3503 push(path.to_string(), observed_at);
3504 }
3505 return Ok(seen);
3506 }
3507
3508 if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3510 for (index, line) in non_empty.iter().enumerate() {
3511 if index == 0 && line.starts_with("total ") {
3512 continue;
3513 }
3514 let mode_shaped = line.len() > 10
3515 && line.starts_with(['-', 'd', 'l'])
3516 && line.as_bytes()[1..10]
3517 .iter()
3518 .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3519 if !mode_shaped {
3520 return Err(unrecognized("FTP LIST row without a Unix mode field"));
3521 }
3522 if !line.starts_with('-') {
3524 continue;
3525 }
3526 let fields: Vec<&str> = line.split_whitespace().collect();
3527 if fields.len() < 9 {
3528 return Err(unrecognized("FTP LIST file row without nine fields"));
3529 }
3530 push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3531 }
3532 return Ok(seen);
3533 }
3534
3535 if has_markup && body.contains("Index of") {
3537 for line in &non_empty {
3538 let mut rest = *line;
3541 while let Some(start) = rest.find("<a href=\"") {
3542 rest = &rest[start + 9..];
3543 let Some(end) = rest.find('"') else { break };
3544 let target = &rest[..end];
3545 rest = &rest[end..];
3546 if target.is_empty()
3547 || target.starts_with('?')
3548 || target.starts_with('/')
3549 || target.starts_with('#')
3550 || target.contains("://")
3551 || target.ends_with('/')
3552 {
3553 continue;
3554 }
3555 let observed_at = find_listing_datetime(rest).map(str::to_string);
3556 push(target.to_string(), observed_at);
3557 }
3558 }
3559 return Ok(seen);
3560 }
3561
3562 Err(unrecognized(if has_markup {
3563 "markup without an autoindex marker"
3564 } else {
3565 "no known listing grammar"
3566 }))
3567}
3568
3569fn find_listing_datetime(rest: &str) -> Option<&str> {
3571 let bytes = rest.as_bytes();
3572 let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3573 for start in 0..bytes.len().saturating_sub(15) {
3574 let shape_matches = is_digit(start)
3575 && is_digit(start + 1)
3576 && is_digit(start + 2)
3577 && is_digit(start + 3)
3578 && bytes[start + 4] == b'-'
3579 && is_digit(start + 5)
3580 && is_digit(start + 6)
3581 && bytes[start + 7] == b'-'
3582 && is_digit(start + 8)
3583 && is_digit(start + 9)
3584 && bytes[start + 10] == b' '
3585 && is_digit(start + 11)
3586 && is_digit(start + 12)
3587 && bytes[start + 13] == b':'
3588 && is_digit(start + 14)
3589 && is_digit(start + 15);
3590 if shape_matches {
3591 return Some(&rest[start..start + 16]);
3592 }
3593 }
3594 None
3595}
3596
3597const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3600 match center {
3601 AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3602 AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3603 _ => None,
3604 }
3605}
3606
3607fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3608 match center_path_marker(center) {
3609 Some(marker) => {
3612 let slashed = format!("/{path}");
3613 slashed.contains(marker)
3614 }
3615 None => true,
3616 }
3617}
3618
3619pub fn newest_published_product(
3633 center: AnalysisCenter,
3634 product_type: ProductType,
3635 objects: &[PublishedObject],
3636) -> Result<Option<PublishedProduct>, DataCatalogError> {
3637 let convention = product_convention(center, product_type)?;
3638 let descriptor = product_type_convention(product_type);
3639 let suffix = format!(".{}", descriptor.extension);
3640 let tail = format!("_{}{}", descriptor.content_code, suffix);
3641
3642 let mut newest: Option<(i64, PublishedProduct)> = None;
3643 for object in objects {
3644 if !object_matches_center(center, &object.path) {
3645 continue;
3646 }
3647 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3648 let stripped = listed_name
3649 .strip_suffix(".gz")
3650 .or_else(|| listed_name.strip_suffix(".Z"))
3651 .unwrap_or(listed_name);
3652 let Some(after_token) = stripped
3653 .strip_prefix(convention.token)
3654 .and_then(|rest| rest.strip_prefix('_'))
3655 else {
3656 continue;
3657 };
3658 let Some(middle) = after_token.strip_suffix(&tail) else {
3659 continue;
3660 };
3661 let mut parts = middle.split('_');
3662 let (Some(block), Some(span), Some(sample), None) =
3663 (parts.next(), parts.next(), parts.next(), parts.next())
3664 else {
3665 continue;
3666 };
3667 if span != convention.span || block.len() != 11 {
3668 continue;
3669 }
3670 let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3671 else {
3672 continue;
3673 };
3674 let issue = &block[7..11];
3675 let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3676 continue;
3677 };
3678 if validate_issue(issue).is_err() {
3679 continue;
3680 }
3681 let issue_argument = (!center_catalog(center)
3684 .expect("catalog entry exists for enum variant")
3685 .issues
3686 .is_empty())
3687 .then_some(issue);
3688 match product(center, product_type, date, Some(sample), issue_argument) {
3689 Ok(spec) => {
3690 if spec.canonical_filename()? != stripped {
3691 continue;
3692 }
3693 }
3694 Err(_) => continue,
3695 }
3696 let ordering = issue_ordering_minutes(date, issue)?;
3697 let replace = newest
3698 .as_ref()
3699 .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3700 if replace {
3701 newest = Some((
3702 ordering,
3703 PublishedProduct {
3704 date,
3705 issue: issue.to_string(),
3706 filename: stripped.to_string(),
3707 observed_at: object.observed_at.clone(),
3708 },
3709 ));
3710 }
3711 }
3712 Ok(newest.map(|(_, product)| product))
3713}
3714
3715pub fn published_issue_age_minutes(
3723 published: &PublishedProduct,
3724 now: ProductDateTime,
3725) -> Result<i64, DataCatalogError> {
3726 Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3727}
3728
3729pub fn next_issue_due(
3759 center: AnalysisCenter,
3760 product_type: ProductType,
3761 now: ProductDateTime,
3762) -> Result<NominalIssue, DataCatalogError> {
3763 ProductDate::new(now.date.year, now.date.month, now.date.day)?;
3764 ProductDateTime::new(now.date, now.hour, now.minute, now.second)?;
3765 product_convention(center, product_type)?;
3766 if center == AnalysisCenter::WumNrt || product_type == ProductType::Nav {
3767 return Err(DataCatalogError::UnsupportedNominalSchedule {
3768 center,
3769 product_type,
3770 });
3771 }
3772
3773 let mut next: Option<NominalIssue> = None;
3774 for offset_days in -28_i64..=42 {
3775 let Ok(identity_date) = now.date.add_days(offset_days) else {
3776 continue;
3777 };
3778 for candidate in nominal_issues_for_date(center, product_type, identity_date)? {
3779 if candidate.due_at < now {
3780 continue;
3781 }
3782 let replace = next.as_ref().is_none_or(|current| {
3783 candidate.due_at < current.due_at
3784 || (candidate.due_at == current.due_at
3785 && candidate.identity.official_filename
3786 < current.identity.official_filename)
3787 });
3788 if replace {
3789 next = Some(candidate);
3790 }
3791 }
3792 }
3793 next.ok_or(DataCatalogError::DateOutOfRange)
3794}
3795
3796fn nominal_issues_for_date(
3797 center: AnalysisCenter,
3798 product_type: ProductType,
3799 identity_date: ProductDate,
3800) -> Result<Vec<NominalIssue>, DataCatalogError> {
3801 let solution = product_solution_class(center, product_type)?;
3802 if solution == SolutionClass::Final && identity_date.gps_day_of_week()? != 6 {
3803 return Ok(Vec::new());
3804 }
3805
3806 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3807 let issues: Vec<&str> = if matches!(solution, SolutionClass::UltraRapid) {
3808 entry.issues.to_vec()
3809 } else {
3810 vec!["0000"]
3811 };
3812 let mut out = Vec::with_capacity(issues.len());
3813 for issue in issues {
3814 let issue_argument = (!entry.issues.is_empty()).then_some(issue);
3815 let identity =
3816 match product_identity(center, product_type, identity_date, None, issue_argument) {
3817 Ok(identity) => identity,
3818 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3819 Err(error) => return Err(error),
3820 };
3821 let filename_epoch = ProductDateTime::new(
3822 identity_date,
3823 (issue_minutes(issue)? / 60) as u8,
3824 (issue_minutes(issue)? % 60) as u8,
3825 0,
3826 )?;
3827 let covers = nominal_coverage(&identity, filename_epoch)?;
3828 let due_at = nominal_due_at(center, product_type, solution, filename_epoch, covers)?;
3829 out.push(NominalIssue {
3830 identity,
3831 due_at,
3832 covers,
3833 });
3834 }
3835 Ok(out)
3836}
3837
3838fn nominal_due_at(
3839 center: AnalysisCenter,
3840 product_type: ProductType,
3841 solution: SolutionClass,
3842 filename_epoch: ProductDateTime,
3843 covers: NominalCoverage,
3844) -> Result<ProductDateTime, DataCatalogError> {
3845 match solution {
3846 SolutionClass::UltraRapid => {
3847 let observed_until = covers
3848 .observed
3849 .ok_or(DataCatalogError::InconsistentProductIdentity {
3850 field: "nominal_ultra_observed_coverage",
3851 })?
3852 .until;
3853 add_product_seconds(
3854 observed_until,
3855 if center == AnalysisCenter::IgsUlt {
3856 3 * 3_600
3857 } else {
3858 2 * 3_600 + 50 * 60
3859 },
3860 )
3861 }
3862 SolutionClass::Rapid if product_type == ProductType::Ionex => {
3863 add_product_seconds(filename_epoch, 24 * 3_600)
3864 }
3865 SolutionClass::Rapid => {
3866 add_product_seconds(filename_epoch, 24 * 3_600 + 15 * 3_600 + 45 * 60)
3867 }
3868 SolutionClass::Final if center == AnalysisCenter::Igs => {
3869 add_product_seconds(filename_epoch, 13 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3870 }
3871 SolutionClass::Final if product_type == ProductType::Ionex => {
3872 add_product_seconds(filename_epoch, 11 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3873 }
3874 SolutionClass::Final => add_product_seconds(filename_epoch, 11 * 86_400 + 5 * 3_600),
3875 SolutionClass::Predicted => {
3876 let horizon = center.prediction_horizon_days().ok_or(
3877 DataCatalogError::UnsupportedNominalSchedule {
3878 center,
3879 product_type,
3880 },
3881 )?;
3882 add_product_seconds(filename_epoch, -i64::from(horizon) * 86_400)
3883 }
3884 SolutionClass::NearRealTime | SolutionClass::Broadcast => {
3885 Err(DataCatalogError::UnsupportedNominalSchedule {
3886 center,
3887 product_type,
3888 })
3889 }
3890 }
3891}
3892
3893fn nominal_coverage(
3894 identity: &ProductIdentity,
3895 filename_epoch: ProductDateTime,
3896) -> Result<NominalCoverage, DataCatalogError> {
3897 let content_offset_s = if identity.family == ProductType::Sp3 {
3898 let entry = center_catalog(identity.analysis_center)
3899 .expect("validated identity has a catalog entry");
3900 let issue = if entry.issues.is_empty() {
3901 None
3902 } else {
3903 identity.issue.as_deref()
3904 };
3905 sp3_content_start_convention(identity.analysis_center, identity.date, issue)?
3906 .content_start_offset_s()
3907 } else {
3908 0
3909 };
3910 let from = add_product_seconds(filename_epoch, content_offset_s)?;
3911 let duration_s = match identity.span.as_str() {
3912 "01D" => 86_400,
3913 "02D" => 172_800,
3914 _ => {
3915 return Err(DataCatalogError::InconsistentProductIdentity {
3916 field: "nominal_coverage_span",
3917 })
3918 }
3919 };
3920 let until = add_product_seconds(from, duration_s)?;
3921
3922 if identity.solution == SolutionClass::UltraRapid && duration_s == 172_800 {
3923 let split = add_product_seconds(from, 86_400)?;
3924 Ok(NominalCoverage {
3925 observed: Some(NominalCoverageInterval { from, until: split }),
3926 predicted: Some(NominalCoverageInterval { from: split, until }),
3927 })
3928 } else if identity.solution == SolutionClass::Predicted {
3929 Ok(NominalCoverage {
3930 observed: None,
3931 predicted: Some(NominalCoverageInterval { from, until }),
3932 })
3933 } else {
3934 Ok(NominalCoverage {
3935 observed: Some(NominalCoverageInterval { from, until }),
3936 predicted: None,
3937 })
3938 }
3939}
3940
3941fn add_product_seconds(
3942 datetime: ProductDateTime,
3943 seconds: i64,
3944) -> Result<ProductDateTime, DataCatalogError> {
3945 let total = datetime
3946 .ordering_seconds()
3947 .checked_add(seconds)
3948 .ok_or(DataCatalogError::DateOutOfRange)?;
3949 let jdn = total.div_euclid(86_400);
3950 let seconds_of_day = total.rem_euclid(86_400);
3951 ProductDateTime::new(
3952 product_date_from_jdn(jdn)?,
3953 u8::try_from(seconds_of_day / 3_600).map_err(|_| DataCatalogError::DateOutOfRange)?,
3954 u8::try_from((seconds_of_day % 3_600) / 60)
3955 .map_err(|_| DataCatalogError::DateOutOfRange)?,
3956 u8::try_from(seconds_of_day % 60).map_err(|_| DataCatalogError::DateOutOfRange)?,
3957 )
3958}
3959
3960pub fn publication_listing_urls(
3978 center: AnalysisCenter,
3979 product_type: ProductType,
3980 around: ProductDate,
3981) -> Result<Vec<String>, DataCatalogError> {
3982 let convention = product_convention(center, product_type)?;
3983 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3984 match convention.layout {
3985 ArchiveLayout::AiubCodeRoot
3986 | ArchiveLayout::AiubCodeYear
3987 | ArchiveLayout::AiubCodeMgexYear => {
3988 Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3989 }
3990 _ => {
3991 let current = format!(
3992 "{}/{}/",
3993 entry.root_url,
3994 product_dir_path(center, convention.layout, around)?
3995 );
3996 let previous_week_date = around.add_days(-7)?;
3997 let previous = format!(
3998 "{}/{}/",
3999 entry.root_url,
4000 product_dir_path(center, convention.layout, previous_week_date)?
4001 );
4002 let mut urls = vec![current];
4003 if !urls.contains(&previous) {
4004 urls.push(previous);
4005 }
4006 Ok(urls)
4007 }
4008 }
4009}
4010
4011pub fn resolve_first_published(
4021 candidates: &[ProductSpec],
4022 objects: &[PublishedObject],
4023) -> Result<Option<usize>, DataCatalogError> {
4024 for (index, candidate) in candidates.iter().enumerate() {
4025 let filename = candidate.canonical_filename()?;
4026 let convention = product_convention(candidate.center, candidate.product_type)?;
4027 let compression = product_archive_compression(
4028 candidate.center,
4029 candidate.product_type,
4030 candidate.date,
4031 convention.compression,
4032 )?;
4033 let archive_name = format!("{filename}{}", compression.suffix());
4034 let found = objects.iter().any(|object| {
4035 if !object_matches_center(candidate.center, &object.path) {
4036 return false;
4037 }
4038 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
4039 listed_name == archive_name || listed_name == filename
4040 });
4041 if found {
4042 return Ok(Some(index));
4043 }
4044 }
4045 Ok(None)
4046}
4047
4048fn product_date_from_year_day(
4049 year: i32,
4050 day_of_year: u16,
4051) -> Result<ProductDate, DataCatalogError> {
4052 if day_of_year == 0 {
4053 return Err(DataCatalogError::DateOutOfRange);
4054 }
4055 ProductDate::new(year, 1, 1)?
4056 .add_days(i64::from(day_of_year) - 1)
4057 .and_then(|date| {
4058 if date.year == year {
4059 Ok(date)
4060 } else {
4061 Err(DataCatalogError::DateOutOfRange)
4062 }
4063 })
4064}
4065
4066pub fn station_obs(
4068 station: &str,
4069 date: ProductDate,
4070 sample: Option<&str>,
4071) -> Result<StationObservationSpec, DataCatalogError> {
4072 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
4073}
4074
4075pub fn station_obs_filename(
4077 station: &str,
4078 date: ProductDate,
4079 sample: &str,
4080) -> Result<String, DataCatalogError> {
4081 validate_station(station)?;
4082 validate_sample(sample)?;
4083 Ok(format!(
4084 "{}_R_{}_01D_{}_MO.crx",
4085 station,
4086 date_block(date, None),
4087 sample
4088 ))
4089}
4090
4091pub fn station_obs_url(
4093 station: &str,
4094 date: ProductDate,
4095 sample: &str,
4096) -> Result<String, DataCatalogError> {
4097 let filename = station_obs_filename(station, date, sample)?;
4098 Ok(format!(
4099 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
4100 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
4101 filename
4102 ))
4103}
4104
4105#[must_use]
4107pub const fn station_obs_protocol() -> ArchiveProtocol {
4108 ArchiveProtocol::Https
4109}
4110
4111fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
4112 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
4113 Ok(())
4114 } else {
4115 Err(DataCatalogError::InvalidTileIndex {
4116 lat_index,
4117 lon_index: 0,
4118 })
4119 }
4120}
4121
4122fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
4123 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4124 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4125 {
4126 Ok(())
4127 } else {
4128 Err(DataCatalogError::InvalidTileIndex {
4129 lat_index,
4130 lon_index,
4131 })
4132 }
4133}
4134
4135fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
4136 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4137 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4138 {
4139 Ok(())
4140 } else {
4141 Err(HgtConversionError::InvalidTileIndex {
4142 lat_index,
4143 lon_index,
4144 })
4145 }
4146}
4147
4148fn dted_coord_field(index: i32, is_longitude: bool) -> String {
4149 let hemi = match (is_longitude, index >= 0) {
4150 (true, true) => 'E',
4151 (true, false) => 'W',
4152 (false, true) => 'N',
4153 (false, false) => 'S',
4154 };
4155 format!("{:03}0000{hemi}", index.abs())
4156}
4157
4158fn encode_dted_signed_magnitude(sample: i16) -> u16 {
4159 if sample == i16::MIN {
4160 0
4161 } else if sample >= 0 {
4162 sample as u16
4163 } else {
4164 0x8000 | (-i32::from(sample) as u16)
4165 }
4166}
4167
4168fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
4169 PRODUCT_TYPE_CONVENTIONS
4170 .iter()
4171 .find(|descriptor| descriptor.product_type == product_type)
4172 .expect("product descriptor exists for enum variant")
4173}
4174
4175const fn product_format(product_type: ProductType) -> ProductFormat {
4176 match product_type {
4177 ProductType::Sp3 => ProductFormat::Sp3,
4178 ProductType::Ionex => ProductFormat::Ionex,
4179 ProductType::Clk => ProductFormat::RinexClock,
4180 ProductType::Nav => ProductFormat::RinexNavigation,
4181 }
4182}
4183
4184fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
4185 if filename.is_empty()
4186 || filename == "."
4187 || filename == ".."
4188 || filename.contains('/')
4189 || filename.contains('\\')
4190 || filename.contains('\0')
4191 || filename.contains("..")
4192 {
4193 Err(DataCatalogError::InvalidOfficialFilename(
4194 filename.to_string(),
4195 ))
4196 } else {
4197 Ok(())
4198 }
4199}
4200
4201fn validate_product(
4202 center: AnalysisCenter,
4203 product_type: ProductType,
4204 date: ProductDate,
4205 sample: &str,
4206 issue: Option<&str>,
4207) -> Result<&'static CenterProductConvention, DataCatalogError> {
4208 let convention = product_convention(center, product_type)?;
4209 validate_sample(sample)?;
4210 validate_issue_for_center(center, issue)?;
4211 validate_product_date(center, product_type, date)?;
4212 validate_catalog_sample(center, product_type, date, sample, issue)?;
4213 Ok(convention)
4214}
4215
4216fn validate_catalog_sample(
4217 center: AnalysisCenter,
4218 product_type: ProductType,
4219 date: ProductDate,
4220 sample: &str,
4221 issue: Option<&str>,
4222) -> Result<(), DataCatalogError> {
4223 let supported = supported_samples_inner(center, product_type, date, issue)?;
4224 if supported.contains(&sample) {
4225 return Ok(());
4226 }
4227 Err(DataCatalogError::UnsupportedSample {
4228 center,
4229 product_type,
4230 sample: sample.to_string(),
4231 })
4232}
4233
4234pub fn supported_samples(
4245 center: AnalysisCenter,
4246 product_type: ProductType,
4247 date: ProductDate,
4248 issue: Option<&str>,
4249) -> Result<&'static [&'static str], DataCatalogError> {
4250 ProductDate::new(date.year, date.month, date.day)?;
4251 product_convention(center, product_type)?;
4252 validate_product_date(center, product_type, date)?;
4253
4254 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4255 if entry.issues.is_empty() {
4256 validate_issue_for_center(center, issue)?;
4257 } else {
4258 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
4259 }
4260 supported_samples_inner(center, product_type, date, issue)
4261}
4262
4263fn supported_samples_inner(
4264 center: AnalysisCenter,
4265 product_type: ProductType,
4266 date: ProductDate,
4267 issue: Option<&str>,
4268) -> Result<&'static [&'static str], DataCatalogError> {
4269 if product_type != ProductType::Sp3 {
4270 let convention = product_convention(center, product_type)?;
4271 return Ok(match convention.default_sample {
4272 "30S" => &["30S"],
4273 "01H" => &["01H"],
4274 "02H" => &["02H"],
4275 "01D" => &["01D"],
4276 _ => &[],
4277 });
4278 }
4279
4280 Ok(match center {
4281 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
4282 AnalysisCenter::Esa
4283 | AnalysisCenter::Cod
4284 | AnalysisCenter::CodUlt
4285 | AnalysisCenter::WumNrt => &["05M"],
4286 AnalysisCenter::Gfz => {
4287 if date < GFZ_RAPID_5M_START_DATE {
4288 &["15M"]
4289 } else {
4290 &["05M"]
4291 }
4292 }
4293 AnalysisCenter::EsaUlt => {
4294 let issue = issue.unwrap_or("0000");
4295 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4296 || (date == ESA_ULTRA_15M_LAST_DATE
4297 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4298 if at_or_before_last_15m {
4299 &["15M"]
4300 } else {
4301 &["05M"]
4302 }
4303 }
4304 AnalysisCenter::GfzUlt => {
4305 if date < GFZ_ULTRA_15M_LAST_DATE {
4306 &["15M"]
4307 } else if date == GFZ_ULTRA_15M_LAST_DATE {
4308 if issue.unwrap_or("0000") == "0000" {
4309 &["15M", "05M"]
4310 } else {
4311 &["15M"]
4312 }
4313 } else {
4314 &["05M"]
4315 }
4316 }
4317 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
4318 })
4319}
4320
4321fn validate_product_date(
4322 center: AnalysisCenter,
4323 product_type: ProductType,
4324 date: ProductDate,
4325) -> Result<(), DataCatalogError> {
4326 if center == AnalysisCenter::Igs
4330 && product_type == ProductType::Sp3
4331 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
4332 {
4333 return Err(DataCatalogError::UnsupportedProductEra {
4334 center,
4335 product_type,
4336 date,
4337 });
4338 }
4339
4340 if center == AnalysisCenter::Cod
4345 && matches!(
4346 product_type,
4347 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
4348 )
4349 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
4350 {
4351 return Err(DataCatalogError::UnsupportedProductEra {
4352 center,
4353 product_type,
4354 date,
4355 });
4356 }
4357
4358 let start_date = match (center, product_type) {
4359 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
4360 Some(ESA_FINAL_SERIES_START_DATE)
4361 }
4362 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
4363 Some(GFZ_RAPID_SERIES_START_DATE)
4364 }
4365 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
4366 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
4367 (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
4368 _ => None,
4369 };
4370 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4371 && product_type == ProductType::Sp3
4372 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4373 if before_long_name_start || start_date.is_some_and(|start| date < start) {
4374 return Err(DataCatalogError::UnsupportedProductEra {
4375 center,
4376 product_type,
4377 date,
4378 });
4379 }
4380 Ok(())
4381}
4382
4383fn default_sample_for_product_issue(
4384 center: AnalysisCenter,
4385 product_type: ProductType,
4386 date: ProductDate,
4387 issue: Option<&str>,
4388) -> Result<&'static str, DataCatalogError> {
4389 ProductDate::new(date.year, date.month, date.day)?;
4390 let current = default_sample(center, product_type)?;
4391 validate_product_date(center, product_type, date)?;
4392
4393 if product_type != ProductType::Sp3 {
4394 return Ok(current);
4395 }
4396 match center {
4397 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4398 AnalysisCenter::EsaUlt => {
4399 let issue = issue.unwrap_or("0000");
4403 validate_issue_for_center(center, Some(issue))?;
4404 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4405 || (date == ESA_ULTRA_15M_LAST_DATE
4406 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4407 if at_or_before_last_15m {
4408 Ok("15M")
4409 } else {
4410 Ok(current)
4411 }
4412 }
4413 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4414 _ => Ok(current),
4415 }
4416}
4417
4418fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4419 let gps_week = identity.date.gps_week()?;
4420 let esa_mgex_final_sp3 =
4421 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4422 if identity.analysis_center == AnalysisCenter::WumNrt {
4426 return Err(DataCatalogError::UnsupportedDistributionEra {
4427 source: DistributionSource::NasaCddis,
4428 center: identity.analysis_center,
4429 product_type: identity.family,
4430 date: identity.date,
4431 });
4432 }
4433 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4434 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4435 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4436 let unmodeled_pretransition_ionex =
4437 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4438 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4439 Err(DataCatalogError::UnsupportedDistributionEra {
4440 source: DistributionSource::NasaCddis,
4441 center: identity.analysis_center,
4442 product_type: identity.family,
4443 date: identity.date,
4444 })
4445 } else {
4446 Ok(())
4447 }
4448}
4449
4450fn validate_issue_for_center(
4451 center: AnalysisCenter,
4452 issue: Option<&str>,
4453) -> Result<(), DataCatalogError> {
4454 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4455 match (entry.issues.is_empty(), issue) {
4456 (true, None) => Ok(()),
4457 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4458 (false, None) => Err(DataCatalogError::MissingIssue { center }),
4459 (false, Some(issue)) => {
4460 validate_issue(issue)?;
4461 if entry.issues.contains(&issue) {
4462 Ok(())
4463 } else {
4464 Err(DataCatalogError::UnsupportedIssue {
4465 center,
4466 issue: issue.to_string(),
4467 })
4468 }
4469 }
4470 }
4471}
4472
4473fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4474 if validate_period_token(sample) {
4475 Ok(())
4476 } else {
4477 Err(DataCatalogError::InvalidSample(sample.to_string()))
4478 }
4479}
4480
4481fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4482 if validate_period_token(span) {
4483 Ok(())
4484 } else {
4485 Err(DataCatalogError::InvalidSpan(span.to_string()))
4486 }
4487}
4488
4489fn validate_period_token(token: &str) -> bool {
4490 let bytes = token.as_bytes();
4491 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4492 return false;
4493 }
4494 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4495 match bytes[2] {
4496 b'S' | b'M' => amount > 0 && amount % 60 != 0,
4501 b'H' => amount > 0 && amount % 24 != 0,
4502 b'D' | b'W' | b'L' | b'Y' => amount > 0,
4503 b'U' => amount == 0,
4506 _ => false,
4507 }
4508}
4509
4510fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4511 let bytes = issue.as_bytes();
4512 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4513 if !valid_digits {
4514 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4515 }
4516 let hour = issue[0..2]
4517 .parse::<u8>()
4518 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4519 let minute = issue[2..4]
4520 .parse::<u8>()
4521 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4522 if hour <= 23 && minute <= 59 {
4523 Ok(())
4524 } else {
4525 Err(DataCatalogError::InvalidIssue(issue.to_string()))
4526 }
4527}
4528
4529fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4530 let bytes = station.as_bytes();
4531 let valid = bytes.len() == 9
4532 && bytes
4533 .iter()
4534 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4535 if valid {
4536 Ok(())
4537 } else {
4538 Err(DataCatalogError::InvalidStation(station.to_string()))
4539 }
4540}
4541
4542fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4543 validate_issue(issue)?;
4544 let hour = issue[0..2]
4545 .parse::<u16>()
4546 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4547 let minute = issue[2..4]
4548 .parse::<u16>()
4549 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4550 Ok(hour * 60 + minute)
4551}
4552
4553fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4554 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4555}
4556
4557fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4558 format!(
4559 "{}{:03}{}",
4560 date.year,
4561 date.day_of_year(),
4562 issue.unwrap_or("0000")
4563 )
4564}
4565
4566fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4567 Ok(match layout {
4568 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4569 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4570 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4571 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4572 ArchiveLayout::BkgBrdcYearDoy => {
4573 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4574 }
4575 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4576 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4577 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4578 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4579 })
4580}
4581
4582fn product_dir_path(
4583 center: AnalysisCenter,
4584 layout: ArchiveLayout,
4585 date: ProductDate,
4586) -> Result<String, DataCatalogError> {
4587 match center {
4588 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4589 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4590 _ => dir_path(layout, date),
4591 }
4592}
4593
4594fn uses_legacy_igs_final_name(
4595 center: AnalysisCenter,
4596 product_type: ProductType,
4597 date: ProductDate,
4598) -> Result<bool, DataCatalogError> {
4599 Ok(center == AnalysisCenter::Igs
4600 && product_type == ProductType::Sp3
4601 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4602}
4603
4604fn product_archive_compression(
4605 center: AnalysisCenter,
4606 product_type: ProductType,
4607 date: ProductDate,
4608 default: ArchiveCompression,
4609) -> Result<ArchiveCompression, DataCatalogError> {
4610 if uses_legacy_igs_final_name(center, product_type, date)? {
4611 Ok(ArchiveCompression::UnixCompress)
4612 } else {
4613 Ok(default)
4614 }
4615}
4616
4617fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4618 let (year, month, day) = civil_from_julian_day_number(jdn);
4619 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4620 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4621 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4622 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4623}
4624
4625#[cfg(test)]
4626mod content_start_tests {
4627 use super::*;
4628
4629 const GFZ_ISSUES: [&str; 8] = [
4630 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4631 ];
4632
4633 fn date(year: i32, month: u8, day: u8) -> ProductDate {
4634 ProductDate::new(year, month, day).expect("test date")
4635 }
4636
4637 fn offset(
4638 center: AnalysisCenter,
4639 product_date: ProductDate,
4640 sample: &str,
4641 issue: Option<&str>,
4642 ) -> i64 {
4643 let identity =
4644 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4645 .expect("cataloged SP3 identity");
4646 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4647 }
4648
4649 #[test]
4650 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4651 for issue in GFZ_ISSUES {
4652 assert_eq!(
4653 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4654 -86_400,
4655 "2022-09-06 issue {issue}"
4656 );
4657 }
4658 }
4659
4660 #[test]
4661 fn gfz_ultra_transition_is_cataloged_per_issue() {
4662 let day_seven = [
4663 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4664 ];
4665 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4666
4667 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4668 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4669 assert_eq!(
4670 offset(
4671 AnalysisCenter::GfzUlt,
4672 date(2022, 9, product_day),
4673 "05M",
4674 Some(issue)
4675 ),
4676 expected_offset,
4677 "2022-09-{product_day:02} issue {issue}"
4678 );
4679 }
4680 }
4681 }
4682
4683 #[test]
4684 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4685 for issue in GFZ_ISSUES {
4686 assert_eq!(
4687 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4688 0,
4689 "2022-09-09 issue {issue}"
4690 );
4691 }
4692
4693 let current = date(2026, 7, 20);
4694 let cases = [
4695 (AnalysisCenter::Igs, "15M", None),
4696 (AnalysisCenter::Esa, "05M", None),
4697 (AnalysisCenter::Cod, "05M", None),
4698 (AnalysisCenter::Gfz, "05M", None),
4699 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4700 (AnalysisCenter::CodUlt, "05M", Some("0000")),
4701 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4702 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4703 ];
4704 for (center, sample, issue) in cases {
4705 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4706 }
4707 }
4708
4709 #[test]
4710 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4711 assert_eq!(
4712 offset(
4713 AnalysisCenter::GfzUlt,
4714 date(2021, 5, 15),
4715 "15M",
4716 Some("0000")
4717 ),
4718 -86_400
4719 );
4720 assert_eq!(
4721 offset(
4722 AnalysisCenter::GfzUlt,
4723 date(2021, 5, 16),
4724 "05M",
4725 Some("0000")
4726 ),
4727 -86_400
4728 );
4729 }
4730
4731 #[test]
4732 fn public_content_start_query_enforces_center_issue_rules() {
4733 assert_eq!(
4734 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4735 Err(DataCatalogError::UnsupportedIssue {
4736 center: AnalysisCenter::GfzUlt,
4737 issue: "0130".to_owned(),
4738 })
4739 );
4740 assert_eq!(
4741 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4742 Err(DataCatalogError::UnexpectedIssue {
4743 center: AnalysisCenter::Gfz,
4744 })
4745 );
4746 assert_eq!(
4747 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4748 Err(DataCatalogError::MissingIssue {
4749 center: AnalysisCenter::GfzUlt,
4750 })
4751 );
4752 }
4753
4754 #[test]
4759 fn listing_index_keeps_distinct_paths_apart_under_total_hash_collision() {
4760 fn collide_everything(_path: &str) -> u64 {
4761 0
4762 }
4763
4764 let mut seen: Vec<PublishedObject> = Vec::new();
4765 let mut index = ListingIndex::default();
4766 for (path, observed_at) in [
4767 ("CODE/a.SP3", None),
4768 ("CODE/b.SP3", Some("2026-01-02 03:04:05".to_string())),
4769 ("CODE/c.SP3", None),
4770 ] {
4771 index.push(&mut seen, collide_everything, path.to_string(), observed_at);
4772 }
4773 assert_eq!(seen.len(), 3, "distinct paths merged under collision");
4774
4775 index.push(
4777 &mut seen,
4778 collide_everything,
4779 "CODE/a.SP3".to_string(),
4780 Some("2026-05-05 05:05:05".to_string()),
4781 );
4782 index.push(
4783 &mut seen,
4784 collide_everything,
4785 "CODE/b.SP3".to_string(),
4786 Some("2026-09-09 09:09:09".to_string()),
4787 );
4788 assert_eq!(seen.len(), 3, "repeat was not merged");
4789
4790 let paths: Vec<&str> = seen.iter().map(|object| object.path.as_str()).collect();
4791 assert_eq!(paths, ["CODE/a.SP3", "CODE/b.SP3", "CODE/c.SP3"]);
4792 assert_eq!(seen[0].observed_at.as_deref(), Some("2026-05-05 05:05:05"));
4793 assert_eq!(seen[1].observed_at.as_deref(), Some("2026-01-02 03:04:05"));
4794 assert_eq!(seen[2].observed_at, None);
4795 }
4796}