1use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11
12use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
13use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
14use crate::astro::time::model::TimeScale;
15use crate::astro::time::scales::julian_day_number;
16use crate::terrain;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum AnalysisCenter {
21 Igs,
23 CodRap,
25 CodPrd1,
27 CodPrd2,
29 Esa,
31 Cod,
33 Gfz,
35 IgsUlt,
37 CodUlt,
39 EsaUlt,
41 GfzUlt,
43 WumNrt,
45}
46
47impl AnalysisCenter {
48 #[must_use]
50 pub const fn code(self) -> &'static str {
51 match self {
52 Self::Igs => "igs",
53 Self::CodRap => "cod_rap",
54 Self::CodPrd1 => "cod_prd1",
55 Self::CodPrd2 => "cod_prd2",
56 Self::Esa => "esa",
57 Self::Cod => "cod",
58 Self::Gfz => "gfz",
59 Self::IgsUlt => "igs_ult",
60 Self::CodUlt => "cod_ult",
61 Self::EsaUlt => "esa_ult",
62 Self::GfzUlt => "gfz_ult",
63 Self::WumNrt => "wum_nrt",
64 }
65 }
66
67 #[must_use]
69 pub fn from_code(code: &str) -> Option<Self> {
70 match code {
71 "igs" => Some(Self::Igs),
72 "cod_rap" => Some(Self::CodRap),
73 "cod_prd1" => Some(Self::CodPrd1),
74 "cod_prd2" => Some(Self::CodPrd2),
75 "esa" => Some(Self::Esa),
76 "cod" => Some(Self::Cod),
77 "gfz" => Some(Self::Gfz),
78 "igs_ult" => Some(Self::IgsUlt),
79 "cod_ult" => Some(Self::CodUlt),
80 "esa_ult" => Some(Self::EsaUlt),
81 "gfz_ult" => Some(Self::GfzUlt),
82 "wum_nrt" => Some(Self::WumNrt),
83 _ => None,
84 }
85 }
86
87 #[must_use]
89 pub const fn publisher(self) -> ProductPublisher {
90 match self {
91 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
92 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
93 ProductPublisher::Code
94 }
95 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
96 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
97 Self::WumNrt => ProductPublisher::Whu,
98 }
99 }
100
101 #[must_use]
107 pub const fn solution_class(self) -> SolutionClass {
108 match self {
109 Self::Igs => SolutionClass::Broadcast,
110 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
111 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
112 Self::Esa | Self::Cod => SolutionClass::Final,
113 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
114 Self::WumNrt => SolutionClass::NearRealTime,
115 }
116 }
117
118 #[must_use]
120 pub const fn prediction_horizon_days(self) -> Option<u8> {
121 match self {
122 Self::CodPrd1 => Some(1),
123 Self::CodPrd2 => Some(2),
124 _ => None,
125 }
126 }
127}
128
129impl fmt::Display for AnalysisCenter {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 f.write_str(self.code())
132 }
133}
134
135impl FromStr for AnalysisCenter {
136 type Err = DataCatalogError;
137
138 fn from_str(s: &str) -> Result<Self, Self::Err> {
139 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
145pub enum ProductType {
146 Sp3,
148 Clk,
150 Nav,
152 Ionex,
154}
155
156impl ProductType {
157 #[must_use]
159 pub const fn code(self) -> &'static str {
160 match self {
161 Self::Sp3 => "sp3",
162 Self::Clk => "clk",
163 Self::Nav => "nav",
164 Self::Ionex => "ionex",
165 }
166 }
167
168 #[must_use]
170 pub fn from_code(code: &str) -> Option<Self> {
171 match code {
172 "sp3" => Some(Self::Sp3),
173 "clk" => Some(Self::Clk),
174 "nav" => Some(Self::Nav),
175 "ionex" => Some(Self::Ionex),
176 _ => None,
177 }
178 }
179}
180
181impl fmt::Display for ProductType {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 f.write_str(self.code())
184 }
185}
186
187impl FromStr for ProductType {
188 type Err = DataCatalogError;
189
190 fn from_str(s: &str) -> Result<Self, Self::Err> {
191 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
201pub enum ProductPublisher {
202 Igs,
204 Code,
206 Esa,
208 Gfz,
210 Whu,
212}
213
214impl ProductPublisher {
215 #[must_use]
217 pub const fn code(self) -> &'static str {
218 match self {
219 Self::Igs => "IGS",
220 Self::Code => "COD",
221 Self::Esa => "ESA",
222 Self::Gfz => "GFZ",
223 Self::Whu => "WUM",
224 }
225 }
226}
227
228impl fmt::Display for ProductPublisher {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.write_str(self.code())
231 }
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
236pub enum SolutionClass {
237 Final,
239 Rapid,
241 UltraRapid,
243 Predicted,
245 NearRealTime,
247 Broadcast,
249}
250
251impl SolutionClass {
252 #[must_use]
254 pub const fn code(self) -> &'static str {
255 match self {
256 Self::Final => "final",
257 Self::Rapid => "rapid",
258 Self::UltraRapid => "ultra_rapid",
259 Self::Predicted => "predicted",
260 Self::NearRealTime => "near_real_time",
261 Self::Broadcast => "broadcast",
262 }
263 }
264
265 #[must_use]
267 pub const fn filename_token(self) -> Option<&'static str> {
268 match self {
269 Self::Final => Some("FIN"),
270 Self::Rapid => Some("RAP"),
271 Self::UltraRapid => Some("ULT"),
272 Self::Predicted => Some("PRD"),
273 Self::NearRealTime => Some("NRT"),
274 Self::Broadcast => None,
275 }
276 }
277}
278
279impl fmt::Display for SolutionClass {
280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281 f.write_str(self.code())
282 }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
287pub enum ProductCampaign {
288 Operational,
290 MultiGnss,
292 MultiGnssExperiment,
294 Broadcast,
296}
297
298impl ProductCampaign {
299 #[must_use]
301 pub const fn code(self) -> &'static str {
302 match self {
303 Self::Operational => "OPS",
304 Self::MultiGnss => "MGN",
305 Self::MultiGnssExperiment => "MGX",
306 Self::Broadcast => "BRD",
307 }
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
313pub enum ProductFormat {
314 Sp3,
316 Ionex,
318 RinexClock,
320 RinexNavigation,
322}
323
324impl ProductFormat {
325 #[must_use]
327 pub const fn code(self) -> &'static str {
328 match self {
329 Self::Sp3 => "SP3",
330 Self::Ionex => "IONEX",
331 Self::RinexClock => "RINEX_CLK",
332 Self::RinexNavigation => "RINEX_NAV",
333 }
334 }
335}
336
337#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
342pub enum DistributionSource {
343 Direct,
345 NasaCddis,
347 LocalFile,
349 InMemory,
351}
352
353impl DistributionSource {
354 #[must_use]
356 pub const fn code(self) -> &'static str {
357 match self {
358 Self::Direct => "direct",
359 Self::NasaCddis => "nasa_cddis",
360 Self::LocalFile => "local_file",
361 Self::InMemory => "in_memory",
362 }
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
368pub enum SpaceWeatherProduct {
369 All,
371 Last5Years,
373}
374
375impl SpaceWeatherProduct {
376 #[must_use]
378 pub const fn code(self) -> &'static str {
379 match self {
380 Self::All => "sw_all",
381 Self::Last5Years => "sw_last5",
382 }
383 }
384
385 #[must_use]
387 pub fn from_code(code: &str) -> Option<Self> {
388 match code {
389 "sw_all" => Some(Self::All),
390 "sw_last5" => Some(Self::Last5Years),
391 _ => None,
392 }
393 }
394}
395
396impl fmt::Display for SpaceWeatherProduct {
397 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
398 f.write_str(self.code())
399 }
400}
401
402impl FromStr for SpaceWeatherProduct {
403 type Err = DataCatalogError;
404
405 fn from_str(s: &str) -> Result<Self, Self::Err> {
406 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
407 }
408}
409
410#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
412pub enum ArchiveProtocol {
413 Http,
415 Https,
417 Ftp,
426}
427
428impl ArchiveProtocol {
429 #[must_use]
431 pub const fn as_str(self) -> &'static str {
432 match self {
433 Self::Http => "http",
434 Self::Https => "https",
435 Self::Ftp => "ftp",
436 }
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
442pub enum ArchiveCompression {
443 Gzip,
445 UnixCompress,
447 None,
449}
450
451impl ArchiveCompression {
452 #[must_use]
454 pub const fn as_str(self) -> &'static str {
455 match self {
456 Self::Gzip => "gzip",
457 Self::UnixCompress => "unix_compress",
458 Self::None => "none",
459 }
460 }
461
462 const fn suffix(self) -> &'static str {
463 match self {
464 Self::Gzip => ".gz",
465 Self::UnixCompress => ".Z",
466 Self::None => "",
467 }
468 }
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
473pub enum ArchiveLayout {
474 GfzRapidWeek,
476 GfzUltraWeek,
478 GpsWeek,
480 BkgProductsWeek,
482 BkgBrdcYearDoy,
484 BkgObsYearDoy,
486 AiubCodeMgexYear,
488 AiubCodeYear,
490 AiubCodeRoot,
492}
493
494#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum ProductFilenameKind {
497 Sampled,
499 Nav,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub struct ProductTypeConvention {
506 pub product_type: ProductType,
508 pub content_code: &'static str,
510 pub extension: &'static str,
512 pub kind: ProductFilenameKind,
514}
515
516#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518pub struct CenterProductConvention {
519 pub product_type: ProductType,
521 pub token: &'static str,
523 pub layout: ArchiveLayout,
525 pub span: &'static str,
527 pub default_sample: &'static str,
529 pub compression: ArchiveCompression,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub struct CenterCatalogEntry {
536 pub center: AnalysisCenter,
538 pub code: &'static str,
540 pub protocol: ArchiveProtocol,
542 pub host: &'static str,
544 pub root_url: &'static str,
546 pub products: &'static [CenterProductConvention],
548 pub issues: &'static [&'static str],
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
554pub struct TerrainSourceEntry {
555 pub protocol: ArchiveProtocol,
557 pub host: &'static str,
559 pub compression: ArchiveCompression,
561 pub root_url: &'static str,
563}
564
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
567pub struct SpaceWeatherSourceEntry {
568 pub protocol: ArchiveProtocol,
570 pub host: &'static str,
572 pub compression: ArchiveCompression,
574 pub root_url: &'static str,
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
580pub struct NoOpenMirrorProduct {
581 pub center: &'static str,
583 pub product_type: &'static str,
585}
586
587const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
588 ProductTypeConvention {
589 product_type: ProductType::Sp3,
590 content_code: "ORB",
591 extension: "SP3",
592 kind: ProductFilenameKind::Sampled,
593 },
594 ProductTypeConvention {
595 product_type: ProductType::Clk,
596 content_code: "CLK",
597 extension: "CLK",
598 kind: ProductFilenameKind::Sampled,
599 },
600 ProductTypeConvention {
601 product_type: ProductType::Nav,
602 content_code: "MN",
603 extension: "rnx",
604 kind: ProductFilenameKind::Nav,
605 },
606 ProductTypeConvention {
607 product_type: ProductType::Ionex,
608 content_code: "GIM",
609 extension: "INX",
610 kind: ProductFilenameKind::Sampled,
611 },
612];
613
614const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
615 product_type: ProductType::Ionex,
616 token: "COD0OPSRAP",
617 layout: ArchiveLayout::AiubCodeRoot,
618 span: "01D",
619 default_sample: "01H",
620 compression: ArchiveCompression::Gzip,
621}];
622
623const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
624 product_type: ProductType::Ionex,
625 token: "COD0OPSPRD",
626 layout: ArchiveLayout::AiubCodeRoot,
627 span: "01D",
628 default_sample: "01H",
629 compression: ArchiveCompression::Gzip,
630}];
631
632const WUM_NRT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
643 product_type: ProductType::Sp3,
644 token: "WUM0MGXNRT",
645 layout: ArchiveLayout::GpsWeek,
646 span: "02D",
647 default_sample: "05M",
648 compression: ArchiveCompression::Gzip,
649}];
650
651const WUM_NRT_ISSUES: [&str; 24] = [
653 "0000", "0100", "0200", "0300", "0400", "0500", "0600", "0700", "0800", "0900", "1000", "1100",
654 "1200", "1300", "1400", "1500", "1600", "1700", "1800", "1900", "2000", "2100", "2200", "2300",
655];
656
657const ESA_PRODUCTS: [CenterProductConvention; 3] = [
658 CenterProductConvention {
659 product_type: ProductType::Sp3,
660 token: "ESA0MGNFIN",
661 layout: ArchiveLayout::GpsWeek,
662 span: "01D",
663 default_sample: "05M",
664 compression: ArchiveCompression::Gzip,
665 },
666 CenterProductConvention {
667 product_type: ProductType::Clk,
668 token: "ESA0MGNFIN",
669 layout: ArchiveLayout::GpsWeek,
670 span: "01D",
671 default_sample: "30S",
672 compression: ArchiveCompression::Gzip,
673 },
674 CenterProductConvention {
675 product_type: ProductType::Ionex,
676 token: "ESA0OPSFIN",
677 layout: ArchiveLayout::GpsWeek,
678 span: "01D",
679 default_sample: "02H",
680 compression: ArchiveCompression::Gzip,
681 },
682];
683
684const COD_PRODUCTS: [CenterProductConvention; 3] = [
685 CenterProductConvention {
686 product_type: ProductType::Sp3,
687 token: "COD0MGXFIN",
688 layout: ArchiveLayout::AiubCodeMgexYear,
689 span: "01D",
690 default_sample: "05M",
691 compression: ArchiveCompression::Gzip,
692 },
693 CenterProductConvention {
694 product_type: ProductType::Clk,
695 token: "COD0MGXFIN",
696 layout: ArchiveLayout::AiubCodeMgexYear,
697 span: "01D",
698 default_sample: "30S",
699 compression: ArchiveCompression::Gzip,
700 },
701 CenterProductConvention {
702 product_type: ProductType::Ionex,
703 token: "COD0OPSFIN",
704 layout: ArchiveLayout::AiubCodeYear,
705 span: "01D",
706 default_sample: "01H",
707 compression: ArchiveCompression::Gzip,
708 },
709];
710
711const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
712 CenterProductConvention {
713 product_type: ProductType::Sp3,
714 token: "GFZ0OPSRAP",
715 layout: ArchiveLayout::GfzRapidWeek,
716 span: "01D",
717 default_sample: "05M",
718 compression: ArchiveCompression::Gzip,
719 },
720 CenterProductConvention {
721 product_type: ProductType::Clk,
722 token: "GFZ0OPSRAP",
723 layout: ArchiveLayout::GfzRapidWeek,
724 span: "01D",
725 default_sample: "30S",
726 compression: ArchiveCompression::Gzip,
727 },
728];
729
730const IGS_PRODUCTS: [CenterProductConvention; 2] = [
731 CenterProductConvention {
732 product_type: ProductType::Sp3,
733 token: "IGS0OPSFIN",
734 layout: ArchiveLayout::BkgProductsWeek,
735 span: "01D",
736 default_sample: "15M",
737 compression: ArchiveCompression::Gzip,
738 },
739 CenterProductConvention {
740 product_type: ProductType::Nav,
741 token: "BRDC00WRD",
742 layout: ArchiveLayout::BkgBrdcYearDoy,
743 span: "01D",
744 default_sample: "01D",
745 compression: ArchiveCompression::Gzip,
746 },
747];
748
749const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
750 product_type: ProductType::Sp3,
751 token: "IGS0OPSULT",
752 layout: ArchiveLayout::BkgProductsWeek,
753 span: "02D",
754 default_sample: "15M",
755 compression: ArchiveCompression::Gzip,
756}];
757
758const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
759 product_type: ProductType::Sp3,
760 token: "COD0OPSULT",
761 layout: ArchiveLayout::AiubCodeRoot,
762 span: "01D",
763 default_sample: "05M",
764 compression: ArchiveCompression::None,
765}];
766
767const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
768 product_type: ProductType::Sp3,
769 token: "ESA0OPSULT",
770 layout: ArchiveLayout::GpsWeek,
771 span: "02D",
772 default_sample: "05M",
773 compression: ArchiveCompression::Gzip,
774}];
775
776const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
777 product_type: ProductType::Sp3,
778 token: "GFZ0OPSULT",
779 layout: ArchiveLayout::GfzUltraWeek,
780 span: "02D",
781 default_sample: "05M",
782 compression: ArchiveCompression::Gzip,
783}];
784
785const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
786const COD_ULT_ISSUES: [&str; 1] = ["0000"];
787const GFZ_ULT_ISSUES: [&str; 8] = [
788 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
789];
790
791const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
795
796const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
802
803const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
806
807const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
812 year: 2021,
813 month: 5,
814 day: 18,
815};
816
817const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
819 year: 2014,
820 month: 1,
821 day: 5,
822};
823
824const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
826 year: 2020,
827 month: 5,
828 day: 13,
829};
830
831const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
833 year: 2022,
834 month: 10,
835 day: 4,
836};
837
838const WUM_NRT_SP3_START_DATE: ProductDate = ProductDate {
846 year: 2024,
847 month: 7,
848 day: 3,
849};
850
851const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
853 year: 2025,
854 month: 2,
855 day: 2,
856};
857const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
858
859const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
861 year: 2020,
862 month: 10,
863 day: 6,
864};
865
866const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
868 year: 2021,
869 month: 5,
870 day: 16,
871};
872
873const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
879 year: 2021,
880 month: 5,
881 day: 15,
882};
883
884const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
891 year: 2022,
892 month: 9,
893 day: 7,
894};
895const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
896 year: 2022,
897 month: 9,
898 day: 8,
899};
900
901#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
908#[non_exhaustive]
909pub enum Sp3ContentStartConvention {
910 FilenameEpoch,
912 FilenameEpochMinusOneDay,
914}
915
916impl Sp3ContentStartConvention {
917 #[must_use]
919 pub const fn code(self) -> &'static str {
920 match self {
921 Self::FilenameEpoch => "filename_epoch",
922 Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
923 }
924 }
925
926 #[must_use]
929 pub const fn content_start_offset_s(self) -> i64 {
930 match self {
931 Self::FilenameEpoch => 0,
932 Self::FilenameEpochMinusOneDay => -86_400,
933 }
934 }
935}
936
937const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
944 (
945 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
946 "0000",
947 Sp3ContentStartConvention::FilenameEpoch,
948 ),
949 (
950 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
951 "0300",
952 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
953 ),
954 (
955 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
956 "0600",
957 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
958 ),
959 (
960 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
961 "0900",
962 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
963 ),
964 (
965 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
966 "1200",
967 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
968 ),
969 (
970 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
971 "1500",
972 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
973 ),
974 (
975 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
976 "1800",
977 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
978 ),
979 (
980 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
981 "2100",
982 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
983 ),
984 (
985 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
986 "0000",
987 Sp3ContentStartConvention::FilenameEpoch,
988 ),
989 (
990 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
991 "0300",
992 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
993 ),
994 (
995 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
996 "0600",
997 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
998 ),
999 (
1000 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1001 "0900",
1002 Sp3ContentStartConvention::FilenameEpoch,
1003 ),
1004 (
1005 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1006 "1200",
1007 Sp3ContentStartConvention::FilenameEpoch,
1008 ),
1009 (
1010 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1011 "1500",
1012 Sp3ContentStartConvention::FilenameEpoch,
1013 ),
1014 (
1015 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1016 "1800",
1017 Sp3ContentStartConvention::FilenameEpoch,
1018 ),
1019 (
1020 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1021 "2100",
1022 Sp3ContentStartConvention::FilenameEpoch,
1023 ),
1024];
1025
1026const CENTER_ORDER: [AnalysisCenter; 12] = [
1027 AnalysisCenter::CodRap,
1028 AnalysisCenter::CodPrd1,
1029 AnalysisCenter::CodPrd2,
1030 AnalysisCenter::Igs,
1031 AnalysisCenter::Esa,
1032 AnalysisCenter::Cod,
1033 AnalysisCenter::Gfz,
1034 AnalysisCenter::IgsUlt,
1035 AnalysisCenter::CodUlt,
1036 AnalysisCenter::EsaUlt,
1037 AnalysisCenter::GfzUlt,
1038 AnalysisCenter::WumNrt,
1039];
1040
1041const CATALOG: [CenterCatalogEntry; 12] = [
1042 CenterCatalogEntry {
1043 center: AnalysisCenter::CodRap,
1044 code: "cod_rap",
1045 protocol: ArchiveProtocol::Https,
1046 host: "www.aiub.unibe.ch",
1047 root_url: "https://www.aiub.unibe.ch/download",
1048 products: &COD_RAP_PRODUCTS,
1049 issues: &[],
1050 },
1051 CenterCatalogEntry {
1052 center: AnalysisCenter::CodPrd1,
1053 code: "cod_prd1",
1054 protocol: ArchiveProtocol::Https,
1055 host: "www.aiub.unibe.ch",
1056 root_url: "https://www.aiub.unibe.ch/download",
1057 products: &COD_PRD_PRODUCTS,
1058 issues: &[],
1059 },
1060 CenterCatalogEntry {
1061 center: AnalysisCenter::CodPrd2,
1062 code: "cod_prd2",
1063 protocol: ArchiveProtocol::Https,
1064 host: "www.aiub.unibe.ch",
1065 root_url: "https://www.aiub.unibe.ch/download",
1066 products: &COD_PRD_PRODUCTS,
1067 issues: &[],
1068 },
1069 CenterCatalogEntry {
1070 center: AnalysisCenter::Igs,
1071 code: "igs",
1072 protocol: ArchiveProtocol::Https,
1073 host: "igs.bkg.bund.de",
1074 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1075 products: &IGS_PRODUCTS,
1076 issues: &[],
1077 },
1078 CenterCatalogEntry {
1079 center: AnalysisCenter::Esa,
1080 code: "esa",
1081 protocol: ArchiveProtocol::Https,
1082 host: "navigation-office.esa.int",
1083 root_url: "https://navigation-office.esa.int/products/gnss-products",
1084 products: &ESA_PRODUCTS,
1085 issues: &[],
1086 },
1087 CenterCatalogEntry {
1088 center: AnalysisCenter::Cod,
1089 code: "cod",
1090 protocol: ArchiveProtocol::Https,
1091 host: "www.aiub.unibe.ch",
1092 root_url: "https://www.aiub.unibe.ch/download",
1093 products: &COD_PRODUCTS,
1094 issues: &[],
1095 },
1096 CenterCatalogEntry {
1097 center: AnalysisCenter::Gfz,
1098 code: "gfz",
1099 protocol: ArchiveProtocol::Https,
1100 host: "isdc-data.gfz.de",
1101 root_url: "https://isdc-data.gfz.de/gnss/products",
1102 products: &GFZ_PRODUCTS,
1103 issues: &[],
1104 },
1105 CenterCatalogEntry {
1106 center: AnalysisCenter::IgsUlt,
1107 code: "igs_ult",
1108 protocol: ArchiveProtocol::Https,
1109 host: "igs.bkg.bund.de",
1110 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1111 products: &IGS_ULT_PRODUCTS,
1112 issues: &OPSULT_ISSUES,
1113 },
1114 CenterCatalogEntry {
1115 center: AnalysisCenter::CodUlt,
1116 code: "cod_ult",
1117 protocol: ArchiveProtocol::Https,
1118 host: "www.aiub.unibe.ch",
1119 root_url: "https://www.aiub.unibe.ch/download",
1123 products: &COD_ULT_PRODUCTS,
1124 issues: &COD_ULT_ISSUES,
1125 },
1126 CenterCatalogEntry {
1127 center: AnalysisCenter::EsaUlt,
1128 code: "esa_ult",
1129 protocol: ArchiveProtocol::Https,
1130 host: "navigation-office.esa.int",
1131 root_url: "https://navigation-office.esa.int/products/gnss-products",
1132 products: &ESA_ULT_PRODUCTS,
1133 issues: &OPSULT_ISSUES,
1134 },
1135 CenterCatalogEntry {
1136 center: AnalysisCenter::GfzUlt,
1137 code: "gfz_ult",
1138 protocol: ArchiveProtocol::Https,
1139 host: "isdc-data.gfz.de",
1140 root_url: "https://isdc-data.gfz.de/gnss/products",
1141 products: &GFZ_ULT_PRODUCTS,
1142 issues: &GFZ_ULT_ISSUES,
1143 },
1144 CenterCatalogEntry {
1145 center: AnalysisCenter::WumNrt,
1146 code: "wum_nrt",
1147 protocol: ArchiveProtocol::Ftp,
1148 host: "igs.gnsswhu.cn",
1149 root_url: "ftp://igs.gnsswhu.cn/pub/gps/products/mgex",
1150 products: &WUM_NRT_PRODUCTS,
1151 issues: &WUM_NRT_ISSUES,
1152 },
1153];
1154
1155const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1156 protocol: ArchiveProtocol::Https,
1157 host: "s3.amazonaws.com",
1158 compression: ArchiveCompression::Gzip,
1159 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1160};
1161
1162const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1163 protocol: ArchiveProtocol::Https,
1164 host: "celestrak.org",
1165 compression: ArchiveCompression::None,
1166 root_url: "https://celestrak.org/SpaceData",
1167};
1168
1169const ALLOWED_HOSTS: [&str; 11] = [
1170 "www.aiub.unibe.ch",
1171 "download.aiub.unibe.ch",
1172 "zhw-b.s3.cloud.switch.ch",
1173 "navigation-office.esa.int",
1174 "isdc-data.gfz.de",
1175 "igs.bkg.bund.de",
1176 "igs.gnsswhu.cn",
1177 "s3.amazonaws.com",
1178 "celestrak.org",
1179 "cddis.nasa.gov",
1180 "urs.earthdata.nasa.gov",
1181];
1182
1183const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1184 NoOpenMirrorProduct {
1185 center: "grg",
1186 product_type: "sp3",
1187 },
1188 NoOpenMirrorProduct {
1189 center: "grg",
1190 product_type: "clk",
1191 },
1192 NoOpenMirrorProduct {
1193 center: "wum",
1194 product_type: "sp3",
1195 },
1196 NoOpenMirrorProduct {
1197 center: "wum",
1198 product_type: "clk",
1199 },
1200 NoOpenMirrorProduct {
1201 center: "grg_ult",
1202 product_type: "sp3",
1203 },
1204 NoOpenMirrorProduct {
1205 center: "grg_ult",
1206 product_type: "clk",
1207 },
1208 NoOpenMirrorProduct {
1209 center: "igs",
1210 product_type: "ionex",
1211 },
1212];
1213
1214#[derive(Debug, Clone, PartialEq, Eq)]
1216pub enum DataCatalogError {
1217 UnknownCenter(String),
1219 UnknownProductType(String),
1221 UnsupportedProduct {
1223 center: AnalysisCenter,
1225 product_type: ProductType,
1227 },
1228 UnsupportedDistribution {
1230 source: DistributionSource,
1232 product_type: ProductType,
1234 },
1235 UnsupportedProductEra {
1237 center: AnalysisCenter,
1239 product_type: ProductType,
1241 date: ProductDate,
1243 },
1244 UnsupportedDistributionEra {
1246 source: DistributionSource,
1248 center: AnalysisCenter,
1250 product_type: ProductType,
1252 date: ProductDate,
1254 },
1255 NoDistributionSources,
1257 InvalidOfficialFilename(String),
1259 InconsistentProductIdentity {
1261 field: &'static str,
1263 },
1264 NoOpenMirror {
1266 center: String,
1268 product_type: String,
1270 },
1271 InvalidDate {
1273 year: i32,
1275 month: u8,
1277 day: u8,
1279 },
1280 DateOutOfRange,
1282 DateBeforeGpsEpoch(ProductDate),
1284 InvalidGpsDayOfWeek(u8),
1286 InvalidSample(String),
1288 UnsupportedSample {
1290 center: AnalysisCenter,
1292 product_type: ProductType,
1294 sample: String,
1296 },
1297 InvalidSpan(String),
1299 InvalidIssue(String),
1301 MissingIssue {
1303 center: AnalysisCenter,
1305 },
1306 UnexpectedIssue {
1308 center: AnalysisCenter,
1310 },
1311 UnsupportedIssue {
1313 center: AnalysisCenter,
1315 issue: String,
1317 },
1318 InvalidDateTime {
1320 hour: u8,
1322 minute: u8,
1324 second: u8,
1326 },
1327 NoUltraIssue,
1329 NoAvailableUltraIssue,
1331 UnsupportedNominalSchedule {
1334 center: AnalysisCenter,
1336 product_type: ProductType,
1338 },
1339 UnrecognizedArchiveListing {
1343 reason: String,
1345 },
1346 InvalidStation(String),
1348 InvalidCoordinate {
1350 lat_deg_bits: u64,
1352 lon_deg_bits: u64,
1354 },
1355 InvalidTileIndex {
1357 lat_index: i32,
1359 lon_index: i32,
1361 },
1362 InvalidTileId(String),
1364}
1365
1366impl fmt::Display for DataCatalogError {
1367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1368 match self {
1369 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1370 Self::UnknownProductType(product_type) => {
1371 write!(f, "unknown product type {product_type:?}")
1372 }
1373 Self::UnsupportedProduct {
1374 center,
1375 product_type,
1376 } => write!(f, "{center} does not serve {product_type}"),
1377 Self::UnsupportedDistribution {
1378 source,
1379 product_type,
1380 } => write!(
1381 f,
1382 "distributor {} does not serve {product_type}",
1383 source.code()
1384 ),
1385 Self::UnsupportedProductEra {
1386 center,
1387 product_type,
1388 date,
1389 } => write!(
1390 f,
1391 "{center}/{product_type} has no cataloged naming convention for {date}"
1392 ),
1393 Self::UnsupportedDistributionEra {
1394 source,
1395 center,
1396 product_type,
1397 date,
1398 } => write!(
1399 f,
1400 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1401 source.code()
1402 ),
1403 Self::NoDistributionSources => {
1404 write!(f, "exact product request has no distributors")
1405 }
1406 Self::InvalidOfficialFilename(filename) => {
1407 write!(f, "invalid official product filename {filename:?}")
1408 }
1409 Self::InconsistentProductIdentity { field } => {
1410 write!(
1411 f,
1412 "product identity field {field:?} disagrees with its official filename"
1413 )
1414 }
1415 Self::NoOpenMirror {
1416 center,
1417 product_type,
1418 } => write!(f, "{center}/{product_type} has no open mirror"),
1419 Self::InvalidDate { year, month, day } => {
1420 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1421 }
1422 Self::DateOutOfRange => write!(f, "product date is out of range"),
1423 Self::DateBeforeGpsEpoch(date) => {
1424 write!(f, "product date {date} is before the GPS week epoch")
1425 }
1426 Self::InvalidGpsDayOfWeek(day) => {
1427 write!(f, "invalid GPS day-of-week {day}")
1428 }
1429 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1430 Self::UnsupportedSample {
1431 center,
1432 product_type,
1433 sample,
1434 } => write!(
1435 f,
1436 "{center}/{product_type} does not publish sample interval {sample:?}"
1437 ),
1438 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1439 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1440 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1441 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1442 Self::UnsupportedIssue { center, issue } => {
1443 write!(f, "{center} does not publish issue {issue:?}")
1444 }
1445 Self::InvalidDateTime {
1446 hour,
1447 minute,
1448 second,
1449 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1450 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1451 Self::NoAvailableUltraIssue => {
1452 write!(f, "no available ultra-rapid issue at or before target")
1453 }
1454 Self::UnsupportedNominalSchedule {
1455 center,
1456 product_type,
1457 } => write!(
1458 f,
1459 "{center}/{product_type} has no nominal due-time schedule"
1460 ),
1461 Self::UnrecognizedArchiveListing { reason } => {
1462 write!(f, "unrecognized archive listing: {reason}")
1463 }
1464 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1465 Self::InvalidCoordinate {
1466 lat_deg_bits,
1467 lon_deg_bits,
1468 } => write!(
1469 f,
1470 "invalid terrain coordinate lat={} lon={}",
1471 f64::from_bits(*lat_deg_bits),
1472 f64::from_bits(*lon_deg_bits)
1473 ),
1474 Self::InvalidTileIndex {
1475 lat_index,
1476 lon_index,
1477 } => write!(
1478 f,
1479 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1480 ),
1481 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1482 }
1483 }
1484}
1485
1486impl std::error::Error for DataCatalogError {}
1487
1488#[derive(Debug, Clone, PartialEq, Eq)]
1490pub enum HgtConversionError {
1491 BadLength {
1493 expected: usize,
1495 got: usize,
1497 },
1498 InvalidTileIndex {
1500 lat_index: i32,
1502 lon_index: i32,
1504 },
1505}
1506
1507impl fmt::Display for HgtConversionError {
1508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1509 match self {
1510 Self::BadLength { expected, got } => {
1511 write!(
1512 f,
1513 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1514 )
1515 }
1516 Self::InvalidTileIndex {
1517 lat_index,
1518 lon_index,
1519 } => write!(
1520 f,
1521 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1522 ),
1523 }
1524 }
1525}
1526
1527impl std::error::Error for HgtConversionError {}
1528
1529const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1530const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1531const MIN_TERRAIN_LON_INDEX: i32 = -180;
1532const MAX_TERRAIN_LON_INDEX: i32 = 179;
1533const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1534const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1535const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1536const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1537const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1538const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1539const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1540const DTED_SRTM1_LEN: usize =
1541 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1542
1543#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1545pub struct ProductDate {
1546 pub year: i32,
1548 pub month: u8,
1550 pub day: u8,
1552}
1553
1554impl ProductDate {
1555 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1557 let days = days_in_month(i64::from(year), i64::from(month));
1558 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1559 return Err(DataCatalogError::InvalidDate { year, month, day });
1560 }
1561 Ok(Self { year, month, day })
1562 }
1563
1564 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1566 if day_of_week > 6 {
1567 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1568 }
1569 let epoch_jdn =
1570 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1571 let offset_days = i64::from(week)
1572 .checked_mul(7)
1573 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1574 .ok_or(DataCatalogError::DateOutOfRange)?;
1575 product_date_from_jdn(
1576 epoch_jdn
1577 .checked_add(offset_days)
1578 .ok_or(DataCatalogError::DateOutOfRange)?,
1579 )
1580 }
1581
1582 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1584 week_from_calendar(
1585 TimeScale::Gpst,
1586 i64::from(self.year),
1587 i64::from(self.month),
1588 i64::from(self.day),
1589 )
1590 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1591 }
1592
1593 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1595 let epoch_jdn =
1596 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1597 let days = self
1598 .julian_day_number()
1599 .checked_sub(epoch_jdn)
1600 .ok_or(DataCatalogError::DateOutOfRange)?;
1601 if days < 0 {
1602 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1603 }
1604 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1605 }
1606
1607 #[must_use]
1609 pub fn day_of_year(self) -> u16 {
1610 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1611 }
1612
1613 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1614 product_date_from_jdn(
1615 self.julian_day_number()
1616 .checked_add(days)
1617 .ok_or(DataCatalogError::DateOutOfRange)?,
1618 )
1619 }
1620
1621 fn julian_day_number(self) -> i64 {
1622 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1623 }
1624}
1625
1626impl fmt::Display for ProductDate {
1627 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1628 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1629 }
1630}
1631
1632#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1634pub struct ProductDateTime {
1635 pub date: ProductDate,
1637 pub hour: u8,
1639 pub minute: u8,
1641 pub second: u8,
1643}
1644
1645impl ProductDateTime {
1646 pub fn new(
1648 date: ProductDate,
1649 hour: u8,
1650 minute: u8,
1651 second: u8,
1652 ) -> Result<Self, DataCatalogError> {
1653 if hour > 23 || minute > 59 || second > 59 {
1654 return Err(DataCatalogError::InvalidDateTime {
1655 hour,
1656 minute,
1657 second,
1658 });
1659 }
1660 Ok(Self {
1661 date,
1662 hour,
1663 minute,
1664 second,
1665 })
1666 }
1667
1668 fn ordering_minutes(self) -> i64 {
1669 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1670 }
1671
1672 fn ordering_seconds(self) -> i64 {
1673 self.date.julian_day_number() * 86_400
1674 + i64::from(self.hour) * 3_600
1675 + i64::from(self.minute) * 60
1676 + i64::from(self.second)
1677 }
1678}
1679
1680impl fmt::Display for ProductDateTime {
1681 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1682 write!(
1683 f,
1684 "{}T{:02}:{:02}:{:02}Z",
1685 self.date, self.hour, self.minute, self.second
1686 )
1687 }
1688}
1689
1690#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1692pub struct NominalCoverageInterval {
1693 pub from: ProductDateTime,
1695 pub until: ProductDateTime,
1697}
1698
1699#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1705pub struct NominalCoverage {
1706 pub observed: Option<NominalCoverageInterval>,
1708 pub predicted: Option<NominalCoverageInterval>,
1710}
1711
1712#[derive(Debug, Clone, PartialEq, Eq)]
1714pub struct NominalIssue {
1715 pub identity: ProductIdentity,
1717 pub due_at: ProductDateTime,
1719 pub covers: NominalCoverage,
1721}
1722
1723#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1725pub struct UltraIssue {
1726 pub date: ProductDate,
1728 pub issue: String,
1730}
1731
1732impl UltraIssue {
1733 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1735 validate_issue(issue)?;
1736 Ok(Self {
1737 date,
1738 issue: issue.to_string(),
1739 })
1740 }
1741}
1742
1743#[derive(Debug, Clone, PartialEq, Eq)]
1745pub struct UltraSp3Location {
1746 pub pattern: String,
1748 pub span: String,
1750 pub sample: String,
1752 pub filename: String,
1754 pub url: String,
1756 pub compression: ArchiveCompression,
1758}
1759
1760#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1766pub struct ProductIdentity {
1767 pub family: ProductType,
1769 pub analysis_center: AnalysisCenter,
1771 pub publisher: ProductPublisher,
1773 pub solution: SolutionClass,
1775 pub campaign: ProductCampaign,
1777 pub version: u8,
1779 pub date: ProductDate,
1785 pub issue: Option<String>,
1787 pub span: String,
1789 pub sample: String,
1791 pub official_filename: String,
1793 pub format: ProductFormat,
1795 pub format_version: Option<String>,
1801 pub prediction_horizon_days: Option<u8>,
1803}
1804
1805impl ProductIdentity {
1806 pub fn validate(&self) -> Result<(), DataCatalogError> {
1812 validate_official_filename(&self.official_filename)?;
1813 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1814 validate_sample(&self.sample)?;
1815 validate_span(&self.span)?;
1816 if let Some(issue) = self.issue.as_deref() {
1817 validate_issue(issue)?;
1818 }
1819
1820 let convention = product_convention(self.analysis_center, self.family)?;
1824 validate_product_date(self.analysis_center, self.family, self.date)?;
1825 if self.span != convention.span {
1826 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1827 }
1828 validate_catalog_sample(
1829 self.analysis_center,
1830 self.family,
1831 self.date,
1832 &self.sample,
1833 self.issue.as_deref(),
1834 )?;
1835
1836 if self.format != product_format(self.family) {
1837 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1838 }
1839
1840 if self
1841 .format_version
1842 .as_deref()
1843 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1844 {
1845 return Err(DataCatalogError::InconsistentProductIdentity {
1846 field: "format_version",
1847 });
1848 }
1849
1850 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1851 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1852 (_, SolutionClass::Predicted, _) => false,
1853 (_, _, None) => true,
1854 (_, _, Some(_)) => false,
1855 };
1856 if !horizon_valid {
1857 return Err(DataCatalogError::InconsistentProductIdentity {
1858 field: "prediction_horizon_days",
1859 });
1860 }
1861 let descriptor = product_type_convention(self.family);
1862 let legacy_igs_final =
1863 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1864 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1865 let entry = center_catalog(self.analysis_center)
1866 .expect("validated analysis center has a catalog entry");
1867 let issue_valid = if entry.issues.is_empty() {
1868 self.issue.as_deref() == Some("0000")
1869 } else {
1870 self.issue
1871 .as_deref()
1872 .is_some_and(|issue| entry.issues.contains(&issue))
1873 };
1874 if !issue_valid {
1875 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1876 }
1877 }
1878 let expected = if legacy_igs_final {
1879 let fields_valid = self.publisher == ProductPublisher::Igs
1880 && self.solution == SolutionClass::Final
1881 && self.campaign == ProductCampaign::Operational
1882 && self.version == 0
1883 && self.issue.as_deref() == Some("0000")
1884 && self.span == convention.span
1885 && self.sample == convention.default_sample;
1886 if !fields_valid {
1887 return Err(DataCatalogError::InconsistentProductIdentity {
1888 field: "legacy_igs_final",
1889 });
1890 }
1891 format!(
1892 "igs{:04}{}.sp3",
1893 self.date.gps_week()?,
1894 self.date.gps_day_of_week()?
1895 )
1896 } else {
1897 match descriptor.kind {
1898 ProductFilenameKind::Sampled => {
1899 let solution_token = self.solution.filename_token().ok_or(
1900 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1901 )?;
1902 format!(
1903 "{}{}{}{}_{}_{}_{}_{}.{}",
1904 self.publisher.code(),
1905 self.version,
1906 self.campaign.code(),
1907 solution_token,
1908 date_block(self.date, self.issue.as_deref()),
1909 self.span,
1910 self.sample,
1911 descriptor.content_code,
1912 descriptor.extension
1913 )
1914 }
1915 ProductFilenameKind::Nav => {
1916 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1917 && self.solution == SolutionClass::Broadcast
1918 && self.campaign == ProductCampaign::Broadcast
1919 && self.version == 0
1920 && self.issue.is_none()
1921 && self.span == "01D"
1922 && self.sample == "01D";
1923 if !nav_fields_valid {
1924 return Err(DataCatalogError::InconsistentProductIdentity {
1925 field: "broadcast_navigation",
1926 });
1927 }
1928 format!(
1929 "BRDC00WRD_R_{}_{}_{}.{}",
1930 date_block(self.date, None),
1931 self.span,
1932 descriptor.content_code,
1933 descriptor.extension
1934 )
1935 }
1936 }
1937 };
1938 if expected != self.official_filename {
1939 return Err(DataCatalogError::InconsistentProductIdentity {
1940 field: "official_filename",
1941 });
1942 }
1943 if self.publisher != self.analysis_center.publisher()
1944 || self.solution != product_solution_class(self.analysis_center, self.family)?
1945 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1946 {
1947 return Err(DataCatalogError::InconsistentProductIdentity {
1948 field: "analysis_center",
1949 });
1950 }
1951
1952 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1953 let expected_catalog_filename = format!(
1954 "{}_{}_{}_{}_{}.{}",
1955 convention.token,
1956 date_block(self.date, self.issue.as_deref()),
1957 self.span,
1958 self.sample,
1959 descriptor.content_code,
1960 descriptor.extension
1961 );
1962 if expected_catalog_filename != self.official_filename {
1963 return Err(DataCatalogError::InconsistentProductIdentity {
1964 field: "analysis_center",
1965 });
1966 }
1967 }
1968 Ok(())
1969 }
1970
1971 pub fn key(&self) -> Result<String, DataCatalogError> {
1973 use sha2::{Digest, Sha256};
1974
1975 let canonical = self.canonical_bytes()?;
1976 let digest = Sha256::digest(canonical);
1977 Ok(format!(
1978 "{}-{}-{}",
1979 self.publisher.code().to_ascii_lowercase(),
1980 self.solution.code(),
1981 digest[..10]
1982 .iter()
1983 .map(|byte| format!("{byte:02x}"))
1984 .collect::<String>()
1985 ))
1986 }
1987
1988 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1994 self.validate()?;
1995 let date = format!(
1996 "{:04}-{:02}-{:02}",
1997 self.date.year, self.date.month, self.date.day
1998 );
1999 let version = self.version.to_string();
2000 let prediction = self
2001 .prediction_horizon_days
2002 .map(|days| days.to_string())
2003 .unwrap_or_default();
2004 let fields = [
2005 self.family.code(),
2006 self.analysis_center.code(),
2007 self.publisher.code(),
2008 self.solution.code(),
2009 self.campaign.code(),
2010 version.as_str(),
2011 date.as_str(),
2012 self.issue.as_deref().unwrap_or_default(),
2013 self.span.as_str(),
2014 self.sample.as_str(),
2015 self.official_filename.as_str(),
2016 self.format.code(),
2017 self.format_version.as_deref().unwrap_or_default(),
2018 prediction.as_str(),
2019 ];
2020 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
2021 return Err(DataCatalogError::InconsistentProductIdentity {
2022 field: "canonical_encoding",
2023 });
2024 }
2025 Ok(fields.join("\0").into_bytes())
2026 }
2027
2028 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
2030 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
2031 }
2032}
2033
2034pub(crate) fn exact_sp3_content_start_offset_s(
2040 identity: &ProductIdentity,
2041) -> Result<i64, DataCatalogError> {
2042 identity.validate()?;
2043 if identity.family != ProductType::Sp3 {
2044 return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
2045 }
2046
2047 let entry =
2048 center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
2049 let catalog_issue = if entry.issues.is_empty() {
2053 None
2054 } else {
2055 identity.issue.as_deref()
2056 };
2057 Ok(
2058 sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
2059 .content_start_offset_s(),
2060 )
2061}
2062
2063pub fn sp3_content_start_convention(
2071 center: AnalysisCenter,
2072 date: ProductDate,
2073 issue: Option<&str>,
2074) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2075 ProductDate::new(date.year, date.month, date.day)?;
2076 product_convention(center, ProductType::Sp3)?;
2077 validate_product_date(center, ProductType::Sp3, date)?;
2078 validate_issue_for_center(center, issue)?;
2079
2080 sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2081 DataCatalogError::UnsupportedIssue {
2082 center,
2083 issue: issue.unwrap_or_default().to_owned(),
2084 }
2085 })
2086}
2087
2088fn sp3_content_start_convention_inner(
2089 center: AnalysisCenter,
2090 date: ProductDate,
2091 issue: Option<&str>,
2092) -> Option<Sp3ContentStartConvention> {
2093 if center != AnalysisCenter::GfzUlt {
2094 return Some(Sp3ContentStartConvention::FilenameEpoch);
2095 }
2096 if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2097 return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2098 }
2099 if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2100 return Some(Sp3ContentStartConvention::FilenameEpoch);
2101 }
2102
2103 let issue = issue?;
2104 GFZ_ULTRA_START_TRANSITION
2105 .iter()
2106 .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2107 .map(|(_, _, convention)| *convention)
2108}
2109
2110#[derive(Debug, Clone, PartialEq, Eq)]
2112pub struct DistributionLocation {
2113 pub source: DistributionSource,
2115 pub original_url: Option<String>,
2117 pub archive_filename: String,
2119 pub compression: ArchiveCompression,
2121}
2122
2123#[derive(Debug, Clone, PartialEq, Eq)]
2125pub struct ProductRequest {
2126 pub identity: ProductIdentity,
2128 pub distributors: Vec<DistributionSource>,
2130}
2131
2132#[derive(Debug, Clone, PartialEq, Eq)]
2134pub enum ExactProductSetError {
2135 EmptyExpected,
2137 InvalidExpected {
2139 index: usize,
2141 source: DataCatalogError,
2143 },
2144 InvalidAvailable {
2146 index: usize,
2148 source: DataCatalogError,
2150 },
2151 Mismatch {
2153 missing: Vec<ProductIdentity>,
2155 unexpected: Vec<ProductIdentity>,
2157 duplicate_expected: Vec<ProductIdentity>,
2159 duplicate_available: Vec<ProductIdentity>,
2161 },
2162}
2163
2164impl fmt::Display for ExactProductSetError {
2165 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2166 match self {
2167 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2168 Self::InvalidExpected { index, source } => {
2169 write!(f, "expected product {index} is invalid: {source}")
2170 }
2171 Self::InvalidAvailable { index, source } => {
2172 write!(f, "available product {index} is invalid: {source}")
2173 }
2174 Self::Mismatch {
2175 missing,
2176 unexpected,
2177 duplicate_expected,
2178 duplicate_available,
2179 } => write!(
2180 f,
2181 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2182 identity_list(missing),
2183 identity_list(unexpected),
2184 identity_list(duplicate_expected),
2185 identity_list(duplicate_available),
2186 ),
2187 }
2188 }
2189}
2190
2191impl std::error::Error for ExactProductSetError {}
2192
2193pub fn validate_exact_product_set(
2207 expected: &[ProductIdentity],
2208 available: &[ProductIdentity],
2209) -> Result<(), ExactProductSetError> {
2210 if expected.is_empty() {
2211 return Err(ExactProductSetError::EmptyExpected);
2212 }
2213 for (index, identity) in expected.iter().enumerate() {
2214 identity
2215 .validate()
2216 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2217 }
2218 for (index, identity) in available.iter().enumerate() {
2219 identity
2220 .validate()
2221 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2222 }
2223
2224 let expected_counts = identity_counts(expected);
2225 let available_counts = identity_counts(available);
2226 let missing = unique_matching(expected, |identity| {
2227 !available_counts.contains_key(identity)
2228 });
2229 let unexpected = unique_matching(available, |identity| {
2230 !expected_counts.contains_key(identity)
2231 });
2232 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2233 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2234
2235 if missing.is_empty()
2236 && unexpected.is_empty()
2237 && duplicate_expected.is_empty()
2238 && duplicate_available.is_empty()
2239 {
2240 Ok(())
2241 } else {
2242 Err(ExactProductSetError::Mismatch {
2243 missing,
2244 unexpected,
2245 duplicate_expected,
2246 duplicate_available,
2247 })
2248 }
2249}
2250
2251fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2252 let mut counts = HashMap::with_capacity(identities.len());
2253 for identity in identities {
2254 *counts.entry(identity).or_insert(0) += 1;
2255 }
2256 counts
2257}
2258
2259fn unique_matching(
2260 identities: &[ProductIdentity],
2261 mut predicate: impl FnMut(&ProductIdentity) -> bool,
2262) -> Vec<ProductIdentity> {
2263 let mut seen = HashSet::with_capacity(identities.len());
2264 identities
2265 .iter()
2266 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2267 .cloned()
2268 .collect()
2269}
2270
2271fn identity_list(identities: &[ProductIdentity]) -> String {
2272 if identities.is_empty() {
2273 return "none".to_string();
2274 }
2275 identities
2276 .iter()
2277 .map(|identity| {
2278 identity
2279 .key()
2280 .unwrap_or_else(|_| identity.official_filename.clone())
2281 })
2282 .collect::<Vec<_>>()
2283 .join(", ")
2284}
2285
2286impl ProductRequest {
2287 pub fn new(
2289 identity: ProductIdentity,
2290 distributors: Vec<DistributionSource>,
2291 ) -> Result<Self, DataCatalogError> {
2292 if distributors.is_empty() {
2293 return Err(DataCatalogError::NoDistributionSources);
2294 }
2295 identity.validate()?;
2296 Ok(Self {
2297 identity,
2298 distributors,
2299 })
2300 }
2301}
2302
2303#[derive(Debug, Clone, PartialEq, Eq)]
2305pub struct ProductSpec {
2306 pub center: AnalysisCenter,
2308 pub product_type: ProductType,
2310 pub date: ProductDate,
2312 pub sample: String,
2314 pub issue: Option<String>,
2316}
2317
2318impl ProductSpec {
2319 pub fn new(
2321 center: AnalysisCenter,
2322 product_type: ProductType,
2323 date: ProductDate,
2324 sample: &str,
2325 issue: Option<&str>,
2326 ) -> Result<Self, DataCatalogError> {
2327 ProductDate::new(date.year, date.month, date.day)?;
2328 validate_product(center, product_type, date, sample, issue)?;
2329 Ok(Self {
2330 center,
2331 product_type,
2332 date,
2333 sample: sample.to_string(),
2334 issue: issue.map(ToOwned::to_owned),
2335 })
2336 }
2337
2338 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2340 self.date.gps_week()
2341 }
2342
2343 #[must_use]
2345 pub fn day_of_year(&self) -> u16 {
2346 self.date.day_of_year()
2347 }
2348
2349 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2355 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2356 let convention = validate_product(
2357 self.center,
2358 self.product_type,
2359 self.date,
2360 &self.sample,
2361 self.issue.as_deref(),
2362 )?;
2363 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2364 return Ok(format!(
2365 "igs{:04}{}.sp3",
2366 self.date.gps_week()?,
2367 self.date.gps_day_of_week()?
2368 ));
2369 }
2370 let descriptor = product_type_convention(self.product_type);
2371 Ok(match descriptor.kind {
2372 ProductFilenameKind::Sampled => format!(
2373 "{}_{}_{}_{}_{}.{}",
2374 convention.token,
2375 date_block(self.date, self.issue.as_deref()),
2376 convention.span,
2377 self.sample,
2378 descriptor.content_code,
2379 descriptor.extension
2380 ),
2381 ProductFilenameKind::Nav => format!(
2382 "{}_R_{}_{}_{}.{}",
2383 convention.token,
2384 date_block(self.date, None),
2385 convention.span,
2386 descriptor.content_code,
2387 descriptor.extension
2388 ),
2389 })
2390 }
2391
2392 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2394 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2395 let convention = validate_product(
2396 self.center,
2397 self.product_type,
2398 self.date,
2399 &self.sample,
2400 self.issue.as_deref(),
2401 )?;
2402 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2403 return Err(DataCatalogError::UnsupportedDistributionEra {
2404 source: DistributionSource::Direct,
2405 center: self.center,
2406 product_type: self.product_type,
2407 date: self.date,
2408 });
2409 }
2410 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2411 let filename = self.canonical_filename()?;
2412 let compression = product_archive_compression(
2413 self.center,
2414 self.product_type,
2415 self.date,
2416 convention.compression,
2417 )?;
2418 Ok(format!(
2419 "{}/{}/{}{}",
2420 entry.root_url,
2421 product_dir_path(self.center, convention.layout, self.date)?,
2422 filename,
2423 compression.suffix()
2424 ))
2425 }
2426
2427 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2429 let convention = validate_product(
2430 self.center,
2431 self.product_type,
2432 self.date,
2433 &self.sample,
2434 self.issue.as_deref(),
2435 )?;
2436 let descriptor = product_type_convention(self.product_type);
2437 let campaign = match descriptor.kind {
2438 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2439 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2440 Some("OPS") => ProductCampaign::Operational,
2441 Some("MGN") => ProductCampaign::MultiGnss,
2442 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2443 _ => {
2444 return Err(DataCatalogError::InconsistentProductIdentity {
2445 field: "campaign",
2446 });
2447 }
2448 },
2449 };
2450 let identity = ProductIdentity {
2451 family: self.product_type,
2452 analysis_center: self.center,
2453 publisher: self.center.publisher(),
2454 solution: product_solution_class(self.center, self.product_type)?,
2455 campaign,
2456 version: 0,
2457 date: self.date,
2458 issue: match descriptor.kind {
2459 ProductFilenameKind::Sampled => {
2460 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2461 }
2462 ProductFilenameKind::Nav => None,
2463 },
2464 span: convention.span.to_string(),
2465 sample: self.sample.clone(),
2466 official_filename: self.canonical_filename()?,
2467 format: product_format(self.product_type),
2468 format_version: None,
2469 prediction_horizon_days: self.center.prediction_horizon_days(),
2470 };
2471 identity.validate()?;
2472 Ok(identity)
2473 }
2474
2475 pub fn distribution_location(
2477 &self,
2478 source: DistributionSource,
2479 ) -> Result<DistributionLocation, DataCatalogError> {
2480 let identity = self.identity()?;
2481 distribution_location_for_identity(&identity, source)
2482 }
2483}
2484
2485#[derive(Debug, Clone, PartialEq, Eq)]
2487pub struct StationObservationSpec {
2488 pub station: String,
2490 pub date: ProductDate,
2492 pub sample: String,
2494}
2495
2496impl StationObservationSpec {
2497 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2499 validate_station(station)?;
2500 validate_sample(sample)?;
2501 Ok(Self {
2502 station: station.to_string(),
2503 date,
2504 sample: sample.to_string(),
2505 })
2506 }
2507
2508 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2510 station_obs_filename(&self.station, self.date, &self.sample)
2511 }
2512
2513 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2515 station_obs_url(&self.station, self.date, &self.sample)
2516 }
2517}
2518
2519#[must_use]
2521pub const fn catalog() -> &'static [CenterCatalogEntry] {
2522 &CATALOG
2523}
2524
2525#[must_use]
2527pub const fn centers() -> &'static [AnalysisCenter] {
2528 &CENTER_ORDER
2529}
2530
2531#[must_use]
2533pub const fn product_types() -> &'static [ProductTypeConvention] {
2534 &PRODUCT_TYPE_CONVENTIONS
2535}
2536
2537#[must_use]
2539pub const fn allowed_hosts() -> &'static [&'static str] {
2540 &ALLOWED_HOSTS
2541}
2542
2543#[must_use]
2545pub const fn skadi_source_entry() -> TerrainSourceEntry {
2546 SKADI_SOURCE
2547}
2548
2549#[must_use]
2551pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2552 CELESTRAK_SPACE_WEATHER_SOURCE
2553}
2554
2555#[must_use]
2557pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2558 match product {
2559 SpaceWeatherProduct::All => "SW-All.csv",
2560 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2561 }
2562}
2563
2564#[must_use]
2566pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2567 format!(
2568 "{}/{}",
2569 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2570 space_weather_filename(product)
2571 )
2572}
2573
2574#[must_use]
2576pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2577 format!("space-weather/{}", space_weather_filename(product))
2578}
2579
2580pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2582 validate_terrain_tile_index(lat_index, lon_index)?;
2583 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2584 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2585 Ok(format!(
2586 "{lat_hemi}{:02}{lon_hemi}{:03}",
2587 lat_index.abs(),
2588 lon_index.abs()
2589 ))
2590}
2591
2592pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2594 validate_terrain_lat_index(lat_index)?;
2595 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2596 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2597}
2598
2599pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2601 let band = skadi_band(lat_index)?;
2602 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2603 Ok(format!(
2604 "{}/skadi/{}/{}.hgt{}",
2605 SKADI_SOURCE.root_url,
2606 band,
2607 tile_id,
2608 SKADI_SOURCE.compression.suffix()
2609 ))
2610}
2611
2612pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2614 validate_terrain_tile_index(lat_index, lon_index)?;
2615 Ok(format!(
2616 "{}_{}{}",
2617 terrain::format_lat(lat_index),
2618 terrain::format_lon(lon_index),
2619 terrain::DTED_SUFFIX
2620 ))
2621}
2622
2623pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2625 validate_terrain_tile_index(lat_index, lon_index)?;
2626 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2627}
2628
2629pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2631 Ok(format!(
2632 "{}/{}",
2633 dted_block_dir(lat_index, lon_index)?,
2634 dted_tile_filename(lat_index, lon_index)?
2635 ))
2636}
2637
2638pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2640 let bytes = id.as_bytes();
2641 if bytes.len() != 7
2642 || !matches!(bytes[0], b'N' | b'S')
2643 || !matches!(bytes[3], b'E' | b'W')
2644 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2645 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2646 {
2647 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2648 }
2649
2650 let lat_abs = id[1..3]
2651 .parse::<i32>()
2652 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2653 let lon_abs = id[4..7]
2654 .parse::<i32>()
2655 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2656 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2657 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2658 }
2659
2660 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2661 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2662 validate_terrain_tile_index(lat_index, lon_index)?;
2663 Ok((lat_index, lon_index))
2664}
2665
2666pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2668 if !lat_deg.is_finite()
2669 || !lon_deg.is_finite()
2670 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2671 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2672 {
2673 return Err(DataCatalogError::InvalidCoordinate {
2674 lat_deg_bits: lat_deg.to_bits(),
2675 lon_deg_bits: lon_deg.to_bits(),
2676 });
2677 }
2678
2679 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2680 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2681 lat_index = MAX_TERRAIN_LAT_INDEX;
2682 }
2683 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2684 lon_index = MAX_TERRAIN_LON_INDEX;
2685 }
2686 validate_terrain_tile_index(lat_index, lon_index)?;
2687 Ok((lat_index, lon_index))
2688}
2689
2690pub fn hgt_to_dted(
2698 lat_index: i32,
2699 lon_index: i32,
2700 hgt: &[u8],
2701) -> Result<Vec<u8>, HgtConversionError> {
2702 validate_hgt_tile_index(lat_index, lon_index)?;
2703 if hgt.len() != SRTM1_HGT_LEN {
2704 return Err(HgtConversionError::BadLength {
2705 expected: SRTM1_HGT_LEN,
2706 got: hgt.len(),
2707 });
2708 }
2709
2710 let mut out = vec![b' '; DTED_SRTM1_LEN];
2711 out[0..4].copy_from_slice(b"UHL1");
2712 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2713 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2714 out[47..51].copy_from_slice(b"3601");
2715 out[51..55].copy_from_slice(b"3601");
2716
2717 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2718 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2719 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2720 out[block_start] = terrain::DATA_SENTINEL;
2721
2722 let count = (lon_posting as u32).to_be_bytes();
2723 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2724 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2725 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2726
2727 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2728 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2729 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2730 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2731 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2732 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2733 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2734 }
2735
2736 let checksum = out[block_start..checksum_start]
2737 .iter()
2738 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2739 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2740 }
2741
2742 debug_assert_eq!(out.len(), 25_981_042);
2743 Ok(out)
2744}
2745
2746#[must_use]
2748pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2749 &NO_OPEN_MIRRORS
2750}
2751
2752pub fn open_mirror(
2754 center: AnalysisCenter,
2755 product_type: ProductType,
2756) -> Result<(), DataCatalogError> {
2757 open_mirror_code(center.code(), product_type.code())
2758}
2759
2760pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2762 if NO_OPEN_MIRRORS
2763 .iter()
2764 .any(|entry| entry.center == center && entry.product_type == product_type)
2765 {
2766 Err(DataCatalogError::NoOpenMirror {
2767 center: center.to_string(),
2768 product_type: product_type.to_string(),
2769 })
2770 } else {
2771 Ok(())
2772 }
2773}
2774
2775#[must_use]
2777pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2778 CATALOG.iter().find(|entry| entry.center == center)
2779}
2780
2781pub fn product_convention(
2783 center: AnalysisCenter,
2784 product_type: ProductType,
2785) -> Result<&'static CenterProductConvention, DataCatalogError> {
2786 open_mirror(center, product_type)?;
2787 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2788 entry
2789 .products
2790 .iter()
2791 .find(|product| product.product_type == product_type)
2792 .ok_or(DataCatalogError::UnsupportedProduct {
2793 center,
2794 product_type,
2795 })
2796}
2797
2798pub fn product_solution_class(
2806 center: AnalysisCenter,
2807 product_type: ProductType,
2808) -> Result<SolutionClass, DataCatalogError> {
2809 product_convention(center, product_type)?;
2810 Ok(match (center, product_type) {
2811 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2812 _ => center.solution_class(),
2813 })
2814}
2815
2816pub fn default_sample(
2822 center: AnalysisCenter,
2823 product_type: ProductType,
2824) -> Result<&'static str, DataCatalogError> {
2825 Ok(product_convention(center, product_type)?.default_sample)
2826}
2827
2828pub fn default_sample_for_date(
2836 center: AnalysisCenter,
2837 product_type: ProductType,
2838 date: ProductDate,
2839) -> Result<&'static str, DataCatalogError> {
2840 default_sample_for_product_issue(center, product_type, date, None)
2841}
2842
2843pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2845 date.gps_week()
2846}
2847
2848#[must_use]
2850pub fn day_of_year(date: ProductDate) -> u16 {
2851 date.day_of_year()
2852}
2853
2854pub fn product(
2856 center: AnalysisCenter,
2857 product_type: ProductType,
2858 date: ProductDate,
2859 sample: Option<&str>,
2860 issue: Option<&str>,
2861) -> Result<ProductSpec, DataCatalogError> {
2862 let sample = match sample {
2863 Some(sample) => sample,
2864 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2865 };
2866 ProductSpec::new(center, product_type, date, sample, issue)
2867}
2868
2869pub fn canonical_filename(
2871 center: AnalysisCenter,
2872 product_type: ProductType,
2873 date: ProductDate,
2874 sample: Option<&str>,
2875 issue: Option<&str>,
2876) -> Result<String, DataCatalogError> {
2877 product(center, product_type, date, sample, issue)?.canonical_filename()
2878}
2879
2880pub fn archive_url(
2882 center: AnalysisCenter,
2883 product_type: ProductType,
2884 date: ProductDate,
2885 sample: Option<&str>,
2886 issue: Option<&str>,
2887) -> Result<String, DataCatalogError> {
2888 product(center, product_type, date, sample, issue)?.archive_url()
2889}
2890
2891pub fn product_identity(
2893 center: AnalysisCenter,
2894 product_type: ProductType,
2895 date: ProductDate,
2896 sample: Option<&str>,
2897 issue: Option<&str>,
2898) -> Result<ProductIdentity, DataCatalogError> {
2899 product(center, product_type, date, sample, issue)?.identity()
2900}
2901
2902pub fn distribution_location(
2904 center: AnalysisCenter,
2905 product_type: ProductType,
2906 date: ProductDate,
2907 sample: Option<&str>,
2908 issue: Option<&str>,
2909 source: DistributionSource,
2910) -> Result<DistributionLocation, DataCatalogError> {
2911 product(center, product_type, date, sample, issue)?.distribution_location(source)
2912}
2913
2914pub fn distribution_location_for_identity(
2921 identity: &ProductIdentity,
2922 source: DistributionSource,
2923) -> Result<DistributionLocation, DataCatalogError> {
2924 identity.validate()?;
2925 match source {
2926 DistributionSource::Direct => {
2927 let convention = product_convention(identity.analysis_center, identity.family)?;
2928 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2929 {
2930 return Err(DataCatalogError::UnsupportedDistributionEra {
2931 source,
2932 center: identity.analysis_center,
2933 product_type: identity.family,
2934 date: identity.date,
2935 });
2936 }
2937 let entry = center_catalog(identity.analysis_center)
2938 .expect("validated analysis center has a catalog entry");
2939 let compression = product_archive_compression(
2940 identity.analysis_center,
2941 identity.family,
2942 identity.date,
2943 convention.compression,
2944 )?;
2945 let url = format!(
2946 "{}/{}/{}{}",
2947 entry.root_url,
2948 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2949 identity.official_filename,
2950 compression.suffix()
2951 );
2952 Ok(DistributionLocation {
2953 source,
2954 original_url: Some(url),
2955 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2956 compression,
2957 })
2958 }
2959 DistributionSource::NasaCddis => {
2960 validate_cddis_distribution_era(identity)?;
2961 let compression = product_archive_compression(
2962 identity.analysis_center,
2963 identity.family,
2964 identity.date,
2965 ArchiveCompression::Gzip,
2966 )?;
2967 Ok(DistributionLocation {
2968 source,
2969 original_url: Some(cddis_archive_url(identity)?),
2970 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2971 compression,
2972 })
2973 }
2974 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2975 source,
2976 original_url: None,
2977 archive_filename: identity.official_filename.clone(),
2978 compression: ArchiveCompression::None,
2979 }),
2980 }
2981}
2982
2983pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2992 identity.validate()?;
2993 validate_cddis_distribution_era(identity)?;
2994 match identity.family {
2995 ProductType::Sp3 => {
2996 let compression = product_archive_compression(
2997 identity.analysis_center,
2998 identity.family,
2999 identity.date,
3000 ArchiveCompression::Gzip,
3001 )?;
3002 Ok(format!(
3003 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
3004 identity.date.gps_week()?,
3005 identity.official_filename,
3006 compression.suffix()
3007 ))
3008 }
3009 ProductType::Ionex => Ok(format!(
3010 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
3011 identity.date.year,
3012 identity.date.day_of_year(),
3013 identity.official_filename
3014 )),
3015 product_type => Err(DataCatalogError::UnsupportedDistribution {
3016 source: DistributionSource::NasaCddis,
3017 product_type,
3018 }),
3019 }
3020}
3021
3022pub fn mgex_clk(
3024 center: AnalysisCenter,
3025 date: ProductDate,
3026 sample: Option<&str>,
3027) -> Result<ProductSpec, DataCatalogError> {
3028 product(center, ProductType::Clk, date, sample, None)
3029}
3030
3031pub fn mgex_nav(
3033 center: AnalysisCenter,
3034 date: ProductDate,
3035 sample: Option<&str>,
3036) -> Result<ProductSpec, DataCatalogError> {
3037 product(center, ProductType::Nav, date, sample, None)
3038}
3039
3040pub fn mgex_ionex(
3042 center: AnalysisCenter,
3043 date: ProductDate,
3044 sample: Option<&str>,
3045) -> Result<ProductSpec, DataCatalogError> {
3046 product(center, ProductType::Ionex, date, sample, None)
3047}
3048
3049pub fn rapid_ionex(
3051 date: ProductDate,
3052 sample: Option<&str>,
3053) -> Result<ProductSpec, DataCatalogError> {
3054 product(
3055 AnalysisCenter::CodRap,
3056 ProductType::Ionex,
3057 date,
3058 sample,
3059 None,
3060 )
3061}
3062
3063#[must_use]
3065pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
3066 match center {
3067 AnalysisCenter::CodPrd2 => 1,
3068 _ => 0,
3069 }
3070}
3071
3072pub fn predicted_ionex(
3074 center: AnalysisCenter,
3075 date: ProductDate,
3076 sample: Option<&str>,
3077) -> Result<ProductSpec, DataCatalogError> {
3078 match center {
3079 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3080 let target = date.add_days(predicted_day_offset(center))?;
3081 product(center, ProductType::Ionex, target, sample, None)
3082 }
3083 other => Err(DataCatalogError::UnsupportedProduct {
3084 center: other,
3085 product_type: ProductType::Ionex,
3086 }),
3087 }
3088}
3089
3090pub fn mgex_sp3(
3092 center: AnalysisCenter,
3093 date: ProductDate,
3094 sample: Option<&str>,
3095) -> Result<ProductSpec, DataCatalogError> {
3096 product(center, ProductType::Sp3, date, sample, None)
3097}
3098
3099pub fn ops_ultra_sp3(
3101 center: AnalysisCenter,
3102 date: ProductDate,
3103 sample: Option<&str>,
3104 issue: Option<&str>,
3105) -> Result<ProductSpec, DataCatalogError> {
3106 let issue = issue.unwrap_or("0000");
3107 product(center, ProductType::Sp3, date, sample, Some(issue))
3108}
3109
3110pub fn ultra_sp3_locations(
3120 center: AnalysisCenter,
3121 date: ProductDate,
3122 issue: &str,
3123) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3124 validate_issue_for_center(center, Some(issue))?;
3125 validate_product_date(center, ProductType::Sp3, date)?;
3126 match center {
3127 AnalysisCenter::IgsUlt
3128 | AnalysisCenter::CodUlt
3129 | AnalysisCenter::EsaUlt
3130 | AnalysisCenter::GfzUlt
3131 | AnalysisCenter::WumNrt => {}
3132 other => {
3133 return Err(DataCatalogError::UnsupportedProduct {
3134 center: other,
3135 product_type: ProductType::Sp3,
3136 })
3137 }
3138 };
3139 let default_sample =
3140 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3141 let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3142 samples.sort_by_key(|sample| *sample != default_sample);
3143
3144 samples
3145 .into_iter()
3146 .map(|sample| {
3147 let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3151 let identity = spec.identity()?;
3152 let filename = spec.canonical_filename()?;
3153 let url = spec.archive_url()?;
3154 let convention = product_convention(center, ProductType::Sp3)?;
3155 let compression = product_archive_compression(
3156 center,
3157 ProductType::Sp3,
3158 date,
3159 convention.compression,
3160 )?;
3161 Ok(UltraSp3Location {
3162 pattern: if sample == default_sample {
3163 format!("primary_{}_{}", identity.span, sample)
3164 } else {
3165 format!("alternate_{}_{}", identity.span, sample)
3166 },
3167 span: identity.span,
3168 sample: sample.to_string(),
3169 url,
3170 filename,
3171 compression,
3172 })
3173 })
3174 .collect()
3175}
3176
3177pub fn ops_ultra_clk(
3179 center: AnalysisCenter,
3180 date: ProductDate,
3181 sample: Option<&str>,
3182 issue: Option<&str>,
3183) -> Result<ProductSpec, DataCatalogError> {
3184 let issue = issue.unwrap_or("0000");
3185 product(center, ProductType::Clk, date, sample, Some(issue))
3186}
3187
3188pub fn latest_ops_ultra_sp3(
3190 center: AnalysisCenter,
3191 target: ProductDateTime,
3192 sample: Option<&str>,
3193 available_issues: Option<&[UltraIssue]>,
3194) -> Result<ProductSpec, DataCatalogError> {
3195 let selected = latest_ultra_issue(center, target, available_issues)?;
3196 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3197}
3198
3199pub fn ultra_issue_candidates(
3201 center: AnalysisCenter,
3202 target: ProductDateTime,
3203) -> Result<Vec<UltraIssue>, DataCatalogError> {
3204 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3205 let _ = product_convention(center, ProductType::Sp3)?;
3206 if entry.issues.is_empty() {
3207 return Err(DataCatalogError::UnsupportedProduct {
3208 center,
3209 product_type: ProductType::Sp3,
3210 });
3211 }
3212 validate_product_date(center, ProductType::Sp3, target.date)?;
3213
3214 let mut candidates = Vec::new();
3215 for date in [target.date, target.date.add_days(-1)?] {
3216 match validate_product_date(center, ProductType::Sp3, date) {
3217 Ok(()) => {}
3218 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3219 Err(error) => return Err(error),
3220 }
3221 for issue in entry.issues.iter().rev() {
3222 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3223 candidates.push(UltraIssue::new(date, issue)?);
3224 }
3225 }
3226 }
3227 Ok(candidates)
3228}
3229
3230pub fn latest_ultra_issue(
3232 center: AnalysisCenter,
3233 target: ProductDateTime,
3234 available_issues: Option<&[UltraIssue]>,
3235) -> Result<UltraIssue, DataCatalogError> {
3236 let candidates = ultra_issue_candidates(center, target)?;
3237 if candidates.is_empty() {
3238 return Err(DataCatalogError::NoUltraIssue);
3239 }
3240 if let Some(available) = available_issues {
3241 candidates
3242 .into_iter()
3243 .find(|candidate| {
3244 available
3245 .iter()
3246 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3247 })
3248 .ok_or(DataCatalogError::NoAvailableUltraIssue)
3249 } else {
3250 Ok(candidates[0].clone())
3251 }
3252}
3253
3254pub fn predicted_ionex_line_candidates(
3285 map_date: ProductDate,
3286 sample: Option<&str>,
3287) -> Result<Vec<ProductSpec>, DataCatalogError> {
3288 let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3289 let two_day_production_date = map_date.add_days(-1)?;
3290 let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3291 if one_day.date != map_date || two_day.date != map_date {
3295 return Err(DataCatalogError::InconsistentProductIdentity {
3296 field: "predicted_ionex_map_date",
3297 });
3298 }
3299 Ok(vec![one_day, two_day])
3300}
3301
3302pub fn gim_date_candidates(
3304 center: AnalysisCenter,
3305 target: ProductDate,
3306 lookback: u32,
3307) -> Result<Vec<ProductDate>, DataCatalogError> {
3308 let _ = product_convention(center, ProductType::Ionex)?;
3309 let base = target.add_days(predicted_day_offset(center))?;
3310 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3311 for back in 0..=lookback {
3312 out.push(base.add_days(-i64::from(back))?);
3313 }
3314 Ok(out)
3315}
3316
3317#[derive(Debug, Clone, PartialEq, Eq)]
3348pub struct PublishedObject {
3349 pub path: String,
3351 pub observed_at: Option<String>,
3353}
3354
3355#[derive(Debug, Clone, PartialEq, Eq)]
3358pub struct PublishedProduct {
3359 pub date: ProductDate,
3361 pub issue: String,
3363 pub filename: String,
3365 pub observed_at: Option<String>,
3367}
3368
3369pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3398 let mut seen: Vec<PublishedObject> = Vec::new();
3399 let mut push = |path: String, observed_at: Option<String>| {
3400 if let Some(existing) = seen.iter_mut().find(|object| object.path == path) {
3401 if existing.observed_at.is_none() {
3402 existing.observed_at = observed_at;
3403 }
3404 } else {
3405 seen.push(PublishedObject { path, observed_at });
3406 }
3407 };
3408 let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3409 reason: reason.to_string(),
3410 };
3411
3412 let non_empty: Vec<&str> = body
3413 .lines()
3414 .map(str::trim_end)
3415 .filter(|line| !line.trim().is_empty())
3416 .collect();
3417 if non_empty.is_empty() {
3418 return Err(unrecognized("empty body"));
3419 }
3420 let has_markup = body.contains('<');
3421
3422 if !has_markup && non_empty[0].matches(';').count() >= 3 {
3424 for line in &non_empty {
3425 if line.matches(';').count() < 3 {
3426 return Err(unrecognized("CSV row without its four fields"));
3427 }
3428 let mut fields = line.split(';');
3429 let (Some(path), Some(_bytes), Some(observed)) =
3430 (fields.next(), fields.next(), fields.next())
3431 else {
3432 return Err(unrecognized("CSV row without its four fields"));
3433 };
3434 if path.is_empty() {
3435 return Err(unrecognized("CSV row without an archive path"));
3436 }
3437 if path.ends_with('/') {
3445 continue;
3446 }
3447 let observed_at =
3448 (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3449 push(path.to_string(), observed_at);
3450 }
3451 return Ok(seen);
3452 }
3453
3454 if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3456 for (index, line) in non_empty.iter().enumerate() {
3457 if index == 0 && line.starts_with("total ") {
3458 continue;
3459 }
3460 let mode_shaped = line.len() > 10
3461 && line.starts_with(['-', 'd', 'l'])
3462 && line.as_bytes()[1..10]
3463 .iter()
3464 .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3465 if !mode_shaped {
3466 return Err(unrecognized("FTP LIST row without a Unix mode field"));
3467 }
3468 if !line.starts_with('-') {
3470 continue;
3471 }
3472 let fields: Vec<&str> = line.split_whitespace().collect();
3473 if fields.len() < 9 {
3474 return Err(unrecognized("FTP LIST file row without nine fields"));
3475 }
3476 push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3477 }
3478 return Ok(seen);
3479 }
3480
3481 if has_markup && body.contains("Index of") {
3483 for line in &non_empty {
3484 let mut rest = *line;
3487 while let Some(start) = rest.find("<a href=\"") {
3488 rest = &rest[start + 9..];
3489 let Some(end) = rest.find('"') else { break };
3490 let target = &rest[..end];
3491 rest = &rest[end..];
3492 if target.is_empty()
3493 || target.starts_with('?')
3494 || target.starts_with('/')
3495 || target.starts_with('#')
3496 || target.contains("://")
3497 || target.ends_with('/')
3498 {
3499 continue;
3500 }
3501 let observed_at = find_listing_datetime(rest).map(str::to_string);
3502 push(target.to_string(), observed_at);
3503 }
3504 }
3505 return Ok(seen);
3506 }
3507
3508 Err(unrecognized(if has_markup {
3509 "markup without an autoindex marker"
3510 } else {
3511 "no known listing grammar"
3512 }))
3513}
3514
3515fn find_listing_datetime(rest: &str) -> Option<&str> {
3517 let bytes = rest.as_bytes();
3518 let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3519 for start in 0..bytes.len().saturating_sub(15) {
3520 let shape_matches = is_digit(start)
3521 && is_digit(start + 1)
3522 && is_digit(start + 2)
3523 && is_digit(start + 3)
3524 && bytes[start + 4] == b'-'
3525 && is_digit(start + 5)
3526 && is_digit(start + 6)
3527 && bytes[start + 7] == b'-'
3528 && is_digit(start + 8)
3529 && is_digit(start + 9)
3530 && bytes[start + 10] == b' '
3531 && is_digit(start + 11)
3532 && is_digit(start + 12)
3533 && bytes[start + 13] == b':'
3534 && is_digit(start + 14)
3535 && is_digit(start + 15);
3536 if shape_matches {
3537 return Some(&rest[start..start + 16]);
3538 }
3539 }
3540 None
3541}
3542
3543const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3546 match center {
3547 AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3548 AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3549 _ => None,
3550 }
3551}
3552
3553fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3554 match center_path_marker(center) {
3555 Some(marker) => {
3558 let slashed = format!("/{path}");
3559 slashed.contains(marker)
3560 }
3561 None => true,
3562 }
3563}
3564
3565pub fn newest_published_product(
3579 center: AnalysisCenter,
3580 product_type: ProductType,
3581 objects: &[PublishedObject],
3582) -> Result<Option<PublishedProduct>, DataCatalogError> {
3583 let convention = product_convention(center, product_type)?;
3584 let descriptor = product_type_convention(product_type);
3585 let suffix = format!(".{}", descriptor.extension);
3586 let tail = format!("_{}{}", descriptor.content_code, suffix);
3587
3588 let mut newest: Option<(i64, PublishedProduct)> = None;
3589 for object in objects {
3590 if !object_matches_center(center, &object.path) {
3591 continue;
3592 }
3593 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3594 let stripped = listed_name
3595 .strip_suffix(".gz")
3596 .or_else(|| listed_name.strip_suffix(".Z"))
3597 .unwrap_or(listed_name);
3598 let Some(after_token) = stripped
3599 .strip_prefix(convention.token)
3600 .and_then(|rest| rest.strip_prefix('_'))
3601 else {
3602 continue;
3603 };
3604 let Some(middle) = after_token.strip_suffix(&tail) else {
3605 continue;
3606 };
3607 let mut parts = middle.split('_');
3608 let (Some(block), Some(span), Some(sample), None) =
3609 (parts.next(), parts.next(), parts.next(), parts.next())
3610 else {
3611 continue;
3612 };
3613 if span != convention.span || block.len() != 11 {
3614 continue;
3615 }
3616 let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3617 else {
3618 continue;
3619 };
3620 let issue = &block[7..11];
3621 let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3622 continue;
3623 };
3624 if validate_issue(issue).is_err() {
3625 continue;
3626 }
3627 let issue_argument = (!center_catalog(center)
3630 .expect("catalog entry exists for enum variant")
3631 .issues
3632 .is_empty())
3633 .then_some(issue);
3634 match product(center, product_type, date, Some(sample), issue_argument) {
3635 Ok(spec) => {
3636 if spec.canonical_filename()? != stripped {
3637 continue;
3638 }
3639 }
3640 Err(_) => continue,
3641 }
3642 let ordering = issue_ordering_minutes(date, issue)?;
3643 let replace = newest
3644 .as_ref()
3645 .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3646 if replace {
3647 newest = Some((
3648 ordering,
3649 PublishedProduct {
3650 date,
3651 issue: issue.to_string(),
3652 filename: stripped.to_string(),
3653 observed_at: object.observed_at.clone(),
3654 },
3655 ));
3656 }
3657 }
3658 Ok(newest.map(|(_, product)| product))
3659}
3660
3661pub fn published_issue_age_minutes(
3669 published: &PublishedProduct,
3670 now: ProductDateTime,
3671) -> Result<i64, DataCatalogError> {
3672 Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3673}
3674
3675pub fn next_issue_due(
3705 center: AnalysisCenter,
3706 product_type: ProductType,
3707 now: ProductDateTime,
3708) -> Result<NominalIssue, DataCatalogError> {
3709 ProductDate::new(now.date.year, now.date.month, now.date.day)?;
3710 ProductDateTime::new(now.date, now.hour, now.minute, now.second)?;
3711 product_convention(center, product_type)?;
3712 if center == AnalysisCenter::WumNrt || product_type == ProductType::Nav {
3713 return Err(DataCatalogError::UnsupportedNominalSchedule {
3714 center,
3715 product_type,
3716 });
3717 }
3718
3719 let mut next: Option<NominalIssue> = None;
3720 for offset_days in -28_i64..=42 {
3721 let Ok(identity_date) = now.date.add_days(offset_days) else {
3722 continue;
3723 };
3724 for candidate in nominal_issues_for_date(center, product_type, identity_date)? {
3725 if candidate.due_at < now {
3726 continue;
3727 }
3728 let replace = next.as_ref().is_none_or(|current| {
3729 candidate.due_at < current.due_at
3730 || (candidate.due_at == current.due_at
3731 && candidate.identity.official_filename
3732 < current.identity.official_filename)
3733 });
3734 if replace {
3735 next = Some(candidate);
3736 }
3737 }
3738 }
3739 next.ok_or(DataCatalogError::DateOutOfRange)
3740}
3741
3742fn nominal_issues_for_date(
3743 center: AnalysisCenter,
3744 product_type: ProductType,
3745 identity_date: ProductDate,
3746) -> Result<Vec<NominalIssue>, DataCatalogError> {
3747 let solution = product_solution_class(center, product_type)?;
3748 if solution == SolutionClass::Final && identity_date.gps_day_of_week()? != 6 {
3749 return Ok(Vec::new());
3750 }
3751
3752 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3753 let issues: Vec<&str> = if matches!(solution, SolutionClass::UltraRapid) {
3754 entry.issues.to_vec()
3755 } else {
3756 vec!["0000"]
3757 };
3758 let mut out = Vec::with_capacity(issues.len());
3759 for issue in issues {
3760 let issue_argument = (!entry.issues.is_empty()).then_some(issue);
3761 let identity =
3762 match product_identity(center, product_type, identity_date, None, issue_argument) {
3763 Ok(identity) => identity,
3764 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3765 Err(error) => return Err(error),
3766 };
3767 let filename_epoch = ProductDateTime::new(
3768 identity_date,
3769 (issue_minutes(issue)? / 60) as u8,
3770 (issue_minutes(issue)? % 60) as u8,
3771 0,
3772 )?;
3773 let covers = nominal_coverage(&identity, filename_epoch)?;
3774 let due_at = nominal_due_at(center, product_type, solution, filename_epoch, covers)?;
3775 out.push(NominalIssue {
3776 identity,
3777 due_at,
3778 covers,
3779 });
3780 }
3781 Ok(out)
3782}
3783
3784fn nominal_due_at(
3785 center: AnalysisCenter,
3786 product_type: ProductType,
3787 solution: SolutionClass,
3788 filename_epoch: ProductDateTime,
3789 covers: NominalCoverage,
3790) -> Result<ProductDateTime, DataCatalogError> {
3791 match solution {
3792 SolutionClass::UltraRapid => {
3793 let observed_until = covers
3794 .observed
3795 .ok_or(DataCatalogError::InconsistentProductIdentity {
3796 field: "nominal_ultra_observed_coverage",
3797 })?
3798 .until;
3799 add_product_seconds(
3800 observed_until,
3801 if center == AnalysisCenter::IgsUlt {
3802 3 * 3_600
3803 } else {
3804 2 * 3_600 + 50 * 60
3805 },
3806 )
3807 }
3808 SolutionClass::Rapid if product_type == ProductType::Ionex => {
3809 add_product_seconds(filename_epoch, 24 * 3_600)
3810 }
3811 SolutionClass::Rapid => {
3812 add_product_seconds(filename_epoch, 24 * 3_600 + 15 * 3_600 + 45 * 60)
3813 }
3814 SolutionClass::Final if center == AnalysisCenter::Igs => {
3815 add_product_seconds(filename_epoch, 13 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3816 }
3817 SolutionClass::Final if product_type == ProductType::Ionex => {
3818 add_product_seconds(filename_epoch, 11 * 86_400 + 23 * 3_600 + 59 * 60 + 59)
3819 }
3820 SolutionClass::Final => add_product_seconds(filename_epoch, 11 * 86_400 + 5 * 3_600),
3821 SolutionClass::Predicted => {
3822 let horizon = center.prediction_horizon_days().ok_or(
3823 DataCatalogError::UnsupportedNominalSchedule {
3824 center,
3825 product_type,
3826 },
3827 )?;
3828 add_product_seconds(filename_epoch, -i64::from(horizon) * 86_400)
3829 }
3830 SolutionClass::NearRealTime | SolutionClass::Broadcast => {
3831 Err(DataCatalogError::UnsupportedNominalSchedule {
3832 center,
3833 product_type,
3834 })
3835 }
3836 }
3837}
3838
3839fn nominal_coverage(
3840 identity: &ProductIdentity,
3841 filename_epoch: ProductDateTime,
3842) -> Result<NominalCoverage, DataCatalogError> {
3843 let content_offset_s = if identity.family == ProductType::Sp3 {
3844 let entry = center_catalog(identity.analysis_center)
3845 .expect("validated identity has a catalog entry");
3846 let issue = if entry.issues.is_empty() {
3847 None
3848 } else {
3849 identity.issue.as_deref()
3850 };
3851 sp3_content_start_convention(identity.analysis_center, identity.date, issue)?
3852 .content_start_offset_s()
3853 } else {
3854 0
3855 };
3856 let from = add_product_seconds(filename_epoch, content_offset_s)?;
3857 let duration_s = match identity.span.as_str() {
3858 "01D" => 86_400,
3859 "02D" => 172_800,
3860 _ => {
3861 return Err(DataCatalogError::InconsistentProductIdentity {
3862 field: "nominal_coverage_span",
3863 })
3864 }
3865 };
3866 let until = add_product_seconds(from, duration_s)?;
3867
3868 if identity.solution == SolutionClass::UltraRapid && duration_s == 172_800 {
3869 let split = add_product_seconds(from, 86_400)?;
3870 Ok(NominalCoverage {
3871 observed: Some(NominalCoverageInterval { from, until: split }),
3872 predicted: Some(NominalCoverageInterval { from: split, until }),
3873 })
3874 } else if identity.solution == SolutionClass::Predicted {
3875 Ok(NominalCoverage {
3876 observed: None,
3877 predicted: Some(NominalCoverageInterval { from, until }),
3878 })
3879 } else {
3880 Ok(NominalCoverage {
3881 observed: Some(NominalCoverageInterval { from, until }),
3882 predicted: None,
3883 })
3884 }
3885}
3886
3887fn add_product_seconds(
3888 datetime: ProductDateTime,
3889 seconds: i64,
3890) -> Result<ProductDateTime, DataCatalogError> {
3891 let total = datetime
3892 .ordering_seconds()
3893 .checked_add(seconds)
3894 .ok_or(DataCatalogError::DateOutOfRange)?;
3895 let jdn = total.div_euclid(86_400);
3896 let seconds_of_day = total.rem_euclid(86_400);
3897 ProductDateTime::new(
3898 product_date_from_jdn(jdn)?,
3899 u8::try_from(seconds_of_day / 3_600).map_err(|_| DataCatalogError::DateOutOfRange)?,
3900 u8::try_from((seconds_of_day % 3_600) / 60)
3901 .map_err(|_| DataCatalogError::DateOutOfRange)?,
3902 u8::try_from(seconds_of_day % 60).map_err(|_| DataCatalogError::DateOutOfRange)?,
3903 )
3904}
3905
3906pub fn publication_listing_urls(
3924 center: AnalysisCenter,
3925 product_type: ProductType,
3926 around: ProductDate,
3927) -> Result<Vec<String>, DataCatalogError> {
3928 let convention = product_convention(center, product_type)?;
3929 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3930 match convention.layout {
3931 ArchiveLayout::AiubCodeRoot
3932 | ArchiveLayout::AiubCodeYear
3933 | ArchiveLayout::AiubCodeMgexYear => {
3934 Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3935 }
3936 _ => {
3937 let current = format!(
3938 "{}/{}/",
3939 entry.root_url,
3940 product_dir_path(center, convention.layout, around)?
3941 );
3942 let previous_week_date = around.add_days(-7)?;
3943 let previous = format!(
3944 "{}/{}/",
3945 entry.root_url,
3946 product_dir_path(center, convention.layout, previous_week_date)?
3947 );
3948 let mut urls = vec![current];
3949 if !urls.contains(&previous) {
3950 urls.push(previous);
3951 }
3952 Ok(urls)
3953 }
3954 }
3955}
3956
3957pub fn resolve_first_published(
3967 candidates: &[ProductSpec],
3968 objects: &[PublishedObject],
3969) -> Result<Option<usize>, DataCatalogError> {
3970 for (index, candidate) in candidates.iter().enumerate() {
3971 let filename = candidate.canonical_filename()?;
3972 let convention = product_convention(candidate.center, candidate.product_type)?;
3973 let compression = product_archive_compression(
3974 candidate.center,
3975 candidate.product_type,
3976 candidate.date,
3977 convention.compression,
3978 )?;
3979 let archive_name = format!("{filename}{}", compression.suffix());
3980 let found = objects.iter().any(|object| {
3981 if !object_matches_center(candidate.center, &object.path) {
3982 return false;
3983 }
3984 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3985 listed_name == archive_name || listed_name == filename
3986 });
3987 if found {
3988 return Ok(Some(index));
3989 }
3990 }
3991 Ok(None)
3992}
3993
3994fn product_date_from_year_day(
3995 year: i32,
3996 day_of_year: u16,
3997) -> Result<ProductDate, DataCatalogError> {
3998 if day_of_year == 0 {
3999 return Err(DataCatalogError::DateOutOfRange);
4000 }
4001 ProductDate::new(year, 1, 1)?
4002 .add_days(i64::from(day_of_year) - 1)
4003 .and_then(|date| {
4004 if date.year == year {
4005 Ok(date)
4006 } else {
4007 Err(DataCatalogError::DateOutOfRange)
4008 }
4009 })
4010}
4011
4012pub fn station_obs(
4014 station: &str,
4015 date: ProductDate,
4016 sample: Option<&str>,
4017) -> Result<StationObservationSpec, DataCatalogError> {
4018 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
4019}
4020
4021pub fn station_obs_filename(
4023 station: &str,
4024 date: ProductDate,
4025 sample: &str,
4026) -> Result<String, DataCatalogError> {
4027 validate_station(station)?;
4028 validate_sample(sample)?;
4029 Ok(format!(
4030 "{}_R_{}_01D_{}_MO.crx",
4031 station,
4032 date_block(date, None),
4033 sample
4034 ))
4035}
4036
4037pub fn station_obs_url(
4039 station: &str,
4040 date: ProductDate,
4041 sample: &str,
4042) -> Result<String, DataCatalogError> {
4043 let filename = station_obs_filename(station, date, sample)?;
4044 Ok(format!(
4045 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
4046 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
4047 filename
4048 ))
4049}
4050
4051#[must_use]
4053pub const fn station_obs_protocol() -> ArchiveProtocol {
4054 ArchiveProtocol::Https
4055}
4056
4057fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
4058 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
4059 Ok(())
4060 } else {
4061 Err(DataCatalogError::InvalidTileIndex {
4062 lat_index,
4063 lon_index: 0,
4064 })
4065 }
4066}
4067
4068fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
4069 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4070 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4071 {
4072 Ok(())
4073 } else {
4074 Err(DataCatalogError::InvalidTileIndex {
4075 lat_index,
4076 lon_index,
4077 })
4078 }
4079}
4080
4081fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
4082 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
4083 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
4084 {
4085 Ok(())
4086 } else {
4087 Err(HgtConversionError::InvalidTileIndex {
4088 lat_index,
4089 lon_index,
4090 })
4091 }
4092}
4093
4094fn dted_coord_field(index: i32, is_longitude: bool) -> String {
4095 let hemi = match (is_longitude, index >= 0) {
4096 (true, true) => 'E',
4097 (true, false) => 'W',
4098 (false, true) => 'N',
4099 (false, false) => 'S',
4100 };
4101 format!("{:03}0000{hemi}", index.abs())
4102}
4103
4104fn encode_dted_signed_magnitude(sample: i16) -> u16 {
4105 if sample == i16::MIN {
4106 0
4107 } else if sample >= 0 {
4108 sample as u16
4109 } else {
4110 0x8000 | (-i32::from(sample) as u16)
4111 }
4112}
4113
4114fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
4115 PRODUCT_TYPE_CONVENTIONS
4116 .iter()
4117 .find(|descriptor| descriptor.product_type == product_type)
4118 .expect("product descriptor exists for enum variant")
4119}
4120
4121const fn product_format(product_type: ProductType) -> ProductFormat {
4122 match product_type {
4123 ProductType::Sp3 => ProductFormat::Sp3,
4124 ProductType::Ionex => ProductFormat::Ionex,
4125 ProductType::Clk => ProductFormat::RinexClock,
4126 ProductType::Nav => ProductFormat::RinexNavigation,
4127 }
4128}
4129
4130fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
4131 if filename.is_empty()
4132 || filename == "."
4133 || filename == ".."
4134 || filename.contains('/')
4135 || filename.contains('\\')
4136 || filename.contains('\0')
4137 || filename.contains("..")
4138 {
4139 Err(DataCatalogError::InvalidOfficialFilename(
4140 filename.to_string(),
4141 ))
4142 } else {
4143 Ok(())
4144 }
4145}
4146
4147fn validate_product(
4148 center: AnalysisCenter,
4149 product_type: ProductType,
4150 date: ProductDate,
4151 sample: &str,
4152 issue: Option<&str>,
4153) -> Result<&'static CenterProductConvention, DataCatalogError> {
4154 let convention = product_convention(center, product_type)?;
4155 validate_sample(sample)?;
4156 validate_issue_for_center(center, issue)?;
4157 validate_product_date(center, product_type, date)?;
4158 validate_catalog_sample(center, product_type, date, sample, issue)?;
4159 Ok(convention)
4160}
4161
4162fn validate_catalog_sample(
4163 center: AnalysisCenter,
4164 product_type: ProductType,
4165 date: ProductDate,
4166 sample: &str,
4167 issue: Option<&str>,
4168) -> Result<(), DataCatalogError> {
4169 let supported = supported_samples_inner(center, product_type, date, issue)?;
4170 if supported.contains(&sample) {
4171 return Ok(());
4172 }
4173 Err(DataCatalogError::UnsupportedSample {
4174 center,
4175 product_type,
4176 sample: sample.to_string(),
4177 })
4178}
4179
4180pub fn supported_samples(
4191 center: AnalysisCenter,
4192 product_type: ProductType,
4193 date: ProductDate,
4194 issue: Option<&str>,
4195) -> Result<&'static [&'static str], DataCatalogError> {
4196 ProductDate::new(date.year, date.month, date.day)?;
4197 product_convention(center, product_type)?;
4198 validate_product_date(center, product_type, date)?;
4199
4200 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4201 if entry.issues.is_empty() {
4202 validate_issue_for_center(center, issue)?;
4203 } else {
4204 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
4205 }
4206 supported_samples_inner(center, product_type, date, issue)
4207}
4208
4209fn supported_samples_inner(
4210 center: AnalysisCenter,
4211 product_type: ProductType,
4212 date: ProductDate,
4213 issue: Option<&str>,
4214) -> Result<&'static [&'static str], DataCatalogError> {
4215 if product_type != ProductType::Sp3 {
4216 let convention = product_convention(center, product_type)?;
4217 return Ok(match convention.default_sample {
4218 "30S" => &["30S"],
4219 "01H" => &["01H"],
4220 "02H" => &["02H"],
4221 "01D" => &["01D"],
4222 _ => &[],
4223 });
4224 }
4225
4226 Ok(match center {
4227 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
4228 AnalysisCenter::Esa
4229 | AnalysisCenter::Cod
4230 | AnalysisCenter::CodUlt
4231 | AnalysisCenter::WumNrt => &["05M"],
4232 AnalysisCenter::Gfz => {
4233 if date < GFZ_RAPID_5M_START_DATE {
4234 &["15M"]
4235 } else {
4236 &["05M"]
4237 }
4238 }
4239 AnalysisCenter::EsaUlt => {
4240 let issue = issue.unwrap_or("0000");
4241 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4242 || (date == ESA_ULTRA_15M_LAST_DATE
4243 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4244 if at_or_before_last_15m {
4245 &["15M"]
4246 } else {
4247 &["05M"]
4248 }
4249 }
4250 AnalysisCenter::GfzUlt => {
4251 if date < GFZ_ULTRA_15M_LAST_DATE {
4252 &["15M"]
4253 } else if date == GFZ_ULTRA_15M_LAST_DATE {
4254 if issue.unwrap_or("0000") == "0000" {
4255 &["15M", "05M"]
4256 } else {
4257 &["15M"]
4258 }
4259 } else {
4260 &["05M"]
4261 }
4262 }
4263 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
4264 })
4265}
4266
4267fn validate_product_date(
4268 center: AnalysisCenter,
4269 product_type: ProductType,
4270 date: ProductDate,
4271) -> Result<(), DataCatalogError> {
4272 if center == AnalysisCenter::Igs
4276 && product_type == ProductType::Sp3
4277 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
4278 {
4279 return Err(DataCatalogError::UnsupportedProductEra {
4280 center,
4281 product_type,
4282 date,
4283 });
4284 }
4285
4286 if center == AnalysisCenter::Cod
4291 && matches!(
4292 product_type,
4293 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
4294 )
4295 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
4296 {
4297 return Err(DataCatalogError::UnsupportedProductEra {
4298 center,
4299 product_type,
4300 date,
4301 });
4302 }
4303
4304 let start_date = match (center, product_type) {
4305 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
4306 Some(ESA_FINAL_SERIES_START_DATE)
4307 }
4308 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
4309 Some(GFZ_RAPID_SERIES_START_DATE)
4310 }
4311 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
4312 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
4313 (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
4314 _ => None,
4315 };
4316 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4317 && product_type == ProductType::Sp3
4318 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4319 if before_long_name_start || start_date.is_some_and(|start| date < start) {
4320 return Err(DataCatalogError::UnsupportedProductEra {
4321 center,
4322 product_type,
4323 date,
4324 });
4325 }
4326 Ok(())
4327}
4328
4329fn default_sample_for_product_issue(
4330 center: AnalysisCenter,
4331 product_type: ProductType,
4332 date: ProductDate,
4333 issue: Option<&str>,
4334) -> Result<&'static str, DataCatalogError> {
4335 ProductDate::new(date.year, date.month, date.day)?;
4336 let current = default_sample(center, product_type)?;
4337 validate_product_date(center, product_type, date)?;
4338
4339 if product_type != ProductType::Sp3 {
4340 return Ok(current);
4341 }
4342 match center {
4343 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4344 AnalysisCenter::EsaUlt => {
4345 let issue = issue.unwrap_or("0000");
4349 validate_issue_for_center(center, Some(issue))?;
4350 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4351 || (date == ESA_ULTRA_15M_LAST_DATE
4352 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4353 if at_or_before_last_15m {
4354 Ok("15M")
4355 } else {
4356 Ok(current)
4357 }
4358 }
4359 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4360 _ => Ok(current),
4361 }
4362}
4363
4364fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4365 let gps_week = identity.date.gps_week()?;
4366 let esa_mgex_final_sp3 =
4367 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4368 if identity.analysis_center == AnalysisCenter::WumNrt {
4372 return Err(DataCatalogError::UnsupportedDistributionEra {
4373 source: DistributionSource::NasaCddis,
4374 center: identity.analysis_center,
4375 product_type: identity.family,
4376 date: identity.date,
4377 });
4378 }
4379 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4380 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4381 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4382 let unmodeled_pretransition_ionex =
4383 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4384 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4385 Err(DataCatalogError::UnsupportedDistributionEra {
4386 source: DistributionSource::NasaCddis,
4387 center: identity.analysis_center,
4388 product_type: identity.family,
4389 date: identity.date,
4390 })
4391 } else {
4392 Ok(())
4393 }
4394}
4395
4396fn validate_issue_for_center(
4397 center: AnalysisCenter,
4398 issue: Option<&str>,
4399) -> Result<(), DataCatalogError> {
4400 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4401 match (entry.issues.is_empty(), issue) {
4402 (true, None) => Ok(()),
4403 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4404 (false, None) => Err(DataCatalogError::MissingIssue { center }),
4405 (false, Some(issue)) => {
4406 validate_issue(issue)?;
4407 if entry.issues.contains(&issue) {
4408 Ok(())
4409 } else {
4410 Err(DataCatalogError::UnsupportedIssue {
4411 center,
4412 issue: issue.to_string(),
4413 })
4414 }
4415 }
4416 }
4417}
4418
4419fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4420 if validate_period_token(sample) {
4421 Ok(())
4422 } else {
4423 Err(DataCatalogError::InvalidSample(sample.to_string()))
4424 }
4425}
4426
4427fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4428 if validate_period_token(span) {
4429 Ok(())
4430 } else {
4431 Err(DataCatalogError::InvalidSpan(span.to_string()))
4432 }
4433}
4434
4435fn validate_period_token(token: &str) -> bool {
4436 let bytes = token.as_bytes();
4437 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4438 return false;
4439 }
4440 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4441 match bytes[2] {
4442 b'S' | b'M' => amount > 0 && amount % 60 != 0,
4447 b'H' => amount > 0 && amount % 24 != 0,
4448 b'D' | b'W' | b'L' | b'Y' => amount > 0,
4449 b'U' => amount == 0,
4452 _ => false,
4453 }
4454}
4455
4456fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4457 let bytes = issue.as_bytes();
4458 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4459 if !valid_digits {
4460 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4461 }
4462 let hour = issue[0..2]
4463 .parse::<u8>()
4464 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4465 let minute = issue[2..4]
4466 .parse::<u8>()
4467 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4468 if hour <= 23 && minute <= 59 {
4469 Ok(())
4470 } else {
4471 Err(DataCatalogError::InvalidIssue(issue.to_string()))
4472 }
4473}
4474
4475fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4476 let bytes = station.as_bytes();
4477 let valid = bytes.len() == 9
4478 && bytes
4479 .iter()
4480 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4481 if valid {
4482 Ok(())
4483 } else {
4484 Err(DataCatalogError::InvalidStation(station.to_string()))
4485 }
4486}
4487
4488fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4489 validate_issue(issue)?;
4490 let hour = issue[0..2]
4491 .parse::<u16>()
4492 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4493 let minute = issue[2..4]
4494 .parse::<u16>()
4495 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4496 Ok(hour * 60 + minute)
4497}
4498
4499fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4500 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4501}
4502
4503fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4504 format!(
4505 "{}{:03}{}",
4506 date.year,
4507 date.day_of_year(),
4508 issue.unwrap_or("0000")
4509 )
4510}
4511
4512fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4513 Ok(match layout {
4514 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4515 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4516 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4517 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4518 ArchiveLayout::BkgBrdcYearDoy => {
4519 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4520 }
4521 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4522 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4523 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4524 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4525 })
4526}
4527
4528fn product_dir_path(
4529 center: AnalysisCenter,
4530 layout: ArchiveLayout,
4531 date: ProductDate,
4532) -> Result<String, DataCatalogError> {
4533 match center {
4534 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4535 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4536 _ => dir_path(layout, date),
4537 }
4538}
4539
4540fn uses_legacy_igs_final_name(
4541 center: AnalysisCenter,
4542 product_type: ProductType,
4543 date: ProductDate,
4544) -> Result<bool, DataCatalogError> {
4545 Ok(center == AnalysisCenter::Igs
4546 && product_type == ProductType::Sp3
4547 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4548}
4549
4550fn product_archive_compression(
4551 center: AnalysisCenter,
4552 product_type: ProductType,
4553 date: ProductDate,
4554 default: ArchiveCompression,
4555) -> Result<ArchiveCompression, DataCatalogError> {
4556 if uses_legacy_igs_final_name(center, product_type, date)? {
4557 Ok(ArchiveCompression::UnixCompress)
4558 } else {
4559 Ok(default)
4560 }
4561}
4562
4563fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4564 let (year, month, day) = civil_from_julian_day_number(jdn);
4565 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4566 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4567 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4568 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4569}
4570
4571#[cfg(test)]
4572mod content_start_tests {
4573 use super::*;
4574
4575 const GFZ_ISSUES: [&str; 8] = [
4576 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4577 ];
4578
4579 fn date(year: i32, month: u8, day: u8) -> ProductDate {
4580 ProductDate::new(year, month, day).expect("test date")
4581 }
4582
4583 fn offset(
4584 center: AnalysisCenter,
4585 product_date: ProductDate,
4586 sample: &str,
4587 issue: Option<&str>,
4588 ) -> i64 {
4589 let identity =
4590 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4591 .expect("cataloged SP3 identity");
4592 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4593 }
4594
4595 #[test]
4596 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4597 for issue in GFZ_ISSUES {
4598 assert_eq!(
4599 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4600 -86_400,
4601 "2022-09-06 issue {issue}"
4602 );
4603 }
4604 }
4605
4606 #[test]
4607 fn gfz_ultra_transition_is_cataloged_per_issue() {
4608 let day_seven = [
4609 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4610 ];
4611 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4612
4613 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4614 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4615 assert_eq!(
4616 offset(
4617 AnalysisCenter::GfzUlt,
4618 date(2022, 9, product_day),
4619 "05M",
4620 Some(issue)
4621 ),
4622 expected_offset,
4623 "2022-09-{product_day:02} issue {issue}"
4624 );
4625 }
4626 }
4627 }
4628
4629 #[test]
4630 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4631 for issue in GFZ_ISSUES {
4632 assert_eq!(
4633 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4634 0,
4635 "2022-09-09 issue {issue}"
4636 );
4637 }
4638
4639 let current = date(2026, 7, 20);
4640 let cases = [
4641 (AnalysisCenter::Igs, "15M", None),
4642 (AnalysisCenter::Esa, "05M", None),
4643 (AnalysisCenter::Cod, "05M", None),
4644 (AnalysisCenter::Gfz, "05M", None),
4645 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4646 (AnalysisCenter::CodUlt, "05M", Some("0000")),
4647 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4648 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4649 ];
4650 for (center, sample, issue) in cases {
4651 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4652 }
4653 }
4654
4655 #[test]
4656 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4657 assert_eq!(
4658 offset(
4659 AnalysisCenter::GfzUlt,
4660 date(2021, 5, 15),
4661 "15M",
4662 Some("0000")
4663 ),
4664 -86_400
4665 );
4666 assert_eq!(
4667 offset(
4668 AnalysisCenter::GfzUlt,
4669 date(2021, 5, 16),
4670 "05M",
4671 Some("0000")
4672 ),
4673 -86_400
4674 );
4675 }
4676
4677 #[test]
4678 fn public_content_start_query_enforces_center_issue_rules() {
4679 assert_eq!(
4680 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4681 Err(DataCatalogError::UnsupportedIssue {
4682 center: AnalysisCenter::GfzUlt,
4683 issue: "0130".to_owned(),
4684 })
4685 );
4686 assert_eq!(
4687 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4688 Err(DataCatalogError::UnexpectedIssue {
4689 center: AnalysisCenter::Gfz,
4690 })
4691 );
4692 assert_eq!(
4693 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4694 Err(DataCatalogError::MissingIssue {
4695 center: AnalysisCenter::GfzUlt,
4696 })
4697 );
4698 }
4699}