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() {
3370 return Err(unrecognized("CSV row without an archive path"));
3371 }
3372 if path.ends_with('/') {
3380 continue;
3381 }
3382 let observed_at =
3383 (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3384 push(path.to_string(), observed_at);
3385 }
3386 return Ok(seen);
3387 }
3388
3389 if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3391 for (index, line) in non_empty.iter().enumerate() {
3392 if index == 0 && line.starts_with("total ") {
3393 continue;
3394 }
3395 let mode_shaped = line.len() > 10
3396 && line.starts_with(['-', 'd', 'l'])
3397 && line.as_bytes()[1..10]
3398 .iter()
3399 .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3400 if !mode_shaped {
3401 return Err(unrecognized("FTP LIST row without a Unix mode field"));
3402 }
3403 if !line.starts_with('-') {
3405 continue;
3406 }
3407 let fields: Vec<&str> = line.split_whitespace().collect();
3408 if fields.len() < 9 {
3409 return Err(unrecognized("FTP LIST file row without nine fields"));
3410 }
3411 push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3412 }
3413 return Ok(seen);
3414 }
3415
3416 if has_markup && body.contains("Index of") {
3418 for line in &non_empty {
3419 let mut rest = *line;
3422 while let Some(start) = rest.find("<a href=\"") {
3423 rest = &rest[start + 9..];
3424 let Some(end) = rest.find('"') else { break };
3425 let target = &rest[..end];
3426 rest = &rest[end..];
3427 if target.is_empty()
3428 || target.starts_with('?')
3429 || target.starts_with('/')
3430 || target.starts_with('#')
3431 || target.contains("://")
3432 || target.ends_with('/')
3433 {
3434 continue;
3435 }
3436 let observed_at = find_listing_datetime(rest).map(str::to_string);
3437 push(target.to_string(), observed_at);
3438 }
3439 }
3440 return Ok(seen);
3441 }
3442
3443 Err(unrecognized(if has_markup {
3444 "markup without an autoindex marker"
3445 } else {
3446 "no known listing grammar"
3447 }))
3448}
3449
3450fn find_listing_datetime(rest: &str) -> Option<&str> {
3452 let bytes = rest.as_bytes();
3453 let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3454 for start in 0..bytes.len().saturating_sub(15) {
3455 let shape_matches = is_digit(start)
3456 && is_digit(start + 1)
3457 && is_digit(start + 2)
3458 && is_digit(start + 3)
3459 && bytes[start + 4] == b'-'
3460 && is_digit(start + 5)
3461 && is_digit(start + 6)
3462 && bytes[start + 7] == b'-'
3463 && is_digit(start + 8)
3464 && is_digit(start + 9)
3465 && bytes[start + 10] == b' '
3466 && is_digit(start + 11)
3467 && is_digit(start + 12)
3468 && bytes[start + 13] == b':'
3469 && is_digit(start + 14)
3470 && is_digit(start + 15);
3471 if shape_matches {
3472 return Some(&rest[start..start + 16]);
3473 }
3474 }
3475 None
3476}
3477
3478const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3481 match center {
3482 AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3483 AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3484 _ => None,
3485 }
3486}
3487
3488fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3489 match center_path_marker(center) {
3490 Some(marker) => {
3493 let slashed = format!("/{path}");
3494 slashed.contains(marker)
3495 }
3496 None => true,
3497 }
3498}
3499
3500pub fn newest_published_product(
3514 center: AnalysisCenter,
3515 product_type: ProductType,
3516 objects: &[PublishedObject],
3517) -> Result<Option<PublishedProduct>, DataCatalogError> {
3518 let convention = product_convention(center, product_type)?;
3519 let descriptor = product_type_convention(product_type);
3520 let suffix = format!(".{}", descriptor.extension);
3521 let tail = format!("_{}{}", descriptor.content_code, suffix);
3522
3523 let mut newest: Option<(i64, PublishedProduct)> = None;
3524 for object in objects {
3525 if !object_matches_center(center, &object.path) {
3526 continue;
3527 }
3528 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3529 let stripped = listed_name
3530 .strip_suffix(".gz")
3531 .or_else(|| listed_name.strip_suffix(".Z"))
3532 .unwrap_or(listed_name);
3533 let Some(after_token) = stripped
3534 .strip_prefix(convention.token)
3535 .and_then(|rest| rest.strip_prefix('_'))
3536 else {
3537 continue;
3538 };
3539 let Some(middle) = after_token.strip_suffix(&tail) else {
3540 continue;
3541 };
3542 let mut parts = middle.split('_');
3543 let (Some(block), Some(span), Some(sample), None) =
3544 (parts.next(), parts.next(), parts.next(), parts.next())
3545 else {
3546 continue;
3547 };
3548 if span != convention.span || block.len() != 11 {
3549 continue;
3550 }
3551 let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3552 else {
3553 continue;
3554 };
3555 let issue = &block[7..11];
3556 let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3557 continue;
3558 };
3559 if validate_issue(issue).is_err() {
3560 continue;
3561 }
3562 let issue_argument = (!center_catalog(center)
3565 .expect("catalog entry exists for enum variant")
3566 .issues
3567 .is_empty())
3568 .then_some(issue);
3569 match product(center, product_type, date, Some(sample), issue_argument) {
3570 Ok(spec) => {
3571 if spec.canonical_filename()? != stripped {
3572 continue;
3573 }
3574 }
3575 Err(_) => continue,
3576 }
3577 let ordering = issue_ordering_minutes(date, issue)?;
3578 let replace = newest
3579 .as_ref()
3580 .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3581 if replace {
3582 newest = Some((
3583 ordering,
3584 PublishedProduct {
3585 date,
3586 issue: issue.to_string(),
3587 filename: stripped.to_string(),
3588 observed_at: object.observed_at.clone(),
3589 },
3590 ));
3591 }
3592 }
3593 Ok(newest.map(|(_, product)| product))
3594}
3595
3596pub fn published_issue_age_minutes(
3604 published: &PublishedProduct,
3605 now: ProductDateTime,
3606) -> Result<i64, DataCatalogError> {
3607 Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3608}
3609
3610pub fn publication_listing_urls(
3628 center: AnalysisCenter,
3629 product_type: ProductType,
3630 around: ProductDate,
3631) -> Result<Vec<String>, DataCatalogError> {
3632 let convention = product_convention(center, product_type)?;
3633 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3634 match convention.layout {
3635 ArchiveLayout::AiubCodeRoot
3636 | ArchiveLayout::AiubCodeYear
3637 | ArchiveLayout::AiubCodeMgexYear => {
3638 Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3639 }
3640 _ => {
3641 let current = format!(
3642 "{}/{}/",
3643 entry.root_url,
3644 product_dir_path(center, convention.layout, around)?
3645 );
3646 let previous_week_date = around.add_days(-7)?;
3647 let previous = format!(
3648 "{}/{}/",
3649 entry.root_url,
3650 product_dir_path(center, convention.layout, previous_week_date)?
3651 );
3652 let mut urls = vec![current];
3653 if !urls.contains(&previous) {
3654 urls.push(previous);
3655 }
3656 Ok(urls)
3657 }
3658 }
3659}
3660
3661pub fn resolve_first_published(
3671 candidates: &[ProductSpec],
3672 objects: &[PublishedObject],
3673) -> Result<Option<usize>, DataCatalogError> {
3674 for (index, candidate) in candidates.iter().enumerate() {
3675 let filename = candidate.canonical_filename()?;
3676 let convention = product_convention(candidate.center, candidate.product_type)?;
3677 let compression = product_archive_compression(
3678 candidate.center,
3679 candidate.product_type,
3680 candidate.date,
3681 convention.compression,
3682 )?;
3683 let archive_name = format!("{filename}{}", compression.suffix());
3684 let found = objects.iter().any(|object| {
3685 if !object_matches_center(candidate.center, &object.path) {
3686 return false;
3687 }
3688 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3689 listed_name == archive_name || listed_name == filename
3690 });
3691 if found {
3692 return Ok(Some(index));
3693 }
3694 }
3695 Ok(None)
3696}
3697
3698fn product_date_from_year_day(
3699 year: i32,
3700 day_of_year: u16,
3701) -> Result<ProductDate, DataCatalogError> {
3702 if day_of_year == 0 {
3703 return Err(DataCatalogError::DateOutOfRange);
3704 }
3705 ProductDate::new(year, 1, 1)?
3706 .add_days(i64::from(day_of_year) - 1)
3707 .and_then(|date| {
3708 if date.year == year {
3709 Ok(date)
3710 } else {
3711 Err(DataCatalogError::DateOutOfRange)
3712 }
3713 })
3714}
3715
3716pub fn station_obs(
3718 station: &str,
3719 date: ProductDate,
3720 sample: Option<&str>,
3721) -> Result<StationObservationSpec, DataCatalogError> {
3722 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3723}
3724
3725pub fn station_obs_filename(
3727 station: &str,
3728 date: ProductDate,
3729 sample: &str,
3730) -> Result<String, DataCatalogError> {
3731 validate_station(station)?;
3732 validate_sample(sample)?;
3733 Ok(format!(
3734 "{}_R_{}_01D_{}_MO.crx",
3735 station,
3736 date_block(date, None),
3737 sample
3738 ))
3739}
3740
3741pub fn station_obs_url(
3743 station: &str,
3744 date: ProductDate,
3745 sample: &str,
3746) -> Result<String, DataCatalogError> {
3747 let filename = station_obs_filename(station, date, sample)?;
3748 Ok(format!(
3749 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3750 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3751 filename
3752 ))
3753}
3754
3755#[must_use]
3757pub const fn station_obs_protocol() -> ArchiveProtocol {
3758 ArchiveProtocol::Https
3759}
3760
3761fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3762 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3763 Ok(())
3764 } else {
3765 Err(DataCatalogError::InvalidTileIndex {
3766 lat_index,
3767 lon_index: 0,
3768 })
3769 }
3770}
3771
3772fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3773 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3774 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3775 {
3776 Ok(())
3777 } else {
3778 Err(DataCatalogError::InvalidTileIndex {
3779 lat_index,
3780 lon_index,
3781 })
3782 }
3783}
3784
3785fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3786 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3787 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3788 {
3789 Ok(())
3790 } else {
3791 Err(HgtConversionError::InvalidTileIndex {
3792 lat_index,
3793 lon_index,
3794 })
3795 }
3796}
3797
3798fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3799 let hemi = match (is_longitude, index >= 0) {
3800 (true, true) => 'E',
3801 (true, false) => 'W',
3802 (false, true) => 'N',
3803 (false, false) => 'S',
3804 };
3805 format!("{:03}0000{hemi}", index.abs())
3806}
3807
3808fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3809 if sample == i16::MIN {
3810 0
3811 } else if sample >= 0 {
3812 sample as u16
3813 } else {
3814 0x8000 | (-i32::from(sample) as u16)
3815 }
3816}
3817
3818fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3819 PRODUCT_TYPE_CONVENTIONS
3820 .iter()
3821 .find(|descriptor| descriptor.product_type == product_type)
3822 .expect("product descriptor exists for enum variant")
3823}
3824
3825const fn product_format(product_type: ProductType) -> ProductFormat {
3826 match product_type {
3827 ProductType::Sp3 => ProductFormat::Sp3,
3828 ProductType::Ionex => ProductFormat::Ionex,
3829 ProductType::Clk => ProductFormat::RinexClock,
3830 ProductType::Nav => ProductFormat::RinexNavigation,
3831 }
3832}
3833
3834fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3835 if filename.is_empty()
3836 || filename == "."
3837 || filename == ".."
3838 || filename.contains('/')
3839 || filename.contains('\\')
3840 || filename.contains('\0')
3841 || filename.contains("..")
3842 {
3843 Err(DataCatalogError::InvalidOfficialFilename(
3844 filename.to_string(),
3845 ))
3846 } else {
3847 Ok(())
3848 }
3849}
3850
3851fn validate_product(
3852 center: AnalysisCenter,
3853 product_type: ProductType,
3854 date: ProductDate,
3855 sample: &str,
3856 issue: Option<&str>,
3857) -> Result<&'static CenterProductConvention, DataCatalogError> {
3858 let convention = product_convention(center, product_type)?;
3859 validate_sample(sample)?;
3860 validate_issue_for_center(center, issue)?;
3861 validate_product_date(center, product_type, date)?;
3862 validate_catalog_sample(center, product_type, date, sample, issue)?;
3863 Ok(convention)
3864}
3865
3866fn validate_catalog_sample(
3867 center: AnalysisCenter,
3868 product_type: ProductType,
3869 date: ProductDate,
3870 sample: &str,
3871 issue: Option<&str>,
3872) -> Result<(), DataCatalogError> {
3873 let supported = supported_samples_inner(center, product_type, date, issue)?;
3874 if supported.contains(&sample) {
3875 return Ok(());
3876 }
3877 Err(DataCatalogError::UnsupportedSample {
3878 center,
3879 product_type,
3880 sample: sample.to_string(),
3881 })
3882}
3883
3884pub fn supported_samples(
3895 center: AnalysisCenter,
3896 product_type: ProductType,
3897 date: ProductDate,
3898 issue: Option<&str>,
3899) -> Result<&'static [&'static str], DataCatalogError> {
3900 ProductDate::new(date.year, date.month, date.day)?;
3901 product_convention(center, product_type)?;
3902 validate_product_date(center, product_type, date)?;
3903
3904 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3905 if entry.issues.is_empty() {
3906 validate_issue_for_center(center, issue)?;
3907 } else {
3908 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3909 }
3910 supported_samples_inner(center, product_type, date, issue)
3911}
3912
3913fn supported_samples_inner(
3914 center: AnalysisCenter,
3915 product_type: ProductType,
3916 date: ProductDate,
3917 issue: Option<&str>,
3918) -> Result<&'static [&'static str], DataCatalogError> {
3919 if product_type != ProductType::Sp3 {
3920 let convention = product_convention(center, product_type)?;
3921 return Ok(match convention.default_sample {
3922 "30S" => &["30S"],
3923 "01H" => &["01H"],
3924 "02H" => &["02H"],
3925 "01D" => &["01D"],
3926 _ => &[],
3927 });
3928 }
3929
3930 Ok(match center {
3931 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3932 AnalysisCenter::Esa
3933 | AnalysisCenter::Cod
3934 | AnalysisCenter::CodUlt
3935 | AnalysisCenter::WumNrt => &["05M"],
3936 AnalysisCenter::Gfz => {
3937 if date < GFZ_RAPID_5M_START_DATE {
3938 &["15M"]
3939 } else {
3940 &["05M"]
3941 }
3942 }
3943 AnalysisCenter::EsaUlt => {
3944 let issue = issue.unwrap_or("0000");
3945 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3946 || (date == ESA_ULTRA_15M_LAST_DATE
3947 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3948 if at_or_before_last_15m {
3949 &["15M"]
3950 } else {
3951 &["05M"]
3952 }
3953 }
3954 AnalysisCenter::GfzUlt => {
3955 if date < GFZ_ULTRA_15M_LAST_DATE {
3956 &["15M"]
3957 } else if date == GFZ_ULTRA_15M_LAST_DATE {
3958 if issue.unwrap_or("0000") == "0000" {
3959 &["15M", "05M"]
3960 } else {
3961 &["15M"]
3962 }
3963 } else {
3964 &["05M"]
3965 }
3966 }
3967 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3968 })
3969}
3970
3971fn validate_product_date(
3972 center: AnalysisCenter,
3973 product_type: ProductType,
3974 date: ProductDate,
3975) -> Result<(), DataCatalogError> {
3976 if center == AnalysisCenter::Igs
3980 && product_type == ProductType::Sp3
3981 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3982 {
3983 return Err(DataCatalogError::UnsupportedProductEra {
3984 center,
3985 product_type,
3986 date,
3987 });
3988 }
3989
3990 if center == AnalysisCenter::Cod
3995 && matches!(
3996 product_type,
3997 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3998 )
3999 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
4000 {
4001 return Err(DataCatalogError::UnsupportedProductEra {
4002 center,
4003 product_type,
4004 date,
4005 });
4006 }
4007
4008 let start_date = match (center, product_type) {
4009 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
4010 Some(ESA_FINAL_SERIES_START_DATE)
4011 }
4012 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
4013 Some(GFZ_RAPID_SERIES_START_DATE)
4014 }
4015 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
4016 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
4017 (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
4018 _ => None,
4019 };
4020 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4021 && product_type == ProductType::Sp3
4022 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4023 if before_long_name_start || start_date.is_some_and(|start| date < start) {
4024 return Err(DataCatalogError::UnsupportedProductEra {
4025 center,
4026 product_type,
4027 date,
4028 });
4029 }
4030 Ok(())
4031}
4032
4033fn default_sample_for_product_issue(
4034 center: AnalysisCenter,
4035 product_type: ProductType,
4036 date: ProductDate,
4037 issue: Option<&str>,
4038) -> Result<&'static str, DataCatalogError> {
4039 ProductDate::new(date.year, date.month, date.day)?;
4040 let current = default_sample(center, product_type)?;
4041 validate_product_date(center, product_type, date)?;
4042
4043 if product_type != ProductType::Sp3 {
4044 return Ok(current);
4045 }
4046 match center {
4047 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4048 AnalysisCenter::EsaUlt => {
4049 let issue = issue.unwrap_or("0000");
4053 validate_issue_for_center(center, Some(issue))?;
4054 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4055 || (date == ESA_ULTRA_15M_LAST_DATE
4056 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4057 if at_or_before_last_15m {
4058 Ok("15M")
4059 } else {
4060 Ok(current)
4061 }
4062 }
4063 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4064 _ => Ok(current),
4065 }
4066}
4067
4068fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4069 let gps_week = identity.date.gps_week()?;
4070 let esa_mgex_final_sp3 =
4071 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4072 if identity.analysis_center == AnalysisCenter::WumNrt {
4076 return Err(DataCatalogError::UnsupportedDistributionEra {
4077 source: DistributionSource::NasaCddis,
4078 center: identity.analysis_center,
4079 product_type: identity.family,
4080 date: identity.date,
4081 });
4082 }
4083 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4084 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4085 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4086 let unmodeled_pretransition_ionex =
4087 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4088 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4089 Err(DataCatalogError::UnsupportedDistributionEra {
4090 source: DistributionSource::NasaCddis,
4091 center: identity.analysis_center,
4092 product_type: identity.family,
4093 date: identity.date,
4094 })
4095 } else {
4096 Ok(())
4097 }
4098}
4099
4100fn validate_issue_for_center(
4101 center: AnalysisCenter,
4102 issue: Option<&str>,
4103) -> Result<(), DataCatalogError> {
4104 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4105 match (entry.issues.is_empty(), issue) {
4106 (true, None) => Ok(()),
4107 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4108 (false, None) => Err(DataCatalogError::MissingIssue { center }),
4109 (false, Some(issue)) => {
4110 validate_issue(issue)?;
4111 if entry.issues.contains(&issue) {
4112 Ok(())
4113 } else {
4114 Err(DataCatalogError::UnsupportedIssue {
4115 center,
4116 issue: issue.to_string(),
4117 })
4118 }
4119 }
4120 }
4121}
4122
4123fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4124 if validate_period_token(sample) {
4125 Ok(())
4126 } else {
4127 Err(DataCatalogError::InvalidSample(sample.to_string()))
4128 }
4129}
4130
4131fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4132 if validate_period_token(span) {
4133 Ok(())
4134 } else {
4135 Err(DataCatalogError::InvalidSpan(span.to_string()))
4136 }
4137}
4138
4139fn validate_period_token(token: &str) -> bool {
4140 let bytes = token.as_bytes();
4141 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4142 return false;
4143 }
4144 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4145 match bytes[2] {
4146 b'S' | b'M' => amount > 0 && amount % 60 != 0,
4151 b'H' => amount > 0 && amount % 24 != 0,
4152 b'D' | b'W' | b'L' | b'Y' => amount > 0,
4153 b'U' => amount == 0,
4156 _ => false,
4157 }
4158}
4159
4160fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4161 let bytes = issue.as_bytes();
4162 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4163 if !valid_digits {
4164 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4165 }
4166 let hour = issue[0..2]
4167 .parse::<u8>()
4168 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4169 let minute = issue[2..4]
4170 .parse::<u8>()
4171 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4172 if hour <= 23 && minute <= 59 {
4173 Ok(())
4174 } else {
4175 Err(DataCatalogError::InvalidIssue(issue.to_string()))
4176 }
4177}
4178
4179fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4180 let bytes = station.as_bytes();
4181 let valid = bytes.len() == 9
4182 && bytes
4183 .iter()
4184 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4185 if valid {
4186 Ok(())
4187 } else {
4188 Err(DataCatalogError::InvalidStation(station.to_string()))
4189 }
4190}
4191
4192fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4193 validate_issue(issue)?;
4194 let hour = issue[0..2]
4195 .parse::<u16>()
4196 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4197 let minute = issue[2..4]
4198 .parse::<u16>()
4199 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4200 Ok(hour * 60 + minute)
4201}
4202
4203fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4204 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4205}
4206
4207fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4208 format!(
4209 "{}{:03}{}",
4210 date.year,
4211 date.day_of_year(),
4212 issue.unwrap_or("0000")
4213 )
4214}
4215
4216fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4217 Ok(match layout {
4218 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4219 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4220 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4221 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4222 ArchiveLayout::BkgBrdcYearDoy => {
4223 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4224 }
4225 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4226 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4227 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4228 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4229 })
4230}
4231
4232fn product_dir_path(
4233 center: AnalysisCenter,
4234 layout: ArchiveLayout,
4235 date: ProductDate,
4236) -> Result<String, DataCatalogError> {
4237 match center {
4238 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4239 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4240 _ => dir_path(layout, date),
4241 }
4242}
4243
4244fn uses_legacy_igs_final_name(
4245 center: AnalysisCenter,
4246 product_type: ProductType,
4247 date: ProductDate,
4248) -> Result<bool, DataCatalogError> {
4249 Ok(center == AnalysisCenter::Igs
4250 && product_type == ProductType::Sp3
4251 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4252}
4253
4254fn product_archive_compression(
4255 center: AnalysisCenter,
4256 product_type: ProductType,
4257 date: ProductDate,
4258 default: ArchiveCompression,
4259) -> Result<ArchiveCompression, DataCatalogError> {
4260 if uses_legacy_igs_final_name(center, product_type, date)? {
4261 Ok(ArchiveCompression::UnixCompress)
4262 } else {
4263 Ok(default)
4264 }
4265}
4266
4267fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4268 let (year, month, day) = civil_from_julian_day_number(jdn);
4269 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4270 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4271 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4272 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4273}
4274
4275#[cfg(test)]
4276mod content_start_tests {
4277 use super::*;
4278
4279 const GFZ_ISSUES: [&str; 8] = [
4280 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4281 ];
4282
4283 fn date(year: i32, month: u8, day: u8) -> ProductDate {
4284 ProductDate::new(year, month, day).expect("test date")
4285 }
4286
4287 fn offset(
4288 center: AnalysisCenter,
4289 product_date: ProductDate,
4290 sample: &str,
4291 issue: Option<&str>,
4292 ) -> i64 {
4293 let identity =
4294 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4295 .expect("cataloged SP3 identity");
4296 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4297 }
4298
4299 #[test]
4300 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4301 for issue in GFZ_ISSUES {
4302 assert_eq!(
4303 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4304 -86_400,
4305 "2022-09-06 issue {issue}"
4306 );
4307 }
4308 }
4309
4310 #[test]
4311 fn gfz_ultra_transition_is_cataloged_per_issue() {
4312 let day_seven = [
4313 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4314 ];
4315 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4316
4317 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4318 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4319 assert_eq!(
4320 offset(
4321 AnalysisCenter::GfzUlt,
4322 date(2022, 9, product_day),
4323 "05M",
4324 Some(issue)
4325 ),
4326 expected_offset,
4327 "2022-09-{product_day:02} issue {issue}"
4328 );
4329 }
4330 }
4331 }
4332
4333 #[test]
4334 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4335 for issue in GFZ_ISSUES {
4336 assert_eq!(
4337 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4338 0,
4339 "2022-09-09 issue {issue}"
4340 );
4341 }
4342
4343 let current = date(2026, 7, 20);
4344 let cases = [
4345 (AnalysisCenter::Igs, "15M", None),
4346 (AnalysisCenter::Esa, "05M", None),
4347 (AnalysisCenter::Cod, "05M", None),
4348 (AnalysisCenter::Gfz, "05M", None),
4349 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4350 (AnalysisCenter::CodUlt, "05M", Some("0000")),
4351 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4352 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4353 ];
4354 for (center, sample, issue) in cases {
4355 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4356 }
4357 }
4358
4359 #[test]
4360 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4361 assert_eq!(
4362 offset(
4363 AnalysisCenter::GfzUlt,
4364 date(2021, 5, 15),
4365 "15M",
4366 Some("0000")
4367 ),
4368 -86_400
4369 );
4370 assert_eq!(
4371 offset(
4372 AnalysisCenter::GfzUlt,
4373 date(2021, 5, 16),
4374 "05M",
4375 Some("0000")
4376 ),
4377 -86_400
4378 );
4379 }
4380
4381 #[test]
4382 fn public_content_start_query_enforces_center_issue_rules() {
4383 assert_eq!(
4384 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4385 Err(DataCatalogError::UnsupportedIssue {
4386 center: AnalysisCenter::GfzUlt,
4387 issue: "0130".to_owned(),
4388 })
4389 );
4390 assert_eq!(
4391 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4392 Err(DataCatalogError::UnexpectedIssue {
4393 center: AnalysisCenter::Gfz,
4394 })
4395 );
4396 assert_eq!(
4397 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4398 Err(DataCatalogError::MissingIssue {
4399 center: AnalysisCenter::GfzUlt,
4400 })
4401 );
4402 }
4403}