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 UnrecognizedArchiveListing {
1335 reason: String,
1337 },
1338 InvalidStation(String),
1340 InvalidCoordinate {
1342 lat_deg_bits: u64,
1344 lon_deg_bits: u64,
1346 },
1347 InvalidTileIndex {
1349 lat_index: i32,
1351 lon_index: i32,
1353 },
1354 InvalidTileId(String),
1356}
1357
1358impl fmt::Display for DataCatalogError {
1359 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1360 match self {
1361 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1362 Self::UnknownProductType(product_type) => {
1363 write!(f, "unknown product type {product_type:?}")
1364 }
1365 Self::UnsupportedProduct {
1366 center,
1367 product_type,
1368 } => write!(f, "{center} does not serve {product_type}"),
1369 Self::UnsupportedDistribution {
1370 source,
1371 product_type,
1372 } => write!(
1373 f,
1374 "distributor {} does not serve {product_type}",
1375 source.code()
1376 ),
1377 Self::UnsupportedProductEra {
1378 center,
1379 product_type,
1380 date,
1381 } => write!(
1382 f,
1383 "{center}/{product_type} has no cataloged naming convention for {date}"
1384 ),
1385 Self::UnsupportedDistributionEra {
1386 source,
1387 center,
1388 product_type,
1389 date,
1390 } => write!(
1391 f,
1392 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1393 source.code()
1394 ),
1395 Self::NoDistributionSources => {
1396 write!(f, "exact product request has no distributors")
1397 }
1398 Self::InvalidOfficialFilename(filename) => {
1399 write!(f, "invalid official product filename {filename:?}")
1400 }
1401 Self::InconsistentProductIdentity { field } => {
1402 write!(
1403 f,
1404 "product identity field {field:?} disagrees with its official filename"
1405 )
1406 }
1407 Self::NoOpenMirror {
1408 center,
1409 product_type,
1410 } => write!(f, "{center}/{product_type} has no open mirror"),
1411 Self::InvalidDate { year, month, day } => {
1412 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1413 }
1414 Self::DateOutOfRange => write!(f, "product date is out of range"),
1415 Self::DateBeforeGpsEpoch(date) => {
1416 write!(f, "product date {date} is before the GPS week epoch")
1417 }
1418 Self::InvalidGpsDayOfWeek(day) => {
1419 write!(f, "invalid GPS day-of-week {day}")
1420 }
1421 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1422 Self::UnsupportedSample {
1423 center,
1424 product_type,
1425 sample,
1426 } => write!(
1427 f,
1428 "{center}/{product_type} does not publish sample interval {sample:?}"
1429 ),
1430 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1431 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1432 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1433 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1434 Self::UnsupportedIssue { center, issue } => {
1435 write!(f, "{center} does not publish issue {issue:?}")
1436 }
1437 Self::InvalidDateTime {
1438 hour,
1439 minute,
1440 second,
1441 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1442 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1443 Self::NoAvailableUltraIssue => {
1444 write!(f, "no available ultra-rapid issue at or before target")
1445 }
1446 Self::UnrecognizedArchiveListing { reason } => {
1447 write!(f, "unrecognized archive listing: {reason}")
1448 }
1449 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1450 Self::InvalidCoordinate {
1451 lat_deg_bits,
1452 lon_deg_bits,
1453 } => write!(
1454 f,
1455 "invalid terrain coordinate lat={} lon={}",
1456 f64::from_bits(*lat_deg_bits),
1457 f64::from_bits(*lon_deg_bits)
1458 ),
1459 Self::InvalidTileIndex {
1460 lat_index,
1461 lon_index,
1462 } => write!(
1463 f,
1464 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1465 ),
1466 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1467 }
1468 }
1469}
1470
1471impl std::error::Error for DataCatalogError {}
1472
1473#[derive(Debug, Clone, PartialEq, Eq)]
1475pub enum HgtConversionError {
1476 BadLength {
1478 expected: usize,
1480 got: usize,
1482 },
1483 InvalidTileIndex {
1485 lat_index: i32,
1487 lon_index: i32,
1489 },
1490}
1491
1492impl fmt::Display for HgtConversionError {
1493 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1494 match self {
1495 Self::BadLength { expected, got } => {
1496 write!(
1497 f,
1498 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1499 )
1500 }
1501 Self::InvalidTileIndex {
1502 lat_index,
1503 lon_index,
1504 } => write!(
1505 f,
1506 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1507 ),
1508 }
1509 }
1510}
1511
1512impl std::error::Error for HgtConversionError {}
1513
1514const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1515const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1516const MIN_TERRAIN_LON_INDEX: i32 = -180;
1517const MAX_TERRAIN_LON_INDEX: i32 = 179;
1518const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1519const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1520const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1521const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1522const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1523const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1524const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1525const DTED_SRTM1_LEN: usize =
1526 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1527
1528#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1530pub struct ProductDate {
1531 pub year: i32,
1533 pub month: u8,
1535 pub day: u8,
1537}
1538
1539impl ProductDate {
1540 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1542 let days = days_in_month(i64::from(year), i64::from(month));
1543 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1544 return Err(DataCatalogError::InvalidDate { year, month, day });
1545 }
1546 Ok(Self { year, month, day })
1547 }
1548
1549 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1551 if day_of_week > 6 {
1552 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1553 }
1554 let epoch_jdn =
1555 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1556 let offset_days = i64::from(week)
1557 .checked_mul(7)
1558 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1559 .ok_or(DataCatalogError::DateOutOfRange)?;
1560 product_date_from_jdn(
1561 epoch_jdn
1562 .checked_add(offset_days)
1563 .ok_or(DataCatalogError::DateOutOfRange)?,
1564 )
1565 }
1566
1567 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1569 week_from_calendar(
1570 TimeScale::Gpst,
1571 i64::from(self.year),
1572 i64::from(self.month),
1573 i64::from(self.day),
1574 )
1575 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1576 }
1577
1578 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1580 let epoch_jdn =
1581 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1582 let days = self
1583 .julian_day_number()
1584 .checked_sub(epoch_jdn)
1585 .ok_or(DataCatalogError::DateOutOfRange)?;
1586 if days < 0 {
1587 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1588 }
1589 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1590 }
1591
1592 #[must_use]
1594 pub fn day_of_year(self) -> u16 {
1595 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1596 }
1597
1598 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1599 product_date_from_jdn(
1600 self.julian_day_number()
1601 .checked_add(days)
1602 .ok_or(DataCatalogError::DateOutOfRange)?,
1603 )
1604 }
1605
1606 fn julian_day_number(self) -> i64 {
1607 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1608 }
1609}
1610
1611impl fmt::Display for ProductDate {
1612 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1613 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1614 }
1615}
1616
1617#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1619pub struct ProductDateTime {
1620 pub date: ProductDate,
1622 pub hour: u8,
1624 pub minute: u8,
1626 pub second: u8,
1628}
1629
1630impl ProductDateTime {
1631 pub fn new(
1633 date: ProductDate,
1634 hour: u8,
1635 minute: u8,
1636 second: u8,
1637 ) -> Result<Self, DataCatalogError> {
1638 if hour > 23 || minute > 59 || second > 59 {
1639 return Err(DataCatalogError::InvalidDateTime {
1640 hour,
1641 minute,
1642 second,
1643 });
1644 }
1645 Ok(Self {
1646 date,
1647 hour,
1648 minute,
1649 second,
1650 })
1651 }
1652
1653 fn ordering_minutes(self) -> i64 {
1654 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1655 }
1656}
1657
1658#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1660pub struct UltraIssue {
1661 pub date: ProductDate,
1663 pub issue: String,
1665}
1666
1667impl UltraIssue {
1668 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1670 validate_issue(issue)?;
1671 Ok(Self {
1672 date,
1673 issue: issue.to_string(),
1674 })
1675 }
1676}
1677
1678#[derive(Debug, Clone, PartialEq, Eq)]
1680pub struct UltraSp3Location {
1681 pub pattern: String,
1683 pub span: String,
1685 pub sample: String,
1687 pub filename: String,
1689 pub url: String,
1691 pub compression: ArchiveCompression,
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1701pub struct ProductIdentity {
1702 pub family: ProductType,
1704 pub analysis_center: AnalysisCenter,
1706 pub publisher: ProductPublisher,
1708 pub solution: SolutionClass,
1710 pub campaign: ProductCampaign,
1712 pub version: u8,
1714 pub date: ProductDate,
1720 pub issue: Option<String>,
1722 pub span: String,
1724 pub sample: String,
1726 pub official_filename: String,
1728 pub format: ProductFormat,
1730 pub format_version: Option<String>,
1736 pub prediction_horizon_days: Option<u8>,
1738}
1739
1740impl ProductIdentity {
1741 pub fn validate(&self) -> Result<(), DataCatalogError> {
1747 validate_official_filename(&self.official_filename)?;
1748 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1749 validate_sample(&self.sample)?;
1750 validate_span(&self.span)?;
1751 if let Some(issue) = self.issue.as_deref() {
1752 validate_issue(issue)?;
1753 }
1754
1755 let convention = product_convention(self.analysis_center, self.family)?;
1759 validate_product_date(self.analysis_center, self.family, self.date)?;
1760 if self.span != convention.span {
1761 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1762 }
1763 validate_catalog_sample(
1764 self.analysis_center,
1765 self.family,
1766 self.date,
1767 &self.sample,
1768 self.issue.as_deref(),
1769 )?;
1770
1771 if self.format != product_format(self.family) {
1772 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1773 }
1774
1775 if self
1776 .format_version
1777 .as_deref()
1778 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1779 {
1780 return Err(DataCatalogError::InconsistentProductIdentity {
1781 field: "format_version",
1782 });
1783 }
1784
1785 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1786 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1787 (_, SolutionClass::Predicted, _) => false,
1788 (_, _, None) => true,
1789 (_, _, Some(_)) => false,
1790 };
1791 if !horizon_valid {
1792 return Err(DataCatalogError::InconsistentProductIdentity {
1793 field: "prediction_horizon_days",
1794 });
1795 }
1796 let descriptor = product_type_convention(self.family);
1797 let legacy_igs_final =
1798 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1799 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1800 let entry = center_catalog(self.analysis_center)
1801 .expect("validated analysis center has a catalog entry");
1802 let issue_valid = if entry.issues.is_empty() {
1803 self.issue.as_deref() == Some("0000")
1804 } else {
1805 self.issue
1806 .as_deref()
1807 .is_some_and(|issue| entry.issues.contains(&issue))
1808 };
1809 if !issue_valid {
1810 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1811 }
1812 }
1813 let expected = if legacy_igs_final {
1814 let fields_valid = self.publisher == ProductPublisher::Igs
1815 && self.solution == SolutionClass::Final
1816 && self.campaign == ProductCampaign::Operational
1817 && self.version == 0
1818 && self.issue.as_deref() == Some("0000")
1819 && self.span == convention.span
1820 && self.sample == convention.default_sample;
1821 if !fields_valid {
1822 return Err(DataCatalogError::InconsistentProductIdentity {
1823 field: "legacy_igs_final",
1824 });
1825 }
1826 format!(
1827 "igs{:04}{}.sp3",
1828 self.date.gps_week()?,
1829 self.date.gps_day_of_week()?
1830 )
1831 } else {
1832 match descriptor.kind {
1833 ProductFilenameKind::Sampled => {
1834 let solution_token = self.solution.filename_token().ok_or(
1835 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1836 )?;
1837 format!(
1838 "{}{}{}{}_{}_{}_{}_{}.{}",
1839 self.publisher.code(),
1840 self.version,
1841 self.campaign.code(),
1842 solution_token,
1843 date_block(self.date, self.issue.as_deref()),
1844 self.span,
1845 self.sample,
1846 descriptor.content_code,
1847 descriptor.extension
1848 )
1849 }
1850 ProductFilenameKind::Nav => {
1851 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1852 && self.solution == SolutionClass::Broadcast
1853 && self.campaign == ProductCampaign::Broadcast
1854 && self.version == 0
1855 && self.issue.is_none()
1856 && self.span == "01D"
1857 && self.sample == "01D";
1858 if !nav_fields_valid {
1859 return Err(DataCatalogError::InconsistentProductIdentity {
1860 field: "broadcast_navigation",
1861 });
1862 }
1863 format!(
1864 "BRDC00WRD_R_{}_{}_{}.{}",
1865 date_block(self.date, None),
1866 self.span,
1867 descriptor.content_code,
1868 descriptor.extension
1869 )
1870 }
1871 }
1872 };
1873 if expected != self.official_filename {
1874 return Err(DataCatalogError::InconsistentProductIdentity {
1875 field: "official_filename",
1876 });
1877 }
1878 if self.publisher != self.analysis_center.publisher()
1879 || self.solution != product_solution_class(self.analysis_center, self.family)?
1880 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1881 {
1882 return Err(DataCatalogError::InconsistentProductIdentity {
1883 field: "analysis_center",
1884 });
1885 }
1886
1887 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1888 let expected_catalog_filename = format!(
1889 "{}_{}_{}_{}_{}.{}",
1890 convention.token,
1891 date_block(self.date, self.issue.as_deref()),
1892 self.span,
1893 self.sample,
1894 descriptor.content_code,
1895 descriptor.extension
1896 );
1897 if expected_catalog_filename != self.official_filename {
1898 return Err(DataCatalogError::InconsistentProductIdentity {
1899 field: "analysis_center",
1900 });
1901 }
1902 }
1903 Ok(())
1904 }
1905
1906 pub fn key(&self) -> Result<String, DataCatalogError> {
1908 use sha2::{Digest, Sha256};
1909
1910 let canonical = self.canonical_bytes()?;
1911 let digest = Sha256::digest(canonical);
1912 Ok(format!(
1913 "{}-{}-{}",
1914 self.publisher.code().to_ascii_lowercase(),
1915 self.solution.code(),
1916 digest[..10]
1917 .iter()
1918 .map(|byte| format!("{byte:02x}"))
1919 .collect::<String>()
1920 ))
1921 }
1922
1923 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1929 self.validate()?;
1930 let date = format!(
1931 "{:04}-{:02}-{:02}",
1932 self.date.year, self.date.month, self.date.day
1933 );
1934 let version = self.version.to_string();
1935 let prediction = self
1936 .prediction_horizon_days
1937 .map(|days| days.to_string())
1938 .unwrap_or_default();
1939 let fields = [
1940 self.family.code(),
1941 self.analysis_center.code(),
1942 self.publisher.code(),
1943 self.solution.code(),
1944 self.campaign.code(),
1945 version.as_str(),
1946 date.as_str(),
1947 self.issue.as_deref().unwrap_or_default(),
1948 self.span.as_str(),
1949 self.sample.as_str(),
1950 self.official_filename.as_str(),
1951 self.format.code(),
1952 self.format_version.as_deref().unwrap_or_default(),
1953 prediction.as_str(),
1954 ];
1955 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1956 return Err(DataCatalogError::InconsistentProductIdentity {
1957 field: "canonical_encoding",
1958 });
1959 }
1960 Ok(fields.join("\0").into_bytes())
1961 }
1962
1963 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1965 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1966 }
1967}
1968
1969pub(crate) fn exact_sp3_content_start_offset_s(
1975 identity: &ProductIdentity,
1976) -> Result<i64, DataCatalogError> {
1977 identity.validate()?;
1978 if identity.family != ProductType::Sp3 {
1979 return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
1980 }
1981
1982 let entry =
1983 center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
1984 let catalog_issue = if entry.issues.is_empty() {
1988 None
1989 } else {
1990 identity.issue.as_deref()
1991 };
1992 Ok(
1993 sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
1994 .content_start_offset_s(),
1995 )
1996}
1997
1998pub fn sp3_content_start_convention(
2006 center: AnalysisCenter,
2007 date: ProductDate,
2008 issue: Option<&str>,
2009) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2010 ProductDate::new(date.year, date.month, date.day)?;
2011 product_convention(center, ProductType::Sp3)?;
2012 validate_product_date(center, ProductType::Sp3, date)?;
2013 validate_issue_for_center(center, issue)?;
2014
2015 sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2016 DataCatalogError::UnsupportedIssue {
2017 center,
2018 issue: issue.unwrap_or_default().to_owned(),
2019 }
2020 })
2021}
2022
2023fn sp3_content_start_convention_inner(
2024 center: AnalysisCenter,
2025 date: ProductDate,
2026 issue: Option<&str>,
2027) -> Option<Sp3ContentStartConvention> {
2028 if center != AnalysisCenter::GfzUlt {
2029 return Some(Sp3ContentStartConvention::FilenameEpoch);
2030 }
2031 if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2032 return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2033 }
2034 if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2035 return Some(Sp3ContentStartConvention::FilenameEpoch);
2036 }
2037
2038 let issue = issue?;
2039 GFZ_ULTRA_START_TRANSITION
2040 .iter()
2041 .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2042 .map(|(_, _, convention)| *convention)
2043}
2044
2045#[derive(Debug, Clone, PartialEq, Eq)]
2047pub struct DistributionLocation {
2048 pub source: DistributionSource,
2050 pub original_url: Option<String>,
2052 pub archive_filename: String,
2054 pub compression: ArchiveCompression,
2056}
2057
2058#[derive(Debug, Clone, PartialEq, Eq)]
2060pub struct ProductRequest {
2061 pub identity: ProductIdentity,
2063 pub distributors: Vec<DistributionSource>,
2065}
2066
2067#[derive(Debug, Clone, PartialEq, Eq)]
2069pub enum ExactProductSetError {
2070 EmptyExpected,
2072 InvalidExpected {
2074 index: usize,
2076 source: DataCatalogError,
2078 },
2079 InvalidAvailable {
2081 index: usize,
2083 source: DataCatalogError,
2085 },
2086 Mismatch {
2088 missing: Vec<ProductIdentity>,
2090 unexpected: Vec<ProductIdentity>,
2092 duplicate_expected: Vec<ProductIdentity>,
2094 duplicate_available: Vec<ProductIdentity>,
2096 },
2097}
2098
2099impl fmt::Display for ExactProductSetError {
2100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2101 match self {
2102 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2103 Self::InvalidExpected { index, source } => {
2104 write!(f, "expected product {index} is invalid: {source}")
2105 }
2106 Self::InvalidAvailable { index, source } => {
2107 write!(f, "available product {index} is invalid: {source}")
2108 }
2109 Self::Mismatch {
2110 missing,
2111 unexpected,
2112 duplicate_expected,
2113 duplicate_available,
2114 } => write!(
2115 f,
2116 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2117 identity_list(missing),
2118 identity_list(unexpected),
2119 identity_list(duplicate_expected),
2120 identity_list(duplicate_available),
2121 ),
2122 }
2123 }
2124}
2125
2126impl std::error::Error for ExactProductSetError {}
2127
2128pub fn validate_exact_product_set(
2142 expected: &[ProductIdentity],
2143 available: &[ProductIdentity],
2144) -> Result<(), ExactProductSetError> {
2145 if expected.is_empty() {
2146 return Err(ExactProductSetError::EmptyExpected);
2147 }
2148 for (index, identity) in expected.iter().enumerate() {
2149 identity
2150 .validate()
2151 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2152 }
2153 for (index, identity) in available.iter().enumerate() {
2154 identity
2155 .validate()
2156 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2157 }
2158
2159 let expected_counts = identity_counts(expected);
2160 let available_counts = identity_counts(available);
2161 let missing = unique_matching(expected, |identity| {
2162 !available_counts.contains_key(identity)
2163 });
2164 let unexpected = unique_matching(available, |identity| {
2165 !expected_counts.contains_key(identity)
2166 });
2167 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2168 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2169
2170 if missing.is_empty()
2171 && unexpected.is_empty()
2172 && duplicate_expected.is_empty()
2173 && duplicate_available.is_empty()
2174 {
2175 Ok(())
2176 } else {
2177 Err(ExactProductSetError::Mismatch {
2178 missing,
2179 unexpected,
2180 duplicate_expected,
2181 duplicate_available,
2182 })
2183 }
2184}
2185
2186fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2187 let mut counts = HashMap::with_capacity(identities.len());
2188 for identity in identities {
2189 *counts.entry(identity).or_insert(0) += 1;
2190 }
2191 counts
2192}
2193
2194fn unique_matching(
2195 identities: &[ProductIdentity],
2196 mut predicate: impl FnMut(&ProductIdentity) -> bool,
2197) -> Vec<ProductIdentity> {
2198 let mut seen = HashSet::with_capacity(identities.len());
2199 identities
2200 .iter()
2201 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2202 .cloned()
2203 .collect()
2204}
2205
2206fn identity_list(identities: &[ProductIdentity]) -> String {
2207 if identities.is_empty() {
2208 return "none".to_string();
2209 }
2210 identities
2211 .iter()
2212 .map(|identity| {
2213 identity
2214 .key()
2215 .unwrap_or_else(|_| identity.official_filename.clone())
2216 })
2217 .collect::<Vec<_>>()
2218 .join(", ")
2219}
2220
2221impl ProductRequest {
2222 pub fn new(
2224 identity: ProductIdentity,
2225 distributors: Vec<DistributionSource>,
2226 ) -> Result<Self, DataCatalogError> {
2227 if distributors.is_empty() {
2228 return Err(DataCatalogError::NoDistributionSources);
2229 }
2230 identity.validate()?;
2231 Ok(Self {
2232 identity,
2233 distributors,
2234 })
2235 }
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2240pub struct ProductSpec {
2241 pub center: AnalysisCenter,
2243 pub product_type: ProductType,
2245 pub date: ProductDate,
2247 pub sample: String,
2249 pub issue: Option<String>,
2251}
2252
2253impl ProductSpec {
2254 pub fn new(
2256 center: AnalysisCenter,
2257 product_type: ProductType,
2258 date: ProductDate,
2259 sample: &str,
2260 issue: Option<&str>,
2261 ) -> Result<Self, DataCatalogError> {
2262 ProductDate::new(date.year, date.month, date.day)?;
2263 validate_product(center, product_type, date, sample, issue)?;
2264 Ok(Self {
2265 center,
2266 product_type,
2267 date,
2268 sample: sample.to_string(),
2269 issue: issue.map(ToOwned::to_owned),
2270 })
2271 }
2272
2273 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2275 self.date.gps_week()
2276 }
2277
2278 #[must_use]
2280 pub fn day_of_year(&self) -> u16 {
2281 self.date.day_of_year()
2282 }
2283
2284 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2290 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2291 let convention = validate_product(
2292 self.center,
2293 self.product_type,
2294 self.date,
2295 &self.sample,
2296 self.issue.as_deref(),
2297 )?;
2298 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2299 return Ok(format!(
2300 "igs{:04}{}.sp3",
2301 self.date.gps_week()?,
2302 self.date.gps_day_of_week()?
2303 ));
2304 }
2305 let descriptor = product_type_convention(self.product_type);
2306 Ok(match descriptor.kind {
2307 ProductFilenameKind::Sampled => format!(
2308 "{}_{}_{}_{}_{}.{}",
2309 convention.token,
2310 date_block(self.date, self.issue.as_deref()),
2311 convention.span,
2312 self.sample,
2313 descriptor.content_code,
2314 descriptor.extension
2315 ),
2316 ProductFilenameKind::Nav => format!(
2317 "{}_R_{}_{}_{}.{}",
2318 convention.token,
2319 date_block(self.date, None),
2320 convention.span,
2321 descriptor.content_code,
2322 descriptor.extension
2323 ),
2324 })
2325 }
2326
2327 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2329 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2330 let convention = validate_product(
2331 self.center,
2332 self.product_type,
2333 self.date,
2334 &self.sample,
2335 self.issue.as_deref(),
2336 )?;
2337 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2338 return Err(DataCatalogError::UnsupportedDistributionEra {
2339 source: DistributionSource::Direct,
2340 center: self.center,
2341 product_type: self.product_type,
2342 date: self.date,
2343 });
2344 }
2345 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2346 let filename = self.canonical_filename()?;
2347 let compression = product_archive_compression(
2348 self.center,
2349 self.product_type,
2350 self.date,
2351 convention.compression,
2352 )?;
2353 Ok(format!(
2354 "{}/{}/{}{}",
2355 entry.root_url,
2356 product_dir_path(self.center, convention.layout, self.date)?,
2357 filename,
2358 compression.suffix()
2359 ))
2360 }
2361
2362 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2364 let convention = validate_product(
2365 self.center,
2366 self.product_type,
2367 self.date,
2368 &self.sample,
2369 self.issue.as_deref(),
2370 )?;
2371 let descriptor = product_type_convention(self.product_type);
2372 let campaign = match descriptor.kind {
2373 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2374 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2375 Some("OPS") => ProductCampaign::Operational,
2376 Some("MGN") => ProductCampaign::MultiGnss,
2377 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2378 _ => {
2379 return Err(DataCatalogError::InconsistentProductIdentity {
2380 field: "campaign",
2381 });
2382 }
2383 },
2384 };
2385 let identity = ProductIdentity {
2386 family: self.product_type,
2387 analysis_center: self.center,
2388 publisher: self.center.publisher(),
2389 solution: product_solution_class(self.center, self.product_type)?,
2390 campaign,
2391 version: 0,
2392 date: self.date,
2393 issue: match descriptor.kind {
2394 ProductFilenameKind::Sampled => {
2395 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2396 }
2397 ProductFilenameKind::Nav => None,
2398 },
2399 span: convention.span.to_string(),
2400 sample: self.sample.clone(),
2401 official_filename: self.canonical_filename()?,
2402 format: product_format(self.product_type),
2403 format_version: None,
2404 prediction_horizon_days: self.center.prediction_horizon_days(),
2405 };
2406 identity.validate()?;
2407 Ok(identity)
2408 }
2409
2410 pub fn distribution_location(
2412 &self,
2413 source: DistributionSource,
2414 ) -> Result<DistributionLocation, DataCatalogError> {
2415 let identity = self.identity()?;
2416 distribution_location_for_identity(&identity, source)
2417 }
2418}
2419
2420#[derive(Debug, Clone, PartialEq, Eq)]
2422pub struct StationObservationSpec {
2423 pub station: String,
2425 pub date: ProductDate,
2427 pub sample: String,
2429}
2430
2431impl StationObservationSpec {
2432 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2434 validate_station(station)?;
2435 validate_sample(sample)?;
2436 Ok(Self {
2437 station: station.to_string(),
2438 date,
2439 sample: sample.to_string(),
2440 })
2441 }
2442
2443 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2445 station_obs_filename(&self.station, self.date, &self.sample)
2446 }
2447
2448 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2450 station_obs_url(&self.station, self.date, &self.sample)
2451 }
2452}
2453
2454#[must_use]
2456pub const fn catalog() -> &'static [CenterCatalogEntry] {
2457 &CATALOG
2458}
2459
2460#[must_use]
2462pub const fn centers() -> &'static [AnalysisCenter] {
2463 &CENTER_ORDER
2464}
2465
2466#[must_use]
2468pub const fn product_types() -> &'static [ProductTypeConvention] {
2469 &PRODUCT_TYPE_CONVENTIONS
2470}
2471
2472#[must_use]
2474pub const fn allowed_hosts() -> &'static [&'static str] {
2475 &ALLOWED_HOSTS
2476}
2477
2478#[must_use]
2480pub const fn skadi_source_entry() -> TerrainSourceEntry {
2481 SKADI_SOURCE
2482}
2483
2484#[must_use]
2486pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2487 CELESTRAK_SPACE_WEATHER_SOURCE
2488}
2489
2490#[must_use]
2492pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2493 match product {
2494 SpaceWeatherProduct::All => "SW-All.csv",
2495 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2496 }
2497}
2498
2499#[must_use]
2501pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2502 format!(
2503 "{}/{}",
2504 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2505 space_weather_filename(product)
2506 )
2507}
2508
2509#[must_use]
2511pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2512 format!("space-weather/{}", space_weather_filename(product))
2513}
2514
2515pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2517 validate_terrain_tile_index(lat_index, lon_index)?;
2518 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2519 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2520 Ok(format!(
2521 "{lat_hemi}{:02}{lon_hemi}{:03}",
2522 lat_index.abs(),
2523 lon_index.abs()
2524 ))
2525}
2526
2527pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2529 validate_terrain_lat_index(lat_index)?;
2530 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2531 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2532}
2533
2534pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2536 let band = skadi_band(lat_index)?;
2537 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2538 Ok(format!(
2539 "{}/skadi/{}/{}.hgt{}",
2540 SKADI_SOURCE.root_url,
2541 band,
2542 tile_id,
2543 SKADI_SOURCE.compression.suffix()
2544 ))
2545}
2546
2547pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2549 validate_terrain_tile_index(lat_index, lon_index)?;
2550 Ok(format!(
2551 "{}_{}{}",
2552 terrain::format_lat(lat_index),
2553 terrain::format_lon(lon_index),
2554 terrain::DTED_SUFFIX
2555 ))
2556}
2557
2558pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2560 validate_terrain_tile_index(lat_index, lon_index)?;
2561 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2562}
2563
2564pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2566 Ok(format!(
2567 "{}/{}",
2568 dted_block_dir(lat_index, lon_index)?,
2569 dted_tile_filename(lat_index, lon_index)?
2570 ))
2571}
2572
2573pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2575 let bytes = id.as_bytes();
2576 if bytes.len() != 7
2577 || !matches!(bytes[0], b'N' | b'S')
2578 || !matches!(bytes[3], b'E' | b'W')
2579 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2580 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2581 {
2582 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2583 }
2584
2585 let lat_abs = id[1..3]
2586 .parse::<i32>()
2587 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2588 let lon_abs = id[4..7]
2589 .parse::<i32>()
2590 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2591 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2592 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2593 }
2594
2595 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2596 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2597 validate_terrain_tile_index(lat_index, lon_index)?;
2598 Ok((lat_index, lon_index))
2599}
2600
2601pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2603 if !lat_deg.is_finite()
2604 || !lon_deg.is_finite()
2605 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2606 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2607 {
2608 return Err(DataCatalogError::InvalidCoordinate {
2609 lat_deg_bits: lat_deg.to_bits(),
2610 lon_deg_bits: lon_deg.to_bits(),
2611 });
2612 }
2613
2614 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2615 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2616 lat_index = MAX_TERRAIN_LAT_INDEX;
2617 }
2618 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2619 lon_index = MAX_TERRAIN_LON_INDEX;
2620 }
2621 validate_terrain_tile_index(lat_index, lon_index)?;
2622 Ok((lat_index, lon_index))
2623}
2624
2625pub fn hgt_to_dted(
2633 lat_index: i32,
2634 lon_index: i32,
2635 hgt: &[u8],
2636) -> Result<Vec<u8>, HgtConversionError> {
2637 validate_hgt_tile_index(lat_index, lon_index)?;
2638 if hgt.len() != SRTM1_HGT_LEN {
2639 return Err(HgtConversionError::BadLength {
2640 expected: SRTM1_HGT_LEN,
2641 got: hgt.len(),
2642 });
2643 }
2644
2645 let mut out = vec![b' '; DTED_SRTM1_LEN];
2646 out[0..4].copy_from_slice(b"UHL1");
2647 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2648 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2649 out[47..51].copy_from_slice(b"3601");
2650 out[51..55].copy_from_slice(b"3601");
2651
2652 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2653 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2654 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2655 out[block_start] = terrain::DATA_SENTINEL;
2656
2657 let count = (lon_posting as u32).to_be_bytes();
2658 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2659 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2660 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2661
2662 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2663 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2664 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2665 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2666 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2667 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2668 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2669 }
2670
2671 let checksum = out[block_start..checksum_start]
2672 .iter()
2673 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2674 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2675 }
2676
2677 debug_assert_eq!(out.len(), 25_981_042);
2678 Ok(out)
2679}
2680
2681#[must_use]
2683pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2684 &NO_OPEN_MIRRORS
2685}
2686
2687pub fn open_mirror(
2689 center: AnalysisCenter,
2690 product_type: ProductType,
2691) -> Result<(), DataCatalogError> {
2692 open_mirror_code(center.code(), product_type.code())
2693}
2694
2695pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2697 if NO_OPEN_MIRRORS
2698 .iter()
2699 .any(|entry| entry.center == center && entry.product_type == product_type)
2700 {
2701 Err(DataCatalogError::NoOpenMirror {
2702 center: center.to_string(),
2703 product_type: product_type.to_string(),
2704 })
2705 } else {
2706 Ok(())
2707 }
2708}
2709
2710#[must_use]
2712pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2713 CATALOG.iter().find(|entry| entry.center == center)
2714}
2715
2716pub fn product_convention(
2718 center: AnalysisCenter,
2719 product_type: ProductType,
2720) -> Result<&'static CenterProductConvention, DataCatalogError> {
2721 open_mirror(center, product_type)?;
2722 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2723 entry
2724 .products
2725 .iter()
2726 .find(|product| product.product_type == product_type)
2727 .ok_or(DataCatalogError::UnsupportedProduct {
2728 center,
2729 product_type,
2730 })
2731}
2732
2733pub fn product_solution_class(
2741 center: AnalysisCenter,
2742 product_type: ProductType,
2743) -> Result<SolutionClass, DataCatalogError> {
2744 product_convention(center, product_type)?;
2745 Ok(match (center, product_type) {
2746 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2747 _ => center.solution_class(),
2748 })
2749}
2750
2751pub fn default_sample(
2757 center: AnalysisCenter,
2758 product_type: ProductType,
2759) -> Result<&'static str, DataCatalogError> {
2760 Ok(product_convention(center, product_type)?.default_sample)
2761}
2762
2763pub fn default_sample_for_date(
2771 center: AnalysisCenter,
2772 product_type: ProductType,
2773 date: ProductDate,
2774) -> Result<&'static str, DataCatalogError> {
2775 default_sample_for_product_issue(center, product_type, date, None)
2776}
2777
2778pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2780 date.gps_week()
2781}
2782
2783#[must_use]
2785pub fn day_of_year(date: ProductDate) -> u16 {
2786 date.day_of_year()
2787}
2788
2789pub fn product(
2791 center: AnalysisCenter,
2792 product_type: ProductType,
2793 date: ProductDate,
2794 sample: Option<&str>,
2795 issue: Option<&str>,
2796) -> Result<ProductSpec, DataCatalogError> {
2797 let sample = match sample {
2798 Some(sample) => sample,
2799 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2800 };
2801 ProductSpec::new(center, product_type, date, sample, issue)
2802}
2803
2804pub fn canonical_filename(
2806 center: AnalysisCenter,
2807 product_type: ProductType,
2808 date: ProductDate,
2809 sample: Option<&str>,
2810 issue: Option<&str>,
2811) -> Result<String, DataCatalogError> {
2812 product(center, product_type, date, sample, issue)?.canonical_filename()
2813}
2814
2815pub fn archive_url(
2817 center: AnalysisCenter,
2818 product_type: ProductType,
2819 date: ProductDate,
2820 sample: Option<&str>,
2821 issue: Option<&str>,
2822) -> Result<String, DataCatalogError> {
2823 product(center, product_type, date, sample, issue)?.archive_url()
2824}
2825
2826pub fn product_identity(
2828 center: AnalysisCenter,
2829 product_type: ProductType,
2830 date: ProductDate,
2831 sample: Option<&str>,
2832 issue: Option<&str>,
2833) -> Result<ProductIdentity, DataCatalogError> {
2834 product(center, product_type, date, sample, issue)?.identity()
2835}
2836
2837pub fn distribution_location(
2839 center: AnalysisCenter,
2840 product_type: ProductType,
2841 date: ProductDate,
2842 sample: Option<&str>,
2843 issue: Option<&str>,
2844 source: DistributionSource,
2845) -> Result<DistributionLocation, DataCatalogError> {
2846 product(center, product_type, date, sample, issue)?.distribution_location(source)
2847}
2848
2849pub fn distribution_location_for_identity(
2856 identity: &ProductIdentity,
2857 source: DistributionSource,
2858) -> Result<DistributionLocation, DataCatalogError> {
2859 identity.validate()?;
2860 match source {
2861 DistributionSource::Direct => {
2862 let convention = product_convention(identity.analysis_center, identity.family)?;
2863 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2864 {
2865 return Err(DataCatalogError::UnsupportedDistributionEra {
2866 source,
2867 center: identity.analysis_center,
2868 product_type: identity.family,
2869 date: identity.date,
2870 });
2871 }
2872 let entry = center_catalog(identity.analysis_center)
2873 .expect("validated analysis center has a catalog entry");
2874 let compression = product_archive_compression(
2875 identity.analysis_center,
2876 identity.family,
2877 identity.date,
2878 convention.compression,
2879 )?;
2880 let url = format!(
2881 "{}/{}/{}{}",
2882 entry.root_url,
2883 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2884 identity.official_filename,
2885 compression.suffix()
2886 );
2887 Ok(DistributionLocation {
2888 source,
2889 original_url: Some(url),
2890 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2891 compression,
2892 })
2893 }
2894 DistributionSource::NasaCddis => {
2895 validate_cddis_distribution_era(identity)?;
2896 let compression = product_archive_compression(
2897 identity.analysis_center,
2898 identity.family,
2899 identity.date,
2900 ArchiveCompression::Gzip,
2901 )?;
2902 Ok(DistributionLocation {
2903 source,
2904 original_url: Some(cddis_archive_url(identity)?),
2905 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2906 compression,
2907 })
2908 }
2909 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2910 source,
2911 original_url: None,
2912 archive_filename: identity.official_filename.clone(),
2913 compression: ArchiveCompression::None,
2914 }),
2915 }
2916}
2917
2918pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2927 identity.validate()?;
2928 validate_cddis_distribution_era(identity)?;
2929 match identity.family {
2930 ProductType::Sp3 => {
2931 let compression = product_archive_compression(
2932 identity.analysis_center,
2933 identity.family,
2934 identity.date,
2935 ArchiveCompression::Gzip,
2936 )?;
2937 Ok(format!(
2938 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2939 identity.date.gps_week()?,
2940 identity.official_filename,
2941 compression.suffix()
2942 ))
2943 }
2944 ProductType::Ionex => Ok(format!(
2945 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2946 identity.date.year,
2947 identity.date.day_of_year(),
2948 identity.official_filename
2949 )),
2950 product_type => Err(DataCatalogError::UnsupportedDistribution {
2951 source: DistributionSource::NasaCddis,
2952 product_type,
2953 }),
2954 }
2955}
2956
2957pub fn mgex_clk(
2959 center: AnalysisCenter,
2960 date: ProductDate,
2961 sample: Option<&str>,
2962) -> Result<ProductSpec, DataCatalogError> {
2963 product(center, ProductType::Clk, date, sample, None)
2964}
2965
2966pub fn mgex_nav(
2968 center: AnalysisCenter,
2969 date: ProductDate,
2970 sample: Option<&str>,
2971) -> Result<ProductSpec, DataCatalogError> {
2972 product(center, ProductType::Nav, date, sample, None)
2973}
2974
2975pub fn mgex_ionex(
2977 center: AnalysisCenter,
2978 date: ProductDate,
2979 sample: Option<&str>,
2980) -> Result<ProductSpec, DataCatalogError> {
2981 product(center, ProductType::Ionex, date, sample, None)
2982}
2983
2984pub fn rapid_ionex(
2986 date: ProductDate,
2987 sample: Option<&str>,
2988) -> Result<ProductSpec, DataCatalogError> {
2989 product(
2990 AnalysisCenter::CodRap,
2991 ProductType::Ionex,
2992 date,
2993 sample,
2994 None,
2995 )
2996}
2997
2998#[must_use]
3000pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
3001 match center {
3002 AnalysisCenter::CodPrd2 => 1,
3003 _ => 0,
3004 }
3005}
3006
3007pub fn predicted_ionex(
3009 center: AnalysisCenter,
3010 date: ProductDate,
3011 sample: Option<&str>,
3012) -> Result<ProductSpec, DataCatalogError> {
3013 match center {
3014 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3015 let target = date.add_days(predicted_day_offset(center))?;
3016 product(center, ProductType::Ionex, target, sample, None)
3017 }
3018 other => Err(DataCatalogError::UnsupportedProduct {
3019 center: other,
3020 product_type: ProductType::Ionex,
3021 }),
3022 }
3023}
3024
3025pub fn mgex_sp3(
3027 center: AnalysisCenter,
3028 date: ProductDate,
3029 sample: Option<&str>,
3030) -> Result<ProductSpec, DataCatalogError> {
3031 product(center, ProductType::Sp3, date, sample, None)
3032}
3033
3034pub fn ops_ultra_sp3(
3036 center: AnalysisCenter,
3037 date: ProductDate,
3038 sample: Option<&str>,
3039 issue: Option<&str>,
3040) -> Result<ProductSpec, DataCatalogError> {
3041 let issue = issue.unwrap_or("0000");
3042 product(center, ProductType::Sp3, date, sample, Some(issue))
3043}
3044
3045pub fn ultra_sp3_locations(
3055 center: AnalysisCenter,
3056 date: ProductDate,
3057 issue: &str,
3058) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3059 validate_issue_for_center(center, Some(issue))?;
3060 validate_product_date(center, ProductType::Sp3, date)?;
3061 match center {
3062 AnalysisCenter::IgsUlt
3063 | AnalysisCenter::CodUlt
3064 | AnalysisCenter::EsaUlt
3065 | AnalysisCenter::GfzUlt
3066 | AnalysisCenter::WumNrt => {}
3067 other => {
3068 return Err(DataCatalogError::UnsupportedProduct {
3069 center: other,
3070 product_type: ProductType::Sp3,
3071 })
3072 }
3073 };
3074 let default_sample =
3075 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3076 let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3077 samples.sort_by_key(|sample| *sample != default_sample);
3078
3079 samples
3080 .into_iter()
3081 .map(|sample| {
3082 let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3086 let identity = spec.identity()?;
3087 let filename = spec.canonical_filename()?;
3088 let url = spec.archive_url()?;
3089 let convention = product_convention(center, ProductType::Sp3)?;
3090 let compression = product_archive_compression(
3091 center,
3092 ProductType::Sp3,
3093 date,
3094 convention.compression,
3095 )?;
3096 Ok(UltraSp3Location {
3097 pattern: if sample == default_sample {
3098 format!("primary_{}_{}", identity.span, sample)
3099 } else {
3100 format!("alternate_{}_{}", identity.span, sample)
3101 },
3102 span: identity.span,
3103 sample: sample.to_string(),
3104 url,
3105 filename,
3106 compression,
3107 })
3108 })
3109 .collect()
3110}
3111
3112pub fn ops_ultra_clk(
3114 center: AnalysisCenter,
3115 date: ProductDate,
3116 sample: Option<&str>,
3117 issue: Option<&str>,
3118) -> Result<ProductSpec, DataCatalogError> {
3119 let issue = issue.unwrap_or("0000");
3120 product(center, ProductType::Clk, date, sample, Some(issue))
3121}
3122
3123pub fn latest_ops_ultra_sp3(
3125 center: AnalysisCenter,
3126 target: ProductDateTime,
3127 sample: Option<&str>,
3128 available_issues: Option<&[UltraIssue]>,
3129) -> Result<ProductSpec, DataCatalogError> {
3130 let selected = latest_ultra_issue(center, target, available_issues)?;
3131 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3132}
3133
3134pub fn ultra_issue_candidates(
3136 center: AnalysisCenter,
3137 target: ProductDateTime,
3138) -> Result<Vec<UltraIssue>, DataCatalogError> {
3139 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3140 let _ = product_convention(center, ProductType::Sp3)?;
3141 if entry.issues.is_empty() {
3142 return Err(DataCatalogError::UnsupportedProduct {
3143 center,
3144 product_type: ProductType::Sp3,
3145 });
3146 }
3147 validate_product_date(center, ProductType::Sp3, target.date)?;
3148
3149 let mut candidates = Vec::new();
3150 for date in [target.date, target.date.add_days(-1)?] {
3151 match validate_product_date(center, ProductType::Sp3, date) {
3152 Ok(()) => {}
3153 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3154 Err(error) => return Err(error),
3155 }
3156 for issue in entry.issues.iter().rev() {
3157 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3158 candidates.push(UltraIssue::new(date, issue)?);
3159 }
3160 }
3161 }
3162 Ok(candidates)
3163}
3164
3165pub fn latest_ultra_issue(
3167 center: AnalysisCenter,
3168 target: ProductDateTime,
3169 available_issues: Option<&[UltraIssue]>,
3170) -> Result<UltraIssue, DataCatalogError> {
3171 let candidates = ultra_issue_candidates(center, target)?;
3172 if candidates.is_empty() {
3173 return Err(DataCatalogError::NoUltraIssue);
3174 }
3175 if let Some(available) = available_issues {
3176 candidates
3177 .into_iter()
3178 .find(|candidate| {
3179 available
3180 .iter()
3181 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3182 })
3183 .ok_or(DataCatalogError::NoAvailableUltraIssue)
3184 } else {
3185 Ok(candidates[0].clone())
3186 }
3187}
3188
3189pub fn predicted_ionex_line_candidates(
3220 map_date: ProductDate,
3221 sample: Option<&str>,
3222) -> Result<Vec<ProductSpec>, DataCatalogError> {
3223 let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3224 let two_day_production_date = map_date.add_days(-1)?;
3225 let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3226 if one_day.date != map_date || two_day.date != map_date {
3230 return Err(DataCatalogError::InconsistentProductIdentity {
3231 field: "predicted_ionex_map_date",
3232 });
3233 }
3234 Ok(vec![one_day, two_day])
3235}
3236
3237pub fn gim_date_candidates(
3239 center: AnalysisCenter,
3240 target: ProductDate,
3241 lookback: u32,
3242) -> Result<Vec<ProductDate>, DataCatalogError> {
3243 let _ = product_convention(center, ProductType::Ionex)?;
3244 let base = target.add_days(predicted_day_offset(center))?;
3245 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3246 for back in 0..=lookback {
3247 out.push(base.add_days(-i64::from(back))?);
3248 }
3249 Ok(out)
3250}
3251
3252#[derive(Debug, Clone, PartialEq, Eq)]
3283pub struct PublishedObject {
3284 pub path: String,
3286 pub observed_at: Option<String>,
3288}
3289
3290#[derive(Debug, Clone, PartialEq, Eq)]
3293pub struct PublishedProduct {
3294 pub date: ProductDate,
3296 pub issue: String,
3298 pub filename: String,
3300 pub observed_at: Option<String>,
3302}
3303
3304pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3333 let mut seen: Vec<PublishedObject> = Vec::new();
3334 let mut push = |path: String, observed_at: Option<String>| {
3335 if let Some(existing) = seen.iter_mut().find(|object| object.path == path) {
3336 if existing.observed_at.is_none() {
3337 existing.observed_at = observed_at;
3338 }
3339 } else {
3340 seen.push(PublishedObject { path, observed_at });
3341 }
3342 };
3343 let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3344 reason: reason.to_string(),
3345 };
3346
3347 let non_empty: Vec<&str> = body
3348 .lines()
3349 .map(str::trim_end)
3350 .filter(|line| !line.trim().is_empty())
3351 .collect();
3352 if non_empty.is_empty() {
3353 return Err(unrecognized("empty body"));
3354 }
3355 let has_markup = body.contains('<');
3356
3357 if !has_markup && non_empty[0].matches(';').count() >= 3 {
3359 for line in &non_empty {
3360 if line.matches(';').count() < 3 {
3361 return Err(unrecognized("CSV row without its four fields"));
3362 }
3363 let mut fields = line.split(';');
3364 let (Some(path), Some(_bytes), Some(observed)) =
3365 (fields.next(), fields.next(), fields.next())
3366 else {
3367 return Err(unrecognized("CSV row without its four fields"));
3368 };
3369 if path.is_empty() || path.contains(' ') {
3370 return Err(unrecognized("CSV row without an archive path"));
3371 }
3372 if path.ends_with('/') {
3374 continue;
3375 }
3376 let observed_at =
3377 (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3378 push(path.to_string(), observed_at);
3379 }
3380 return Ok(seen);
3381 }
3382
3383 if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3385 for (index, line) in non_empty.iter().enumerate() {
3386 if index == 0 && line.starts_with("total ") {
3387 continue;
3388 }
3389 let mode_shaped = line.len() > 10
3390 && line.starts_with(['-', 'd', 'l'])
3391 && line.as_bytes()[1..10]
3392 .iter()
3393 .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3394 if !mode_shaped {
3395 return Err(unrecognized("FTP LIST row without a Unix mode field"));
3396 }
3397 if !line.starts_with('-') {
3399 continue;
3400 }
3401 let fields: Vec<&str> = line.split_whitespace().collect();
3402 if fields.len() < 9 {
3403 return Err(unrecognized("FTP LIST file row without nine fields"));
3404 }
3405 push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3406 }
3407 return Ok(seen);
3408 }
3409
3410 if has_markup && body.contains("Index of") {
3412 for line in &non_empty {
3413 let mut rest = *line;
3416 while let Some(start) = rest.find("<a href=\"") {
3417 rest = &rest[start + 9..];
3418 let Some(end) = rest.find('"') else { break };
3419 let target = &rest[..end];
3420 rest = &rest[end..];
3421 if target.is_empty()
3422 || target.starts_with('?')
3423 || target.starts_with('/')
3424 || target.starts_with('#')
3425 || target.contains("://")
3426 || target.ends_with('/')
3427 {
3428 continue;
3429 }
3430 let observed_at = find_listing_datetime(rest).map(str::to_string);
3431 push(target.to_string(), observed_at);
3432 }
3433 }
3434 return Ok(seen);
3435 }
3436
3437 Err(unrecognized(if has_markup {
3438 "markup without an autoindex marker"
3439 } else {
3440 "no known listing grammar"
3441 }))
3442}
3443
3444fn find_listing_datetime(rest: &str) -> Option<&str> {
3446 let bytes = rest.as_bytes();
3447 let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3448 for start in 0..bytes.len().saturating_sub(15) {
3449 let shape_matches = is_digit(start)
3450 && is_digit(start + 1)
3451 && is_digit(start + 2)
3452 && is_digit(start + 3)
3453 && bytes[start + 4] == b'-'
3454 && is_digit(start + 5)
3455 && is_digit(start + 6)
3456 && bytes[start + 7] == b'-'
3457 && is_digit(start + 8)
3458 && is_digit(start + 9)
3459 && bytes[start + 10] == b' '
3460 && is_digit(start + 11)
3461 && is_digit(start + 12)
3462 && bytes[start + 13] == b':'
3463 && is_digit(start + 14)
3464 && is_digit(start + 15);
3465 if shape_matches {
3466 return Some(&rest[start..start + 16]);
3467 }
3468 }
3469 None
3470}
3471
3472const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3475 match center {
3476 AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3477 AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3478 _ => None,
3479 }
3480}
3481
3482fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3483 match center_path_marker(center) {
3484 Some(marker) => {
3487 let slashed = format!("/{path}");
3488 slashed.contains(marker)
3489 }
3490 None => true,
3491 }
3492}
3493
3494pub fn newest_published_product(
3508 center: AnalysisCenter,
3509 product_type: ProductType,
3510 objects: &[PublishedObject],
3511) -> Result<Option<PublishedProduct>, DataCatalogError> {
3512 let convention = product_convention(center, product_type)?;
3513 let descriptor = product_type_convention(product_type);
3514 let suffix = format!(".{}", descriptor.extension);
3515 let tail = format!("_{}{}", descriptor.content_code, suffix);
3516
3517 let mut newest: Option<(i64, PublishedProduct)> = None;
3518 for object in objects {
3519 if !object_matches_center(center, &object.path) {
3520 continue;
3521 }
3522 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3523 let stripped = listed_name
3524 .strip_suffix(".gz")
3525 .or_else(|| listed_name.strip_suffix(".Z"))
3526 .unwrap_or(listed_name);
3527 let Some(after_token) = stripped
3528 .strip_prefix(convention.token)
3529 .and_then(|rest| rest.strip_prefix('_'))
3530 else {
3531 continue;
3532 };
3533 let Some(middle) = after_token.strip_suffix(&tail) else {
3534 continue;
3535 };
3536 let mut parts = middle.split('_');
3537 let (Some(block), Some(span), Some(sample), None) =
3538 (parts.next(), parts.next(), parts.next(), parts.next())
3539 else {
3540 continue;
3541 };
3542 if span != convention.span || block.len() != 11 {
3543 continue;
3544 }
3545 let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3546 else {
3547 continue;
3548 };
3549 let issue = &block[7..11];
3550 let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3551 continue;
3552 };
3553 if validate_issue(issue).is_err() {
3554 continue;
3555 }
3556 let issue_argument = (!center_catalog(center)
3559 .expect("catalog entry exists for enum variant")
3560 .issues
3561 .is_empty())
3562 .then_some(issue);
3563 match product(center, product_type, date, Some(sample), issue_argument) {
3564 Ok(spec) => {
3565 if spec.canonical_filename()? != stripped {
3566 continue;
3567 }
3568 }
3569 Err(_) => continue,
3570 }
3571 let ordering = issue_ordering_minutes(date, issue)?;
3572 let replace = newest
3573 .as_ref()
3574 .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3575 if replace {
3576 newest = Some((
3577 ordering,
3578 PublishedProduct {
3579 date,
3580 issue: issue.to_string(),
3581 filename: stripped.to_string(),
3582 observed_at: object.observed_at.clone(),
3583 },
3584 ));
3585 }
3586 }
3587 Ok(newest.map(|(_, product)| product))
3588}
3589
3590pub fn published_issue_age_minutes(
3598 published: &PublishedProduct,
3599 now: ProductDateTime,
3600) -> Result<i64, DataCatalogError> {
3601 Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3602}
3603
3604pub fn publication_listing_urls(
3622 center: AnalysisCenter,
3623 product_type: ProductType,
3624 around: ProductDate,
3625) -> Result<Vec<String>, DataCatalogError> {
3626 let convention = product_convention(center, product_type)?;
3627 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3628 match convention.layout {
3629 ArchiveLayout::AiubCodeRoot
3630 | ArchiveLayout::AiubCodeYear
3631 | ArchiveLayout::AiubCodeMgexYear => {
3632 Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3633 }
3634 _ => {
3635 let current = format!(
3636 "{}/{}/",
3637 entry.root_url,
3638 product_dir_path(center, convention.layout, around)?
3639 );
3640 let previous_week_date = around.add_days(-7)?;
3641 let previous = format!(
3642 "{}/{}/",
3643 entry.root_url,
3644 product_dir_path(center, convention.layout, previous_week_date)?
3645 );
3646 let mut urls = vec![current];
3647 if !urls.contains(&previous) {
3648 urls.push(previous);
3649 }
3650 Ok(urls)
3651 }
3652 }
3653}
3654
3655pub fn resolve_first_published(
3665 candidates: &[ProductSpec],
3666 objects: &[PublishedObject],
3667) -> Result<Option<usize>, DataCatalogError> {
3668 for (index, candidate) in candidates.iter().enumerate() {
3669 let filename = candidate.canonical_filename()?;
3670 let convention = product_convention(candidate.center, candidate.product_type)?;
3671 let compression = product_archive_compression(
3672 candidate.center,
3673 candidate.product_type,
3674 candidate.date,
3675 convention.compression,
3676 )?;
3677 let archive_name = format!("{filename}{}", compression.suffix());
3678 let found = objects.iter().any(|object| {
3679 if !object_matches_center(candidate.center, &object.path) {
3680 return false;
3681 }
3682 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3683 listed_name == archive_name || listed_name == filename
3684 });
3685 if found {
3686 return Ok(Some(index));
3687 }
3688 }
3689 Ok(None)
3690}
3691
3692fn product_date_from_year_day(
3693 year: i32,
3694 day_of_year: u16,
3695) -> Result<ProductDate, DataCatalogError> {
3696 if day_of_year == 0 {
3697 return Err(DataCatalogError::DateOutOfRange);
3698 }
3699 ProductDate::new(year, 1, 1)?
3700 .add_days(i64::from(day_of_year) - 1)
3701 .and_then(|date| {
3702 if date.year == year {
3703 Ok(date)
3704 } else {
3705 Err(DataCatalogError::DateOutOfRange)
3706 }
3707 })
3708}
3709
3710pub fn station_obs(
3712 station: &str,
3713 date: ProductDate,
3714 sample: Option<&str>,
3715) -> Result<StationObservationSpec, DataCatalogError> {
3716 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3717}
3718
3719pub fn station_obs_filename(
3721 station: &str,
3722 date: ProductDate,
3723 sample: &str,
3724) -> Result<String, DataCatalogError> {
3725 validate_station(station)?;
3726 validate_sample(sample)?;
3727 Ok(format!(
3728 "{}_R_{}_01D_{}_MO.crx",
3729 station,
3730 date_block(date, None),
3731 sample
3732 ))
3733}
3734
3735pub fn station_obs_url(
3737 station: &str,
3738 date: ProductDate,
3739 sample: &str,
3740) -> Result<String, DataCatalogError> {
3741 let filename = station_obs_filename(station, date, sample)?;
3742 Ok(format!(
3743 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3744 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3745 filename
3746 ))
3747}
3748
3749#[must_use]
3751pub const fn station_obs_protocol() -> ArchiveProtocol {
3752 ArchiveProtocol::Https
3753}
3754
3755fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3756 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3757 Ok(())
3758 } else {
3759 Err(DataCatalogError::InvalidTileIndex {
3760 lat_index,
3761 lon_index: 0,
3762 })
3763 }
3764}
3765
3766fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3767 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3768 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3769 {
3770 Ok(())
3771 } else {
3772 Err(DataCatalogError::InvalidTileIndex {
3773 lat_index,
3774 lon_index,
3775 })
3776 }
3777}
3778
3779fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3780 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3781 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3782 {
3783 Ok(())
3784 } else {
3785 Err(HgtConversionError::InvalidTileIndex {
3786 lat_index,
3787 lon_index,
3788 })
3789 }
3790}
3791
3792fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3793 let hemi = match (is_longitude, index >= 0) {
3794 (true, true) => 'E',
3795 (true, false) => 'W',
3796 (false, true) => 'N',
3797 (false, false) => 'S',
3798 };
3799 format!("{:03}0000{hemi}", index.abs())
3800}
3801
3802fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3803 if sample == i16::MIN {
3804 0
3805 } else if sample >= 0 {
3806 sample as u16
3807 } else {
3808 0x8000 | (-i32::from(sample) as u16)
3809 }
3810}
3811
3812fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3813 PRODUCT_TYPE_CONVENTIONS
3814 .iter()
3815 .find(|descriptor| descriptor.product_type == product_type)
3816 .expect("product descriptor exists for enum variant")
3817}
3818
3819const fn product_format(product_type: ProductType) -> ProductFormat {
3820 match product_type {
3821 ProductType::Sp3 => ProductFormat::Sp3,
3822 ProductType::Ionex => ProductFormat::Ionex,
3823 ProductType::Clk => ProductFormat::RinexClock,
3824 ProductType::Nav => ProductFormat::RinexNavigation,
3825 }
3826}
3827
3828fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3829 if filename.is_empty()
3830 || filename == "."
3831 || filename == ".."
3832 || filename.contains('/')
3833 || filename.contains('\\')
3834 || filename.contains('\0')
3835 || filename.contains("..")
3836 {
3837 Err(DataCatalogError::InvalidOfficialFilename(
3838 filename.to_string(),
3839 ))
3840 } else {
3841 Ok(())
3842 }
3843}
3844
3845fn validate_product(
3846 center: AnalysisCenter,
3847 product_type: ProductType,
3848 date: ProductDate,
3849 sample: &str,
3850 issue: Option<&str>,
3851) -> Result<&'static CenterProductConvention, DataCatalogError> {
3852 let convention = product_convention(center, product_type)?;
3853 validate_sample(sample)?;
3854 validate_issue_for_center(center, issue)?;
3855 validate_product_date(center, product_type, date)?;
3856 validate_catalog_sample(center, product_type, date, sample, issue)?;
3857 Ok(convention)
3858}
3859
3860fn validate_catalog_sample(
3861 center: AnalysisCenter,
3862 product_type: ProductType,
3863 date: ProductDate,
3864 sample: &str,
3865 issue: Option<&str>,
3866) -> Result<(), DataCatalogError> {
3867 let supported = supported_samples_inner(center, product_type, date, issue)?;
3868 if supported.contains(&sample) {
3869 return Ok(());
3870 }
3871 Err(DataCatalogError::UnsupportedSample {
3872 center,
3873 product_type,
3874 sample: sample.to_string(),
3875 })
3876}
3877
3878pub fn supported_samples(
3889 center: AnalysisCenter,
3890 product_type: ProductType,
3891 date: ProductDate,
3892 issue: Option<&str>,
3893) -> Result<&'static [&'static str], DataCatalogError> {
3894 ProductDate::new(date.year, date.month, date.day)?;
3895 product_convention(center, product_type)?;
3896 validate_product_date(center, product_type, date)?;
3897
3898 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3899 if entry.issues.is_empty() {
3900 validate_issue_for_center(center, issue)?;
3901 } else {
3902 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3903 }
3904 supported_samples_inner(center, product_type, date, issue)
3905}
3906
3907fn supported_samples_inner(
3908 center: AnalysisCenter,
3909 product_type: ProductType,
3910 date: ProductDate,
3911 issue: Option<&str>,
3912) -> Result<&'static [&'static str], DataCatalogError> {
3913 if product_type != ProductType::Sp3 {
3914 let convention = product_convention(center, product_type)?;
3915 return Ok(match convention.default_sample {
3916 "30S" => &["30S"],
3917 "01H" => &["01H"],
3918 "02H" => &["02H"],
3919 "01D" => &["01D"],
3920 _ => &[],
3921 });
3922 }
3923
3924 Ok(match center {
3925 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3926 AnalysisCenter::Esa
3927 | AnalysisCenter::Cod
3928 | AnalysisCenter::CodUlt
3929 | AnalysisCenter::WumNrt => &["05M"],
3930 AnalysisCenter::Gfz => {
3931 if date < GFZ_RAPID_5M_START_DATE {
3932 &["15M"]
3933 } else {
3934 &["05M"]
3935 }
3936 }
3937 AnalysisCenter::EsaUlt => {
3938 let issue = issue.unwrap_or("0000");
3939 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3940 || (date == ESA_ULTRA_15M_LAST_DATE
3941 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3942 if at_or_before_last_15m {
3943 &["15M"]
3944 } else {
3945 &["05M"]
3946 }
3947 }
3948 AnalysisCenter::GfzUlt => {
3949 if date < GFZ_ULTRA_15M_LAST_DATE {
3950 &["15M"]
3951 } else if date == GFZ_ULTRA_15M_LAST_DATE {
3952 if issue.unwrap_or("0000") == "0000" {
3953 &["15M", "05M"]
3954 } else {
3955 &["15M"]
3956 }
3957 } else {
3958 &["05M"]
3959 }
3960 }
3961 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3962 })
3963}
3964
3965fn validate_product_date(
3966 center: AnalysisCenter,
3967 product_type: ProductType,
3968 date: ProductDate,
3969) -> Result<(), DataCatalogError> {
3970 if center == AnalysisCenter::Igs
3974 && product_type == ProductType::Sp3
3975 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3976 {
3977 return Err(DataCatalogError::UnsupportedProductEra {
3978 center,
3979 product_type,
3980 date,
3981 });
3982 }
3983
3984 if center == AnalysisCenter::Cod
3989 && matches!(
3990 product_type,
3991 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3992 )
3993 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3994 {
3995 return Err(DataCatalogError::UnsupportedProductEra {
3996 center,
3997 product_type,
3998 date,
3999 });
4000 }
4001
4002 let start_date = match (center, product_type) {
4003 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
4004 Some(ESA_FINAL_SERIES_START_DATE)
4005 }
4006 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
4007 Some(GFZ_RAPID_SERIES_START_DATE)
4008 }
4009 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
4010 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
4011 (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
4012 _ => None,
4013 };
4014 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4015 && product_type == ProductType::Sp3
4016 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4017 if before_long_name_start || start_date.is_some_and(|start| date < start) {
4018 return Err(DataCatalogError::UnsupportedProductEra {
4019 center,
4020 product_type,
4021 date,
4022 });
4023 }
4024 Ok(())
4025}
4026
4027fn default_sample_for_product_issue(
4028 center: AnalysisCenter,
4029 product_type: ProductType,
4030 date: ProductDate,
4031 issue: Option<&str>,
4032) -> Result<&'static str, DataCatalogError> {
4033 ProductDate::new(date.year, date.month, date.day)?;
4034 let current = default_sample(center, product_type)?;
4035 validate_product_date(center, product_type, date)?;
4036
4037 if product_type != ProductType::Sp3 {
4038 return Ok(current);
4039 }
4040 match center {
4041 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4042 AnalysisCenter::EsaUlt => {
4043 let issue = issue.unwrap_or("0000");
4047 validate_issue_for_center(center, Some(issue))?;
4048 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4049 || (date == ESA_ULTRA_15M_LAST_DATE
4050 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4051 if at_or_before_last_15m {
4052 Ok("15M")
4053 } else {
4054 Ok(current)
4055 }
4056 }
4057 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4058 _ => Ok(current),
4059 }
4060}
4061
4062fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4063 let gps_week = identity.date.gps_week()?;
4064 let esa_mgex_final_sp3 =
4065 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4066 if identity.analysis_center == AnalysisCenter::WumNrt {
4070 return Err(DataCatalogError::UnsupportedDistributionEra {
4071 source: DistributionSource::NasaCddis,
4072 center: identity.analysis_center,
4073 product_type: identity.family,
4074 date: identity.date,
4075 });
4076 }
4077 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4078 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4079 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4080 let unmodeled_pretransition_ionex =
4081 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4082 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4083 Err(DataCatalogError::UnsupportedDistributionEra {
4084 source: DistributionSource::NasaCddis,
4085 center: identity.analysis_center,
4086 product_type: identity.family,
4087 date: identity.date,
4088 })
4089 } else {
4090 Ok(())
4091 }
4092}
4093
4094fn validate_issue_for_center(
4095 center: AnalysisCenter,
4096 issue: Option<&str>,
4097) -> Result<(), DataCatalogError> {
4098 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4099 match (entry.issues.is_empty(), issue) {
4100 (true, None) => Ok(()),
4101 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4102 (false, None) => Err(DataCatalogError::MissingIssue { center }),
4103 (false, Some(issue)) => {
4104 validate_issue(issue)?;
4105 if entry.issues.contains(&issue) {
4106 Ok(())
4107 } else {
4108 Err(DataCatalogError::UnsupportedIssue {
4109 center,
4110 issue: issue.to_string(),
4111 })
4112 }
4113 }
4114 }
4115}
4116
4117fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4118 if validate_period_token(sample) {
4119 Ok(())
4120 } else {
4121 Err(DataCatalogError::InvalidSample(sample.to_string()))
4122 }
4123}
4124
4125fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4126 if validate_period_token(span) {
4127 Ok(())
4128 } else {
4129 Err(DataCatalogError::InvalidSpan(span.to_string()))
4130 }
4131}
4132
4133fn validate_period_token(token: &str) -> bool {
4134 let bytes = token.as_bytes();
4135 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4136 return false;
4137 }
4138 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4139 match bytes[2] {
4140 b'S' | b'M' => amount > 0 && amount % 60 != 0,
4145 b'H' => amount > 0 && amount % 24 != 0,
4146 b'D' | b'W' | b'L' | b'Y' => amount > 0,
4147 b'U' => amount == 0,
4150 _ => false,
4151 }
4152}
4153
4154fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4155 let bytes = issue.as_bytes();
4156 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4157 if !valid_digits {
4158 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4159 }
4160 let hour = issue[0..2]
4161 .parse::<u8>()
4162 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4163 let minute = issue[2..4]
4164 .parse::<u8>()
4165 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4166 if hour <= 23 && minute <= 59 {
4167 Ok(())
4168 } else {
4169 Err(DataCatalogError::InvalidIssue(issue.to_string()))
4170 }
4171}
4172
4173fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4174 let bytes = station.as_bytes();
4175 let valid = bytes.len() == 9
4176 && bytes
4177 .iter()
4178 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4179 if valid {
4180 Ok(())
4181 } else {
4182 Err(DataCatalogError::InvalidStation(station.to_string()))
4183 }
4184}
4185
4186fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4187 validate_issue(issue)?;
4188 let hour = issue[0..2]
4189 .parse::<u16>()
4190 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4191 let minute = issue[2..4]
4192 .parse::<u16>()
4193 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4194 Ok(hour * 60 + minute)
4195}
4196
4197fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4198 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4199}
4200
4201fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4202 format!(
4203 "{}{:03}{}",
4204 date.year,
4205 date.day_of_year(),
4206 issue.unwrap_or("0000")
4207 )
4208}
4209
4210fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4211 Ok(match layout {
4212 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4213 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4214 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4215 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4216 ArchiveLayout::BkgBrdcYearDoy => {
4217 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4218 }
4219 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4220 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4221 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4222 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4223 })
4224}
4225
4226fn product_dir_path(
4227 center: AnalysisCenter,
4228 layout: ArchiveLayout,
4229 date: ProductDate,
4230) -> Result<String, DataCatalogError> {
4231 match center {
4232 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4233 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4234 _ => dir_path(layout, date),
4235 }
4236}
4237
4238fn uses_legacy_igs_final_name(
4239 center: AnalysisCenter,
4240 product_type: ProductType,
4241 date: ProductDate,
4242) -> Result<bool, DataCatalogError> {
4243 Ok(center == AnalysisCenter::Igs
4244 && product_type == ProductType::Sp3
4245 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4246}
4247
4248fn product_archive_compression(
4249 center: AnalysisCenter,
4250 product_type: ProductType,
4251 date: ProductDate,
4252 default: ArchiveCompression,
4253) -> Result<ArchiveCompression, DataCatalogError> {
4254 if uses_legacy_igs_final_name(center, product_type, date)? {
4255 Ok(ArchiveCompression::UnixCompress)
4256 } else {
4257 Ok(default)
4258 }
4259}
4260
4261fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4262 let (year, month, day) = civil_from_julian_day_number(jdn);
4263 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4264 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4265 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4266 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4267}
4268
4269#[cfg(test)]
4270mod content_start_tests {
4271 use super::*;
4272
4273 const GFZ_ISSUES: [&str; 8] = [
4274 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4275 ];
4276
4277 fn date(year: i32, month: u8, day: u8) -> ProductDate {
4278 ProductDate::new(year, month, day).expect("test date")
4279 }
4280
4281 fn offset(
4282 center: AnalysisCenter,
4283 product_date: ProductDate,
4284 sample: &str,
4285 issue: Option<&str>,
4286 ) -> i64 {
4287 let identity =
4288 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4289 .expect("cataloged SP3 identity");
4290 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4291 }
4292
4293 #[test]
4294 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4295 for issue in GFZ_ISSUES {
4296 assert_eq!(
4297 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4298 -86_400,
4299 "2022-09-06 issue {issue}"
4300 );
4301 }
4302 }
4303
4304 #[test]
4305 fn gfz_ultra_transition_is_cataloged_per_issue() {
4306 let day_seven = [
4307 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4308 ];
4309 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4310
4311 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4312 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4313 assert_eq!(
4314 offset(
4315 AnalysisCenter::GfzUlt,
4316 date(2022, 9, product_day),
4317 "05M",
4318 Some(issue)
4319 ),
4320 expected_offset,
4321 "2022-09-{product_day:02} issue {issue}"
4322 );
4323 }
4324 }
4325 }
4326
4327 #[test]
4328 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4329 for issue in GFZ_ISSUES {
4330 assert_eq!(
4331 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4332 0,
4333 "2022-09-09 issue {issue}"
4334 );
4335 }
4336
4337 let current = date(2026, 7, 20);
4338 let cases = [
4339 (AnalysisCenter::Igs, "15M", None),
4340 (AnalysisCenter::Esa, "05M", None),
4341 (AnalysisCenter::Cod, "05M", None),
4342 (AnalysisCenter::Gfz, "05M", None),
4343 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4344 (AnalysisCenter::CodUlt, "05M", Some("0000")),
4345 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4346 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4347 ];
4348 for (center, sample, issue) in cases {
4349 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4350 }
4351 }
4352
4353 #[test]
4354 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4355 assert_eq!(
4356 offset(
4357 AnalysisCenter::GfzUlt,
4358 date(2021, 5, 15),
4359 "15M",
4360 Some("0000")
4361 ),
4362 -86_400
4363 );
4364 assert_eq!(
4365 offset(
4366 AnalysisCenter::GfzUlt,
4367 date(2021, 5, 16),
4368 "05M",
4369 Some("0000")
4370 ),
4371 -86_400
4372 );
4373 }
4374
4375 #[test]
4376 fn public_content_start_query_enforces_center_issue_rules() {
4377 assert_eq!(
4378 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4379 Err(DataCatalogError::UnsupportedIssue {
4380 center: AnalysisCenter::GfzUlt,
4381 issue: "0130".to_owned(),
4382 })
4383 );
4384 assert_eq!(
4385 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4386 Err(DataCatalogError::UnexpectedIssue {
4387 center: AnalysisCenter::Gfz,
4388 })
4389 );
4390 assert_eq!(
4391 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4392 Err(DataCatalogError::MissingIssue {
4393 center: AnalysisCenter::GfzUlt,
4394 })
4395 );
4396 }
4397}