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,
420}
421
422impl ArchiveProtocol {
423 #[must_use]
425 pub const fn as_str(self) -> &'static str {
426 match self {
427 Self::Http => "http",
428 Self::Https => "https",
429 Self::Ftp => "ftp",
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
436pub enum ArchiveCompression {
437 Gzip,
439 UnixCompress,
441 None,
443}
444
445impl ArchiveCompression {
446 #[must_use]
448 pub const fn as_str(self) -> &'static str {
449 match self {
450 Self::Gzip => "gzip",
451 Self::UnixCompress => "unix_compress",
452 Self::None => "none",
453 }
454 }
455
456 const fn suffix(self) -> &'static str {
457 match self {
458 Self::Gzip => ".gz",
459 Self::UnixCompress => ".Z",
460 Self::None => "",
461 }
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
467pub enum ArchiveLayout {
468 GfzRapidWeek,
470 GfzUltraWeek,
472 GpsWeek,
474 BkgProductsWeek,
476 BkgBrdcYearDoy,
478 BkgObsYearDoy,
480 AiubCodeMgexYear,
482 AiubCodeYear,
484 AiubCodeRoot,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490pub enum ProductFilenameKind {
491 Sampled,
493 Nav,
495}
496
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
499pub struct ProductTypeConvention {
500 pub product_type: ProductType,
502 pub content_code: &'static str,
504 pub extension: &'static str,
506 pub kind: ProductFilenameKind,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub struct CenterProductConvention {
513 pub product_type: ProductType,
515 pub token: &'static str,
517 pub layout: ArchiveLayout,
519 pub span: &'static str,
521 pub default_sample: &'static str,
523 pub compression: ArchiveCompression,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
529pub struct CenterCatalogEntry {
530 pub center: AnalysisCenter,
532 pub code: &'static str,
534 pub protocol: ArchiveProtocol,
536 pub host: &'static str,
538 pub root_url: &'static str,
540 pub products: &'static [CenterProductConvention],
542 pub issues: &'static [&'static str],
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub struct TerrainSourceEntry {
549 pub protocol: ArchiveProtocol,
551 pub host: &'static str,
553 pub compression: ArchiveCompression,
555 pub root_url: &'static str,
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
561pub struct SpaceWeatherSourceEntry {
562 pub protocol: ArchiveProtocol,
564 pub host: &'static str,
566 pub compression: ArchiveCompression,
568 pub root_url: &'static str,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
574pub struct NoOpenMirrorProduct {
575 pub center: &'static str,
577 pub product_type: &'static str,
579}
580
581const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
582 ProductTypeConvention {
583 product_type: ProductType::Sp3,
584 content_code: "ORB",
585 extension: "SP3",
586 kind: ProductFilenameKind::Sampled,
587 },
588 ProductTypeConvention {
589 product_type: ProductType::Clk,
590 content_code: "CLK",
591 extension: "CLK",
592 kind: ProductFilenameKind::Sampled,
593 },
594 ProductTypeConvention {
595 product_type: ProductType::Nav,
596 content_code: "MN",
597 extension: "rnx",
598 kind: ProductFilenameKind::Nav,
599 },
600 ProductTypeConvention {
601 product_type: ProductType::Ionex,
602 content_code: "GIM",
603 extension: "INX",
604 kind: ProductFilenameKind::Sampled,
605 },
606];
607
608const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
609 product_type: ProductType::Ionex,
610 token: "COD0OPSRAP",
611 layout: ArchiveLayout::AiubCodeRoot,
612 span: "01D",
613 default_sample: "01H",
614 compression: ArchiveCompression::Gzip,
615}];
616
617const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
618 product_type: ProductType::Ionex,
619 token: "COD0OPSPRD",
620 layout: ArchiveLayout::AiubCodeRoot,
621 span: "01D",
622 default_sample: "01H",
623 compression: ArchiveCompression::Gzip,
624}];
625
626const WUM_NRT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
637 product_type: ProductType::Sp3,
638 token: "WUM0MGXNRT",
639 layout: ArchiveLayout::GpsWeek,
640 span: "02D",
641 default_sample: "05M",
642 compression: ArchiveCompression::Gzip,
643}];
644
645const WUM_NRT_ISSUES: [&str; 24] = [
647 "0000", "0100", "0200", "0300", "0400", "0500", "0600", "0700", "0800", "0900", "1000", "1100",
648 "1200", "1300", "1400", "1500", "1600", "1700", "1800", "1900", "2000", "2100", "2200", "2300",
649];
650
651const ESA_PRODUCTS: [CenterProductConvention; 3] = [
652 CenterProductConvention {
653 product_type: ProductType::Sp3,
654 token: "ESA0MGNFIN",
655 layout: ArchiveLayout::GpsWeek,
656 span: "01D",
657 default_sample: "05M",
658 compression: ArchiveCompression::Gzip,
659 },
660 CenterProductConvention {
661 product_type: ProductType::Clk,
662 token: "ESA0MGNFIN",
663 layout: ArchiveLayout::GpsWeek,
664 span: "01D",
665 default_sample: "30S",
666 compression: ArchiveCompression::Gzip,
667 },
668 CenterProductConvention {
669 product_type: ProductType::Ionex,
670 token: "ESA0OPSFIN",
671 layout: ArchiveLayout::GpsWeek,
672 span: "01D",
673 default_sample: "02H",
674 compression: ArchiveCompression::Gzip,
675 },
676];
677
678const COD_PRODUCTS: [CenterProductConvention; 3] = [
679 CenterProductConvention {
680 product_type: ProductType::Sp3,
681 token: "COD0MGXFIN",
682 layout: ArchiveLayout::AiubCodeMgexYear,
683 span: "01D",
684 default_sample: "05M",
685 compression: ArchiveCompression::Gzip,
686 },
687 CenterProductConvention {
688 product_type: ProductType::Clk,
689 token: "COD0MGXFIN",
690 layout: ArchiveLayout::AiubCodeMgexYear,
691 span: "01D",
692 default_sample: "30S",
693 compression: ArchiveCompression::Gzip,
694 },
695 CenterProductConvention {
696 product_type: ProductType::Ionex,
697 token: "COD0OPSFIN",
698 layout: ArchiveLayout::AiubCodeYear,
699 span: "01D",
700 default_sample: "01H",
701 compression: ArchiveCompression::Gzip,
702 },
703];
704
705const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
706 CenterProductConvention {
707 product_type: ProductType::Sp3,
708 token: "GFZ0OPSRAP",
709 layout: ArchiveLayout::GfzRapidWeek,
710 span: "01D",
711 default_sample: "05M",
712 compression: ArchiveCompression::Gzip,
713 },
714 CenterProductConvention {
715 product_type: ProductType::Clk,
716 token: "GFZ0OPSRAP",
717 layout: ArchiveLayout::GfzRapidWeek,
718 span: "01D",
719 default_sample: "30S",
720 compression: ArchiveCompression::Gzip,
721 },
722];
723
724const IGS_PRODUCTS: [CenterProductConvention; 2] = [
725 CenterProductConvention {
726 product_type: ProductType::Sp3,
727 token: "IGS0OPSFIN",
728 layout: ArchiveLayout::BkgProductsWeek,
729 span: "01D",
730 default_sample: "15M",
731 compression: ArchiveCompression::Gzip,
732 },
733 CenterProductConvention {
734 product_type: ProductType::Nav,
735 token: "BRDC00WRD",
736 layout: ArchiveLayout::BkgBrdcYearDoy,
737 span: "01D",
738 default_sample: "01D",
739 compression: ArchiveCompression::Gzip,
740 },
741];
742
743const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
744 product_type: ProductType::Sp3,
745 token: "IGS0OPSULT",
746 layout: ArchiveLayout::BkgProductsWeek,
747 span: "02D",
748 default_sample: "15M",
749 compression: ArchiveCompression::Gzip,
750}];
751
752const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
753 product_type: ProductType::Sp3,
754 token: "COD0OPSULT",
755 layout: ArchiveLayout::AiubCodeRoot,
756 span: "01D",
757 default_sample: "05M",
758 compression: ArchiveCompression::None,
759}];
760
761const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
762 product_type: ProductType::Sp3,
763 token: "ESA0OPSULT",
764 layout: ArchiveLayout::GpsWeek,
765 span: "02D",
766 default_sample: "05M",
767 compression: ArchiveCompression::Gzip,
768}];
769
770const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
771 product_type: ProductType::Sp3,
772 token: "GFZ0OPSULT",
773 layout: ArchiveLayout::GfzUltraWeek,
774 span: "02D",
775 default_sample: "05M",
776 compression: ArchiveCompression::Gzip,
777}];
778
779const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
780const COD_ULT_ISSUES: [&str; 1] = ["0000"];
781const GFZ_ULT_ISSUES: [&str; 8] = [
782 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
783];
784
785const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
789
790const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
796
797const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
800
801const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
806 year: 2021,
807 month: 5,
808 day: 18,
809};
810
811const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
813 year: 2014,
814 month: 1,
815 day: 5,
816};
817
818const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
820 year: 2020,
821 month: 5,
822 day: 13,
823};
824
825const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
827 year: 2022,
828 month: 10,
829 day: 4,
830};
831
832const WUM_NRT_SP3_START_DATE: ProductDate = ProductDate {
840 year: 2024,
841 month: 7,
842 day: 3,
843};
844
845const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
847 year: 2025,
848 month: 2,
849 day: 2,
850};
851const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
852
853const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
855 year: 2020,
856 month: 10,
857 day: 6,
858};
859
860const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
862 year: 2021,
863 month: 5,
864 day: 16,
865};
866
867const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
873 year: 2021,
874 month: 5,
875 day: 15,
876};
877
878const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
885 year: 2022,
886 month: 9,
887 day: 7,
888};
889const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
890 year: 2022,
891 month: 9,
892 day: 8,
893};
894
895#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
902#[non_exhaustive]
903pub enum Sp3ContentStartConvention {
904 FilenameEpoch,
906 FilenameEpochMinusOneDay,
908}
909
910impl Sp3ContentStartConvention {
911 #[must_use]
913 pub const fn code(self) -> &'static str {
914 match self {
915 Self::FilenameEpoch => "filename_epoch",
916 Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
917 }
918 }
919
920 #[must_use]
923 pub const fn content_start_offset_s(self) -> i64 {
924 match self {
925 Self::FilenameEpoch => 0,
926 Self::FilenameEpochMinusOneDay => -86_400,
927 }
928 }
929}
930
931const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
938 (
939 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
940 "0000",
941 Sp3ContentStartConvention::FilenameEpoch,
942 ),
943 (
944 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
945 "0300",
946 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
947 ),
948 (
949 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
950 "0600",
951 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
952 ),
953 (
954 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
955 "0900",
956 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
957 ),
958 (
959 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
960 "1200",
961 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
962 ),
963 (
964 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
965 "1500",
966 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
967 ),
968 (
969 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
970 "1800",
971 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
972 ),
973 (
974 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
975 "2100",
976 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
977 ),
978 (
979 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
980 "0000",
981 Sp3ContentStartConvention::FilenameEpoch,
982 ),
983 (
984 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
985 "0300",
986 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
987 ),
988 (
989 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
990 "0600",
991 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
992 ),
993 (
994 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
995 "0900",
996 Sp3ContentStartConvention::FilenameEpoch,
997 ),
998 (
999 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1000 "1200",
1001 Sp3ContentStartConvention::FilenameEpoch,
1002 ),
1003 (
1004 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1005 "1500",
1006 Sp3ContentStartConvention::FilenameEpoch,
1007 ),
1008 (
1009 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1010 "1800",
1011 Sp3ContentStartConvention::FilenameEpoch,
1012 ),
1013 (
1014 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
1015 "2100",
1016 Sp3ContentStartConvention::FilenameEpoch,
1017 ),
1018];
1019
1020const CENTER_ORDER: [AnalysisCenter; 12] = [
1021 AnalysisCenter::CodRap,
1022 AnalysisCenter::CodPrd1,
1023 AnalysisCenter::CodPrd2,
1024 AnalysisCenter::Igs,
1025 AnalysisCenter::Esa,
1026 AnalysisCenter::Cod,
1027 AnalysisCenter::Gfz,
1028 AnalysisCenter::IgsUlt,
1029 AnalysisCenter::CodUlt,
1030 AnalysisCenter::EsaUlt,
1031 AnalysisCenter::GfzUlt,
1032 AnalysisCenter::WumNrt,
1033];
1034
1035const CATALOG: [CenterCatalogEntry; 12] = [
1036 CenterCatalogEntry {
1037 center: AnalysisCenter::CodRap,
1038 code: "cod_rap",
1039 protocol: ArchiveProtocol::Https,
1040 host: "www.aiub.unibe.ch",
1041 root_url: "https://www.aiub.unibe.ch/download",
1042 products: &COD_RAP_PRODUCTS,
1043 issues: &[],
1044 },
1045 CenterCatalogEntry {
1046 center: AnalysisCenter::CodPrd1,
1047 code: "cod_prd1",
1048 protocol: ArchiveProtocol::Https,
1049 host: "www.aiub.unibe.ch",
1050 root_url: "https://www.aiub.unibe.ch/download",
1051 products: &COD_PRD_PRODUCTS,
1052 issues: &[],
1053 },
1054 CenterCatalogEntry {
1055 center: AnalysisCenter::CodPrd2,
1056 code: "cod_prd2",
1057 protocol: ArchiveProtocol::Https,
1058 host: "www.aiub.unibe.ch",
1059 root_url: "https://www.aiub.unibe.ch/download",
1060 products: &COD_PRD_PRODUCTS,
1061 issues: &[],
1062 },
1063 CenterCatalogEntry {
1064 center: AnalysisCenter::Igs,
1065 code: "igs",
1066 protocol: ArchiveProtocol::Https,
1067 host: "igs.bkg.bund.de",
1068 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1069 products: &IGS_PRODUCTS,
1070 issues: &[],
1071 },
1072 CenterCatalogEntry {
1073 center: AnalysisCenter::Esa,
1074 code: "esa",
1075 protocol: ArchiveProtocol::Https,
1076 host: "navigation-office.esa.int",
1077 root_url: "https://navigation-office.esa.int/products/gnss-products",
1078 products: &ESA_PRODUCTS,
1079 issues: &[],
1080 },
1081 CenterCatalogEntry {
1082 center: AnalysisCenter::Cod,
1083 code: "cod",
1084 protocol: ArchiveProtocol::Https,
1085 host: "www.aiub.unibe.ch",
1086 root_url: "https://www.aiub.unibe.ch/download",
1087 products: &COD_PRODUCTS,
1088 issues: &[],
1089 },
1090 CenterCatalogEntry {
1091 center: AnalysisCenter::Gfz,
1092 code: "gfz",
1093 protocol: ArchiveProtocol::Https,
1094 host: "isdc-data.gfz.de",
1095 root_url: "https://isdc-data.gfz.de/gnss/products",
1096 products: &GFZ_PRODUCTS,
1097 issues: &[],
1098 },
1099 CenterCatalogEntry {
1100 center: AnalysisCenter::IgsUlt,
1101 code: "igs_ult",
1102 protocol: ArchiveProtocol::Https,
1103 host: "igs.bkg.bund.de",
1104 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1105 products: &IGS_ULT_PRODUCTS,
1106 issues: &OPSULT_ISSUES,
1107 },
1108 CenterCatalogEntry {
1109 center: AnalysisCenter::CodUlt,
1110 code: "cod_ult",
1111 protocol: ArchiveProtocol::Https,
1112 host: "www.aiub.unibe.ch",
1113 root_url: "https://www.aiub.unibe.ch/download",
1117 products: &COD_ULT_PRODUCTS,
1118 issues: &COD_ULT_ISSUES,
1119 },
1120 CenterCatalogEntry {
1121 center: AnalysisCenter::EsaUlt,
1122 code: "esa_ult",
1123 protocol: ArchiveProtocol::Https,
1124 host: "navigation-office.esa.int",
1125 root_url: "https://navigation-office.esa.int/products/gnss-products",
1126 products: &ESA_ULT_PRODUCTS,
1127 issues: &OPSULT_ISSUES,
1128 },
1129 CenterCatalogEntry {
1130 center: AnalysisCenter::GfzUlt,
1131 code: "gfz_ult",
1132 protocol: ArchiveProtocol::Https,
1133 host: "isdc-data.gfz.de",
1134 root_url: "https://isdc-data.gfz.de/gnss/products",
1135 products: &GFZ_ULT_PRODUCTS,
1136 issues: &GFZ_ULT_ISSUES,
1137 },
1138 CenterCatalogEntry {
1139 center: AnalysisCenter::WumNrt,
1140 code: "wum_nrt",
1141 protocol: ArchiveProtocol::Ftp,
1142 host: "igs.gnsswhu.cn",
1143 root_url: "ftp://igs.gnsswhu.cn/pub/gps/products/mgex",
1144 products: &WUM_NRT_PRODUCTS,
1145 issues: &WUM_NRT_ISSUES,
1146 },
1147];
1148
1149const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1150 protocol: ArchiveProtocol::Https,
1151 host: "s3.amazonaws.com",
1152 compression: ArchiveCompression::Gzip,
1153 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1154};
1155
1156const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1157 protocol: ArchiveProtocol::Https,
1158 host: "celestrak.org",
1159 compression: ArchiveCompression::None,
1160 root_url: "https://celestrak.org/SpaceData",
1161};
1162
1163const ALLOWED_HOSTS: [&str; 11] = [
1164 "www.aiub.unibe.ch",
1165 "download.aiub.unibe.ch",
1166 "zhw-b.s3.cloud.switch.ch",
1167 "navigation-office.esa.int",
1168 "isdc-data.gfz.de",
1169 "igs.bkg.bund.de",
1170 "igs.gnsswhu.cn",
1171 "s3.amazonaws.com",
1172 "celestrak.org",
1173 "cddis.nasa.gov",
1174 "urs.earthdata.nasa.gov",
1175];
1176
1177const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1178 NoOpenMirrorProduct {
1179 center: "grg",
1180 product_type: "sp3",
1181 },
1182 NoOpenMirrorProduct {
1183 center: "grg",
1184 product_type: "clk",
1185 },
1186 NoOpenMirrorProduct {
1187 center: "wum",
1188 product_type: "sp3",
1189 },
1190 NoOpenMirrorProduct {
1191 center: "wum",
1192 product_type: "clk",
1193 },
1194 NoOpenMirrorProduct {
1195 center: "grg_ult",
1196 product_type: "sp3",
1197 },
1198 NoOpenMirrorProduct {
1199 center: "grg_ult",
1200 product_type: "clk",
1201 },
1202 NoOpenMirrorProduct {
1203 center: "igs",
1204 product_type: "ionex",
1205 },
1206];
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1210pub enum DataCatalogError {
1211 UnknownCenter(String),
1213 UnknownProductType(String),
1215 UnsupportedProduct {
1217 center: AnalysisCenter,
1219 product_type: ProductType,
1221 },
1222 UnsupportedDistribution {
1224 source: DistributionSource,
1226 product_type: ProductType,
1228 },
1229 UnsupportedProductEra {
1231 center: AnalysisCenter,
1233 product_type: ProductType,
1235 date: ProductDate,
1237 },
1238 UnsupportedDistributionEra {
1240 source: DistributionSource,
1242 center: AnalysisCenter,
1244 product_type: ProductType,
1246 date: ProductDate,
1248 },
1249 NoDistributionSources,
1251 InvalidOfficialFilename(String),
1253 InconsistentProductIdentity {
1255 field: &'static str,
1257 },
1258 NoOpenMirror {
1260 center: String,
1262 product_type: String,
1264 },
1265 InvalidDate {
1267 year: i32,
1269 month: u8,
1271 day: u8,
1273 },
1274 DateOutOfRange,
1276 DateBeforeGpsEpoch(ProductDate),
1278 InvalidGpsDayOfWeek(u8),
1280 InvalidSample(String),
1282 UnsupportedSample {
1284 center: AnalysisCenter,
1286 product_type: ProductType,
1288 sample: String,
1290 },
1291 InvalidSpan(String),
1293 InvalidIssue(String),
1295 MissingIssue {
1297 center: AnalysisCenter,
1299 },
1300 UnexpectedIssue {
1302 center: AnalysisCenter,
1304 },
1305 UnsupportedIssue {
1307 center: AnalysisCenter,
1309 issue: String,
1311 },
1312 InvalidDateTime {
1314 hour: u8,
1316 minute: u8,
1318 second: u8,
1320 },
1321 NoUltraIssue,
1323 NoAvailableUltraIssue,
1325 UnrecognizedArchiveListing {
1329 reason: String,
1331 },
1332 InvalidStation(String),
1334 InvalidCoordinate {
1336 lat_deg_bits: u64,
1338 lon_deg_bits: u64,
1340 },
1341 InvalidTileIndex {
1343 lat_index: i32,
1345 lon_index: i32,
1347 },
1348 InvalidTileId(String),
1350}
1351
1352impl fmt::Display for DataCatalogError {
1353 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1354 match self {
1355 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1356 Self::UnknownProductType(product_type) => {
1357 write!(f, "unknown product type {product_type:?}")
1358 }
1359 Self::UnsupportedProduct {
1360 center,
1361 product_type,
1362 } => write!(f, "{center} does not serve {product_type}"),
1363 Self::UnsupportedDistribution {
1364 source,
1365 product_type,
1366 } => write!(
1367 f,
1368 "distributor {} does not serve {product_type}",
1369 source.code()
1370 ),
1371 Self::UnsupportedProductEra {
1372 center,
1373 product_type,
1374 date,
1375 } => write!(
1376 f,
1377 "{center}/{product_type} has no cataloged naming convention for {date}"
1378 ),
1379 Self::UnsupportedDistributionEra {
1380 source,
1381 center,
1382 product_type,
1383 date,
1384 } => write!(
1385 f,
1386 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1387 source.code()
1388 ),
1389 Self::NoDistributionSources => {
1390 write!(f, "exact product request has no distributors")
1391 }
1392 Self::InvalidOfficialFilename(filename) => {
1393 write!(f, "invalid official product filename {filename:?}")
1394 }
1395 Self::InconsistentProductIdentity { field } => {
1396 write!(
1397 f,
1398 "product identity field {field:?} disagrees with its official filename"
1399 )
1400 }
1401 Self::NoOpenMirror {
1402 center,
1403 product_type,
1404 } => write!(f, "{center}/{product_type} has no open mirror"),
1405 Self::InvalidDate { year, month, day } => {
1406 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1407 }
1408 Self::DateOutOfRange => write!(f, "product date is out of range"),
1409 Self::DateBeforeGpsEpoch(date) => {
1410 write!(f, "product date {date} is before the GPS week epoch")
1411 }
1412 Self::InvalidGpsDayOfWeek(day) => {
1413 write!(f, "invalid GPS day-of-week {day}")
1414 }
1415 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1416 Self::UnsupportedSample {
1417 center,
1418 product_type,
1419 sample,
1420 } => write!(
1421 f,
1422 "{center}/{product_type} does not publish sample interval {sample:?}"
1423 ),
1424 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1425 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1426 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1427 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1428 Self::UnsupportedIssue { center, issue } => {
1429 write!(f, "{center} does not publish issue {issue:?}")
1430 }
1431 Self::InvalidDateTime {
1432 hour,
1433 minute,
1434 second,
1435 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1436 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1437 Self::NoAvailableUltraIssue => {
1438 write!(f, "no available ultra-rapid issue at or before target")
1439 }
1440 Self::UnrecognizedArchiveListing { reason } => {
1441 write!(f, "unrecognized archive listing: {reason}")
1442 }
1443 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1444 Self::InvalidCoordinate {
1445 lat_deg_bits,
1446 lon_deg_bits,
1447 } => write!(
1448 f,
1449 "invalid terrain coordinate lat={} lon={}",
1450 f64::from_bits(*lat_deg_bits),
1451 f64::from_bits(*lon_deg_bits)
1452 ),
1453 Self::InvalidTileIndex {
1454 lat_index,
1455 lon_index,
1456 } => write!(
1457 f,
1458 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1459 ),
1460 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1461 }
1462 }
1463}
1464
1465impl std::error::Error for DataCatalogError {}
1466
1467#[derive(Debug, Clone, PartialEq, Eq)]
1469pub enum HgtConversionError {
1470 BadLength {
1472 expected: usize,
1474 got: usize,
1476 },
1477 InvalidTileIndex {
1479 lat_index: i32,
1481 lon_index: i32,
1483 },
1484}
1485
1486impl fmt::Display for HgtConversionError {
1487 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1488 match self {
1489 Self::BadLength { expected, got } => {
1490 write!(
1491 f,
1492 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1493 )
1494 }
1495 Self::InvalidTileIndex {
1496 lat_index,
1497 lon_index,
1498 } => write!(
1499 f,
1500 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1501 ),
1502 }
1503 }
1504}
1505
1506impl std::error::Error for HgtConversionError {}
1507
1508const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1509const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1510const MIN_TERRAIN_LON_INDEX: i32 = -180;
1511const MAX_TERRAIN_LON_INDEX: i32 = 179;
1512const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1513const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1514const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1515const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1516const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1517const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1518const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1519const DTED_SRTM1_LEN: usize =
1520 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1521
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1524pub struct ProductDate {
1525 pub year: i32,
1527 pub month: u8,
1529 pub day: u8,
1531}
1532
1533impl ProductDate {
1534 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1536 let days = days_in_month(i64::from(year), i64::from(month));
1537 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1538 return Err(DataCatalogError::InvalidDate { year, month, day });
1539 }
1540 Ok(Self { year, month, day })
1541 }
1542
1543 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1545 if day_of_week > 6 {
1546 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1547 }
1548 let epoch_jdn =
1549 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1550 let offset_days = i64::from(week)
1551 .checked_mul(7)
1552 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1553 .ok_or(DataCatalogError::DateOutOfRange)?;
1554 product_date_from_jdn(
1555 epoch_jdn
1556 .checked_add(offset_days)
1557 .ok_or(DataCatalogError::DateOutOfRange)?,
1558 )
1559 }
1560
1561 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1563 week_from_calendar(
1564 TimeScale::Gpst,
1565 i64::from(self.year),
1566 i64::from(self.month),
1567 i64::from(self.day),
1568 )
1569 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1570 }
1571
1572 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1574 let epoch_jdn =
1575 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1576 let days = self
1577 .julian_day_number()
1578 .checked_sub(epoch_jdn)
1579 .ok_or(DataCatalogError::DateOutOfRange)?;
1580 if days < 0 {
1581 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1582 }
1583 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1584 }
1585
1586 #[must_use]
1588 pub fn day_of_year(self) -> u16 {
1589 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1590 }
1591
1592 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1593 product_date_from_jdn(
1594 self.julian_day_number()
1595 .checked_add(days)
1596 .ok_or(DataCatalogError::DateOutOfRange)?,
1597 )
1598 }
1599
1600 fn julian_day_number(self) -> i64 {
1601 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1602 }
1603}
1604
1605impl fmt::Display for ProductDate {
1606 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1607 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1608 }
1609}
1610
1611#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1613pub struct ProductDateTime {
1614 pub date: ProductDate,
1616 pub hour: u8,
1618 pub minute: u8,
1620 pub second: u8,
1622}
1623
1624impl ProductDateTime {
1625 pub fn new(
1627 date: ProductDate,
1628 hour: u8,
1629 minute: u8,
1630 second: u8,
1631 ) -> Result<Self, DataCatalogError> {
1632 if hour > 23 || minute > 59 || second > 59 {
1633 return Err(DataCatalogError::InvalidDateTime {
1634 hour,
1635 minute,
1636 second,
1637 });
1638 }
1639 Ok(Self {
1640 date,
1641 hour,
1642 minute,
1643 second,
1644 })
1645 }
1646
1647 fn ordering_minutes(self) -> i64 {
1648 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1649 }
1650}
1651
1652#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1654pub struct UltraIssue {
1655 pub date: ProductDate,
1657 pub issue: String,
1659}
1660
1661impl UltraIssue {
1662 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1664 validate_issue(issue)?;
1665 Ok(Self {
1666 date,
1667 issue: issue.to_string(),
1668 })
1669 }
1670}
1671
1672#[derive(Debug, Clone, PartialEq, Eq)]
1674pub struct UltraSp3Location {
1675 pub pattern: String,
1677 pub span: String,
1679 pub sample: String,
1681 pub filename: String,
1683 pub url: String,
1685 pub compression: ArchiveCompression,
1687}
1688
1689#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1695pub struct ProductIdentity {
1696 pub family: ProductType,
1698 pub analysis_center: AnalysisCenter,
1700 pub publisher: ProductPublisher,
1702 pub solution: SolutionClass,
1704 pub campaign: ProductCampaign,
1706 pub version: u8,
1708 pub date: ProductDate,
1714 pub issue: Option<String>,
1716 pub span: String,
1718 pub sample: String,
1720 pub official_filename: String,
1722 pub format: ProductFormat,
1724 pub format_version: Option<String>,
1730 pub prediction_horizon_days: Option<u8>,
1732}
1733
1734impl ProductIdentity {
1735 pub fn validate(&self) -> Result<(), DataCatalogError> {
1741 validate_official_filename(&self.official_filename)?;
1742 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1743 validate_sample(&self.sample)?;
1744 validate_span(&self.span)?;
1745 if let Some(issue) = self.issue.as_deref() {
1746 validate_issue(issue)?;
1747 }
1748
1749 let convention = product_convention(self.analysis_center, self.family)?;
1753 validate_product_date(self.analysis_center, self.family, self.date)?;
1754 if self.span != convention.span {
1755 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1756 }
1757 validate_catalog_sample(
1758 self.analysis_center,
1759 self.family,
1760 self.date,
1761 &self.sample,
1762 self.issue.as_deref(),
1763 )?;
1764
1765 if self.format != product_format(self.family) {
1766 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1767 }
1768
1769 if self
1770 .format_version
1771 .as_deref()
1772 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1773 {
1774 return Err(DataCatalogError::InconsistentProductIdentity {
1775 field: "format_version",
1776 });
1777 }
1778
1779 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1780 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1781 (_, SolutionClass::Predicted, _) => false,
1782 (_, _, None) => true,
1783 (_, _, Some(_)) => false,
1784 };
1785 if !horizon_valid {
1786 return Err(DataCatalogError::InconsistentProductIdentity {
1787 field: "prediction_horizon_days",
1788 });
1789 }
1790 let descriptor = product_type_convention(self.family);
1791 let legacy_igs_final =
1792 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1793 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1794 let entry = center_catalog(self.analysis_center)
1795 .expect("validated analysis center has a catalog entry");
1796 let issue_valid = if entry.issues.is_empty() {
1797 self.issue.as_deref() == Some("0000")
1798 } else {
1799 self.issue
1800 .as_deref()
1801 .is_some_and(|issue| entry.issues.contains(&issue))
1802 };
1803 if !issue_valid {
1804 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1805 }
1806 }
1807 let expected = if legacy_igs_final {
1808 let fields_valid = self.publisher == ProductPublisher::Igs
1809 && self.solution == SolutionClass::Final
1810 && self.campaign == ProductCampaign::Operational
1811 && self.version == 0
1812 && self.issue.as_deref() == Some("0000")
1813 && self.span == convention.span
1814 && self.sample == convention.default_sample;
1815 if !fields_valid {
1816 return Err(DataCatalogError::InconsistentProductIdentity {
1817 field: "legacy_igs_final",
1818 });
1819 }
1820 format!(
1821 "igs{:04}{}.sp3",
1822 self.date.gps_week()?,
1823 self.date.gps_day_of_week()?
1824 )
1825 } else {
1826 match descriptor.kind {
1827 ProductFilenameKind::Sampled => {
1828 let solution_token = self.solution.filename_token().ok_or(
1829 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1830 )?;
1831 format!(
1832 "{}{}{}{}_{}_{}_{}_{}.{}",
1833 self.publisher.code(),
1834 self.version,
1835 self.campaign.code(),
1836 solution_token,
1837 date_block(self.date, self.issue.as_deref()),
1838 self.span,
1839 self.sample,
1840 descriptor.content_code,
1841 descriptor.extension
1842 )
1843 }
1844 ProductFilenameKind::Nav => {
1845 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1846 && self.solution == SolutionClass::Broadcast
1847 && self.campaign == ProductCampaign::Broadcast
1848 && self.version == 0
1849 && self.issue.is_none()
1850 && self.span == "01D"
1851 && self.sample == "01D";
1852 if !nav_fields_valid {
1853 return Err(DataCatalogError::InconsistentProductIdentity {
1854 field: "broadcast_navigation",
1855 });
1856 }
1857 format!(
1858 "BRDC00WRD_R_{}_{}_{}.{}",
1859 date_block(self.date, None),
1860 self.span,
1861 descriptor.content_code,
1862 descriptor.extension
1863 )
1864 }
1865 }
1866 };
1867 if expected != self.official_filename {
1868 return Err(DataCatalogError::InconsistentProductIdentity {
1869 field: "official_filename",
1870 });
1871 }
1872 if self.publisher != self.analysis_center.publisher()
1873 || self.solution != product_solution_class(self.analysis_center, self.family)?
1874 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1875 {
1876 return Err(DataCatalogError::InconsistentProductIdentity {
1877 field: "analysis_center",
1878 });
1879 }
1880
1881 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1882 let expected_catalog_filename = format!(
1883 "{}_{}_{}_{}_{}.{}",
1884 convention.token,
1885 date_block(self.date, self.issue.as_deref()),
1886 self.span,
1887 self.sample,
1888 descriptor.content_code,
1889 descriptor.extension
1890 );
1891 if expected_catalog_filename != self.official_filename {
1892 return Err(DataCatalogError::InconsistentProductIdentity {
1893 field: "analysis_center",
1894 });
1895 }
1896 }
1897 Ok(())
1898 }
1899
1900 pub fn key(&self) -> Result<String, DataCatalogError> {
1902 use sha2::{Digest, Sha256};
1903
1904 let canonical = self.canonical_bytes()?;
1905 let digest = Sha256::digest(canonical);
1906 Ok(format!(
1907 "{}-{}-{}",
1908 self.publisher.code().to_ascii_lowercase(),
1909 self.solution.code(),
1910 digest[..10]
1911 .iter()
1912 .map(|byte| format!("{byte:02x}"))
1913 .collect::<String>()
1914 ))
1915 }
1916
1917 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1923 self.validate()?;
1924 let date = format!(
1925 "{:04}-{:02}-{:02}",
1926 self.date.year, self.date.month, self.date.day
1927 );
1928 let version = self.version.to_string();
1929 let prediction = self
1930 .prediction_horizon_days
1931 .map(|days| days.to_string())
1932 .unwrap_or_default();
1933 let fields = [
1934 self.family.code(),
1935 self.analysis_center.code(),
1936 self.publisher.code(),
1937 self.solution.code(),
1938 self.campaign.code(),
1939 version.as_str(),
1940 date.as_str(),
1941 self.issue.as_deref().unwrap_or_default(),
1942 self.span.as_str(),
1943 self.sample.as_str(),
1944 self.official_filename.as_str(),
1945 self.format.code(),
1946 self.format_version.as_deref().unwrap_or_default(),
1947 prediction.as_str(),
1948 ];
1949 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1950 return Err(DataCatalogError::InconsistentProductIdentity {
1951 field: "canonical_encoding",
1952 });
1953 }
1954 Ok(fields.join("\0").into_bytes())
1955 }
1956
1957 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1959 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1960 }
1961}
1962
1963pub(crate) fn exact_sp3_content_start_offset_s(
1969 identity: &ProductIdentity,
1970) -> Result<i64, DataCatalogError> {
1971 identity.validate()?;
1972 if identity.family != ProductType::Sp3 {
1973 return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
1974 }
1975
1976 let entry =
1977 center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
1978 let catalog_issue = if entry.issues.is_empty() {
1982 None
1983 } else {
1984 identity.issue.as_deref()
1985 };
1986 Ok(
1987 sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
1988 .content_start_offset_s(),
1989 )
1990}
1991
1992pub fn sp3_content_start_convention(
2000 center: AnalysisCenter,
2001 date: ProductDate,
2002 issue: Option<&str>,
2003) -> Result<Sp3ContentStartConvention, DataCatalogError> {
2004 ProductDate::new(date.year, date.month, date.day)?;
2005 product_convention(center, ProductType::Sp3)?;
2006 validate_product_date(center, ProductType::Sp3, date)?;
2007 validate_issue_for_center(center, issue)?;
2008
2009 sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
2010 DataCatalogError::UnsupportedIssue {
2011 center,
2012 issue: issue.unwrap_or_default().to_owned(),
2013 }
2014 })
2015}
2016
2017fn sp3_content_start_convention_inner(
2018 center: AnalysisCenter,
2019 date: ProductDate,
2020 issue: Option<&str>,
2021) -> Option<Sp3ContentStartConvention> {
2022 if center != AnalysisCenter::GfzUlt {
2023 return Some(Sp3ContentStartConvention::FilenameEpoch);
2024 }
2025 if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
2026 return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
2027 }
2028 if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
2029 return Some(Sp3ContentStartConvention::FilenameEpoch);
2030 }
2031
2032 let issue = issue?;
2033 GFZ_ULTRA_START_TRANSITION
2034 .iter()
2035 .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
2036 .map(|(_, _, convention)| *convention)
2037}
2038
2039#[derive(Debug, Clone, PartialEq, Eq)]
2041pub struct DistributionLocation {
2042 pub source: DistributionSource,
2044 pub original_url: Option<String>,
2046 pub archive_filename: String,
2048 pub compression: ArchiveCompression,
2050}
2051
2052#[derive(Debug, Clone, PartialEq, Eq)]
2054pub struct ProductRequest {
2055 pub identity: ProductIdentity,
2057 pub distributors: Vec<DistributionSource>,
2059}
2060
2061#[derive(Debug, Clone, PartialEq, Eq)]
2063pub enum ExactProductSetError {
2064 EmptyExpected,
2066 InvalidExpected {
2068 index: usize,
2070 source: DataCatalogError,
2072 },
2073 InvalidAvailable {
2075 index: usize,
2077 source: DataCatalogError,
2079 },
2080 Mismatch {
2082 missing: Vec<ProductIdentity>,
2084 unexpected: Vec<ProductIdentity>,
2086 duplicate_expected: Vec<ProductIdentity>,
2088 duplicate_available: Vec<ProductIdentity>,
2090 },
2091}
2092
2093impl fmt::Display for ExactProductSetError {
2094 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2095 match self {
2096 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2097 Self::InvalidExpected { index, source } => {
2098 write!(f, "expected product {index} is invalid: {source}")
2099 }
2100 Self::InvalidAvailable { index, source } => {
2101 write!(f, "available product {index} is invalid: {source}")
2102 }
2103 Self::Mismatch {
2104 missing,
2105 unexpected,
2106 duplicate_expected,
2107 duplicate_available,
2108 } => write!(
2109 f,
2110 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2111 identity_list(missing),
2112 identity_list(unexpected),
2113 identity_list(duplicate_expected),
2114 identity_list(duplicate_available),
2115 ),
2116 }
2117 }
2118}
2119
2120impl std::error::Error for ExactProductSetError {}
2121
2122pub fn validate_exact_product_set(
2136 expected: &[ProductIdentity],
2137 available: &[ProductIdentity],
2138) -> Result<(), ExactProductSetError> {
2139 if expected.is_empty() {
2140 return Err(ExactProductSetError::EmptyExpected);
2141 }
2142 for (index, identity) in expected.iter().enumerate() {
2143 identity
2144 .validate()
2145 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2146 }
2147 for (index, identity) in available.iter().enumerate() {
2148 identity
2149 .validate()
2150 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2151 }
2152
2153 let expected_counts = identity_counts(expected);
2154 let available_counts = identity_counts(available);
2155 let missing = unique_matching(expected, |identity| {
2156 !available_counts.contains_key(identity)
2157 });
2158 let unexpected = unique_matching(available, |identity| {
2159 !expected_counts.contains_key(identity)
2160 });
2161 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2162 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2163
2164 if missing.is_empty()
2165 && unexpected.is_empty()
2166 && duplicate_expected.is_empty()
2167 && duplicate_available.is_empty()
2168 {
2169 Ok(())
2170 } else {
2171 Err(ExactProductSetError::Mismatch {
2172 missing,
2173 unexpected,
2174 duplicate_expected,
2175 duplicate_available,
2176 })
2177 }
2178}
2179
2180fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2181 let mut counts = HashMap::with_capacity(identities.len());
2182 for identity in identities {
2183 *counts.entry(identity).or_insert(0) += 1;
2184 }
2185 counts
2186}
2187
2188fn unique_matching(
2189 identities: &[ProductIdentity],
2190 mut predicate: impl FnMut(&ProductIdentity) -> bool,
2191) -> Vec<ProductIdentity> {
2192 let mut seen = HashSet::with_capacity(identities.len());
2193 identities
2194 .iter()
2195 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2196 .cloned()
2197 .collect()
2198}
2199
2200fn identity_list(identities: &[ProductIdentity]) -> String {
2201 if identities.is_empty() {
2202 return "none".to_string();
2203 }
2204 identities
2205 .iter()
2206 .map(|identity| {
2207 identity
2208 .key()
2209 .unwrap_or_else(|_| identity.official_filename.clone())
2210 })
2211 .collect::<Vec<_>>()
2212 .join(", ")
2213}
2214
2215impl ProductRequest {
2216 pub fn new(
2218 identity: ProductIdentity,
2219 distributors: Vec<DistributionSource>,
2220 ) -> Result<Self, DataCatalogError> {
2221 if distributors.is_empty() {
2222 return Err(DataCatalogError::NoDistributionSources);
2223 }
2224 identity.validate()?;
2225 Ok(Self {
2226 identity,
2227 distributors,
2228 })
2229 }
2230}
2231
2232#[derive(Debug, Clone, PartialEq, Eq)]
2234pub struct ProductSpec {
2235 pub center: AnalysisCenter,
2237 pub product_type: ProductType,
2239 pub date: ProductDate,
2241 pub sample: String,
2243 pub issue: Option<String>,
2245}
2246
2247impl ProductSpec {
2248 pub fn new(
2250 center: AnalysisCenter,
2251 product_type: ProductType,
2252 date: ProductDate,
2253 sample: &str,
2254 issue: Option<&str>,
2255 ) -> Result<Self, DataCatalogError> {
2256 ProductDate::new(date.year, date.month, date.day)?;
2257 validate_product(center, product_type, date, sample, issue)?;
2258 Ok(Self {
2259 center,
2260 product_type,
2261 date,
2262 sample: sample.to_string(),
2263 issue: issue.map(ToOwned::to_owned),
2264 })
2265 }
2266
2267 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2269 self.date.gps_week()
2270 }
2271
2272 #[must_use]
2274 pub fn day_of_year(&self) -> u16 {
2275 self.date.day_of_year()
2276 }
2277
2278 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2284 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2285 let convention = validate_product(
2286 self.center,
2287 self.product_type,
2288 self.date,
2289 &self.sample,
2290 self.issue.as_deref(),
2291 )?;
2292 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2293 return Ok(format!(
2294 "igs{:04}{}.sp3",
2295 self.date.gps_week()?,
2296 self.date.gps_day_of_week()?
2297 ));
2298 }
2299 let descriptor = product_type_convention(self.product_type);
2300 Ok(match descriptor.kind {
2301 ProductFilenameKind::Sampled => format!(
2302 "{}_{}_{}_{}_{}.{}",
2303 convention.token,
2304 date_block(self.date, self.issue.as_deref()),
2305 convention.span,
2306 self.sample,
2307 descriptor.content_code,
2308 descriptor.extension
2309 ),
2310 ProductFilenameKind::Nav => format!(
2311 "{}_R_{}_{}_{}.{}",
2312 convention.token,
2313 date_block(self.date, None),
2314 convention.span,
2315 descriptor.content_code,
2316 descriptor.extension
2317 ),
2318 })
2319 }
2320
2321 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2323 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2324 let convention = validate_product(
2325 self.center,
2326 self.product_type,
2327 self.date,
2328 &self.sample,
2329 self.issue.as_deref(),
2330 )?;
2331 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2332 return Err(DataCatalogError::UnsupportedDistributionEra {
2333 source: DistributionSource::Direct,
2334 center: self.center,
2335 product_type: self.product_type,
2336 date: self.date,
2337 });
2338 }
2339 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2340 let filename = self.canonical_filename()?;
2341 let compression = product_archive_compression(
2342 self.center,
2343 self.product_type,
2344 self.date,
2345 convention.compression,
2346 )?;
2347 Ok(format!(
2348 "{}/{}/{}{}",
2349 entry.root_url,
2350 product_dir_path(self.center, convention.layout, self.date)?,
2351 filename,
2352 compression.suffix()
2353 ))
2354 }
2355
2356 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2358 let convention = validate_product(
2359 self.center,
2360 self.product_type,
2361 self.date,
2362 &self.sample,
2363 self.issue.as_deref(),
2364 )?;
2365 let descriptor = product_type_convention(self.product_type);
2366 let campaign = match descriptor.kind {
2367 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2368 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2369 Some("OPS") => ProductCampaign::Operational,
2370 Some("MGN") => ProductCampaign::MultiGnss,
2371 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2372 _ => {
2373 return Err(DataCatalogError::InconsistentProductIdentity {
2374 field: "campaign",
2375 });
2376 }
2377 },
2378 };
2379 let identity = ProductIdentity {
2380 family: self.product_type,
2381 analysis_center: self.center,
2382 publisher: self.center.publisher(),
2383 solution: product_solution_class(self.center, self.product_type)?,
2384 campaign,
2385 version: 0,
2386 date: self.date,
2387 issue: match descriptor.kind {
2388 ProductFilenameKind::Sampled => {
2389 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2390 }
2391 ProductFilenameKind::Nav => None,
2392 },
2393 span: convention.span.to_string(),
2394 sample: self.sample.clone(),
2395 official_filename: self.canonical_filename()?,
2396 format: product_format(self.product_type),
2397 format_version: None,
2398 prediction_horizon_days: self.center.prediction_horizon_days(),
2399 };
2400 identity.validate()?;
2401 Ok(identity)
2402 }
2403
2404 pub fn distribution_location(
2406 &self,
2407 source: DistributionSource,
2408 ) -> Result<DistributionLocation, DataCatalogError> {
2409 let identity = self.identity()?;
2410 distribution_location_for_identity(&identity, source)
2411 }
2412}
2413
2414#[derive(Debug, Clone, PartialEq, Eq)]
2416pub struct StationObservationSpec {
2417 pub station: String,
2419 pub date: ProductDate,
2421 pub sample: String,
2423}
2424
2425impl StationObservationSpec {
2426 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2428 validate_station(station)?;
2429 validate_sample(sample)?;
2430 Ok(Self {
2431 station: station.to_string(),
2432 date,
2433 sample: sample.to_string(),
2434 })
2435 }
2436
2437 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2439 station_obs_filename(&self.station, self.date, &self.sample)
2440 }
2441
2442 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2444 station_obs_url(&self.station, self.date, &self.sample)
2445 }
2446}
2447
2448#[must_use]
2450pub const fn catalog() -> &'static [CenterCatalogEntry] {
2451 &CATALOG
2452}
2453
2454#[must_use]
2456pub const fn centers() -> &'static [AnalysisCenter] {
2457 &CENTER_ORDER
2458}
2459
2460#[must_use]
2462pub const fn product_types() -> &'static [ProductTypeConvention] {
2463 &PRODUCT_TYPE_CONVENTIONS
2464}
2465
2466#[must_use]
2468pub const fn allowed_hosts() -> &'static [&'static str] {
2469 &ALLOWED_HOSTS
2470}
2471
2472#[must_use]
2474pub const fn skadi_source_entry() -> TerrainSourceEntry {
2475 SKADI_SOURCE
2476}
2477
2478#[must_use]
2480pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2481 CELESTRAK_SPACE_WEATHER_SOURCE
2482}
2483
2484#[must_use]
2486pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2487 match product {
2488 SpaceWeatherProduct::All => "SW-All.csv",
2489 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2490 }
2491}
2492
2493#[must_use]
2495pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2496 format!(
2497 "{}/{}",
2498 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2499 space_weather_filename(product)
2500 )
2501}
2502
2503#[must_use]
2505pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2506 format!("space-weather/{}", space_weather_filename(product))
2507}
2508
2509pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2511 validate_terrain_tile_index(lat_index, lon_index)?;
2512 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2513 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2514 Ok(format!(
2515 "{lat_hemi}{:02}{lon_hemi}{:03}",
2516 lat_index.abs(),
2517 lon_index.abs()
2518 ))
2519}
2520
2521pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2523 validate_terrain_lat_index(lat_index)?;
2524 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2525 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2526}
2527
2528pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2530 let band = skadi_band(lat_index)?;
2531 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2532 Ok(format!(
2533 "{}/skadi/{}/{}.hgt{}",
2534 SKADI_SOURCE.root_url,
2535 band,
2536 tile_id,
2537 SKADI_SOURCE.compression.suffix()
2538 ))
2539}
2540
2541pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2543 validate_terrain_tile_index(lat_index, lon_index)?;
2544 Ok(format!(
2545 "{}_{}{}",
2546 terrain::format_lat(lat_index),
2547 terrain::format_lon(lon_index),
2548 terrain::DTED_SUFFIX
2549 ))
2550}
2551
2552pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2554 validate_terrain_tile_index(lat_index, lon_index)?;
2555 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2556}
2557
2558pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2560 Ok(format!(
2561 "{}/{}",
2562 dted_block_dir(lat_index, lon_index)?,
2563 dted_tile_filename(lat_index, lon_index)?
2564 ))
2565}
2566
2567pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2569 let bytes = id.as_bytes();
2570 if bytes.len() != 7
2571 || !matches!(bytes[0], b'N' | b'S')
2572 || !matches!(bytes[3], b'E' | b'W')
2573 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2574 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2575 {
2576 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2577 }
2578
2579 let lat_abs = id[1..3]
2580 .parse::<i32>()
2581 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2582 let lon_abs = id[4..7]
2583 .parse::<i32>()
2584 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2585 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2586 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2587 }
2588
2589 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2590 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2591 validate_terrain_tile_index(lat_index, lon_index)?;
2592 Ok((lat_index, lon_index))
2593}
2594
2595pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2597 if !lat_deg.is_finite()
2598 || !lon_deg.is_finite()
2599 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2600 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2601 {
2602 return Err(DataCatalogError::InvalidCoordinate {
2603 lat_deg_bits: lat_deg.to_bits(),
2604 lon_deg_bits: lon_deg.to_bits(),
2605 });
2606 }
2607
2608 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2609 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2610 lat_index = MAX_TERRAIN_LAT_INDEX;
2611 }
2612 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2613 lon_index = MAX_TERRAIN_LON_INDEX;
2614 }
2615 validate_terrain_tile_index(lat_index, lon_index)?;
2616 Ok((lat_index, lon_index))
2617}
2618
2619pub fn hgt_to_dted(
2627 lat_index: i32,
2628 lon_index: i32,
2629 hgt: &[u8],
2630) -> Result<Vec<u8>, HgtConversionError> {
2631 validate_hgt_tile_index(lat_index, lon_index)?;
2632 if hgt.len() != SRTM1_HGT_LEN {
2633 return Err(HgtConversionError::BadLength {
2634 expected: SRTM1_HGT_LEN,
2635 got: hgt.len(),
2636 });
2637 }
2638
2639 let mut out = vec![b' '; DTED_SRTM1_LEN];
2640 out[0..4].copy_from_slice(b"UHL1");
2641 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2642 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2643 out[47..51].copy_from_slice(b"3601");
2644 out[51..55].copy_from_slice(b"3601");
2645
2646 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2647 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2648 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2649 out[block_start] = terrain::DATA_SENTINEL;
2650
2651 let count = (lon_posting as u32).to_be_bytes();
2652 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2653 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2654 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2655
2656 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2657 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2658 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2659 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2660 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2661 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2662 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2663 }
2664
2665 let checksum = out[block_start..checksum_start]
2666 .iter()
2667 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2668 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2669 }
2670
2671 debug_assert_eq!(out.len(), 25_981_042);
2672 Ok(out)
2673}
2674
2675#[must_use]
2677pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2678 &NO_OPEN_MIRRORS
2679}
2680
2681pub fn open_mirror(
2683 center: AnalysisCenter,
2684 product_type: ProductType,
2685) -> Result<(), DataCatalogError> {
2686 open_mirror_code(center.code(), product_type.code())
2687}
2688
2689pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2691 if NO_OPEN_MIRRORS
2692 .iter()
2693 .any(|entry| entry.center == center && entry.product_type == product_type)
2694 {
2695 Err(DataCatalogError::NoOpenMirror {
2696 center: center.to_string(),
2697 product_type: product_type.to_string(),
2698 })
2699 } else {
2700 Ok(())
2701 }
2702}
2703
2704#[must_use]
2706pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2707 CATALOG.iter().find(|entry| entry.center == center)
2708}
2709
2710pub fn product_convention(
2712 center: AnalysisCenter,
2713 product_type: ProductType,
2714) -> Result<&'static CenterProductConvention, DataCatalogError> {
2715 open_mirror(center, product_type)?;
2716 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2717 entry
2718 .products
2719 .iter()
2720 .find(|product| product.product_type == product_type)
2721 .ok_or(DataCatalogError::UnsupportedProduct {
2722 center,
2723 product_type,
2724 })
2725}
2726
2727pub fn product_solution_class(
2735 center: AnalysisCenter,
2736 product_type: ProductType,
2737) -> Result<SolutionClass, DataCatalogError> {
2738 product_convention(center, product_type)?;
2739 Ok(match (center, product_type) {
2740 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2741 _ => center.solution_class(),
2742 })
2743}
2744
2745pub fn default_sample(
2751 center: AnalysisCenter,
2752 product_type: ProductType,
2753) -> Result<&'static str, DataCatalogError> {
2754 Ok(product_convention(center, product_type)?.default_sample)
2755}
2756
2757pub fn default_sample_for_date(
2765 center: AnalysisCenter,
2766 product_type: ProductType,
2767 date: ProductDate,
2768) -> Result<&'static str, DataCatalogError> {
2769 default_sample_for_product_issue(center, product_type, date, None)
2770}
2771
2772pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2774 date.gps_week()
2775}
2776
2777#[must_use]
2779pub fn day_of_year(date: ProductDate) -> u16 {
2780 date.day_of_year()
2781}
2782
2783pub fn product(
2785 center: AnalysisCenter,
2786 product_type: ProductType,
2787 date: ProductDate,
2788 sample: Option<&str>,
2789 issue: Option<&str>,
2790) -> Result<ProductSpec, DataCatalogError> {
2791 let sample = match sample {
2792 Some(sample) => sample,
2793 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2794 };
2795 ProductSpec::new(center, product_type, date, sample, issue)
2796}
2797
2798pub fn canonical_filename(
2800 center: AnalysisCenter,
2801 product_type: ProductType,
2802 date: ProductDate,
2803 sample: Option<&str>,
2804 issue: Option<&str>,
2805) -> Result<String, DataCatalogError> {
2806 product(center, product_type, date, sample, issue)?.canonical_filename()
2807}
2808
2809pub fn archive_url(
2811 center: AnalysisCenter,
2812 product_type: ProductType,
2813 date: ProductDate,
2814 sample: Option<&str>,
2815 issue: Option<&str>,
2816) -> Result<String, DataCatalogError> {
2817 product(center, product_type, date, sample, issue)?.archive_url()
2818}
2819
2820pub fn product_identity(
2822 center: AnalysisCenter,
2823 product_type: ProductType,
2824 date: ProductDate,
2825 sample: Option<&str>,
2826 issue: Option<&str>,
2827) -> Result<ProductIdentity, DataCatalogError> {
2828 product(center, product_type, date, sample, issue)?.identity()
2829}
2830
2831pub fn distribution_location(
2833 center: AnalysisCenter,
2834 product_type: ProductType,
2835 date: ProductDate,
2836 sample: Option<&str>,
2837 issue: Option<&str>,
2838 source: DistributionSource,
2839) -> Result<DistributionLocation, DataCatalogError> {
2840 product(center, product_type, date, sample, issue)?.distribution_location(source)
2841}
2842
2843pub fn distribution_location_for_identity(
2850 identity: &ProductIdentity,
2851 source: DistributionSource,
2852) -> Result<DistributionLocation, DataCatalogError> {
2853 identity.validate()?;
2854 match source {
2855 DistributionSource::Direct => {
2856 let convention = product_convention(identity.analysis_center, identity.family)?;
2857 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2858 {
2859 return Err(DataCatalogError::UnsupportedDistributionEra {
2860 source,
2861 center: identity.analysis_center,
2862 product_type: identity.family,
2863 date: identity.date,
2864 });
2865 }
2866 let entry = center_catalog(identity.analysis_center)
2867 .expect("validated analysis center has a catalog entry");
2868 let compression = product_archive_compression(
2869 identity.analysis_center,
2870 identity.family,
2871 identity.date,
2872 convention.compression,
2873 )?;
2874 let url = format!(
2875 "{}/{}/{}{}",
2876 entry.root_url,
2877 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2878 identity.official_filename,
2879 compression.suffix()
2880 );
2881 Ok(DistributionLocation {
2882 source,
2883 original_url: Some(url),
2884 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2885 compression,
2886 })
2887 }
2888 DistributionSource::NasaCddis => {
2889 validate_cddis_distribution_era(identity)?;
2890 let compression = product_archive_compression(
2891 identity.analysis_center,
2892 identity.family,
2893 identity.date,
2894 ArchiveCompression::Gzip,
2895 )?;
2896 Ok(DistributionLocation {
2897 source,
2898 original_url: Some(cddis_archive_url(identity)?),
2899 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2900 compression,
2901 })
2902 }
2903 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2904 source,
2905 original_url: None,
2906 archive_filename: identity.official_filename.clone(),
2907 compression: ArchiveCompression::None,
2908 }),
2909 }
2910}
2911
2912pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2921 identity.validate()?;
2922 validate_cddis_distribution_era(identity)?;
2923 match identity.family {
2924 ProductType::Sp3 => {
2925 let compression = product_archive_compression(
2926 identity.analysis_center,
2927 identity.family,
2928 identity.date,
2929 ArchiveCompression::Gzip,
2930 )?;
2931 Ok(format!(
2932 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2933 identity.date.gps_week()?,
2934 identity.official_filename,
2935 compression.suffix()
2936 ))
2937 }
2938 ProductType::Ionex => Ok(format!(
2939 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2940 identity.date.year,
2941 identity.date.day_of_year(),
2942 identity.official_filename
2943 )),
2944 product_type => Err(DataCatalogError::UnsupportedDistribution {
2945 source: DistributionSource::NasaCddis,
2946 product_type,
2947 }),
2948 }
2949}
2950
2951pub fn mgex_clk(
2953 center: AnalysisCenter,
2954 date: ProductDate,
2955 sample: Option<&str>,
2956) -> Result<ProductSpec, DataCatalogError> {
2957 product(center, ProductType::Clk, date, sample, None)
2958}
2959
2960pub fn mgex_nav(
2962 center: AnalysisCenter,
2963 date: ProductDate,
2964 sample: Option<&str>,
2965) -> Result<ProductSpec, DataCatalogError> {
2966 product(center, ProductType::Nav, date, sample, None)
2967}
2968
2969pub fn mgex_ionex(
2971 center: AnalysisCenter,
2972 date: ProductDate,
2973 sample: Option<&str>,
2974) -> Result<ProductSpec, DataCatalogError> {
2975 product(center, ProductType::Ionex, date, sample, None)
2976}
2977
2978pub fn rapid_ionex(
2980 date: ProductDate,
2981 sample: Option<&str>,
2982) -> Result<ProductSpec, DataCatalogError> {
2983 product(
2984 AnalysisCenter::CodRap,
2985 ProductType::Ionex,
2986 date,
2987 sample,
2988 None,
2989 )
2990}
2991
2992#[must_use]
2994pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2995 match center {
2996 AnalysisCenter::CodPrd2 => 1,
2997 _ => 0,
2998 }
2999}
3000
3001pub fn predicted_ionex(
3003 center: AnalysisCenter,
3004 date: ProductDate,
3005 sample: Option<&str>,
3006) -> Result<ProductSpec, DataCatalogError> {
3007 match center {
3008 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
3009 let target = date.add_days(predicted_day_offset(center))?;
3010 product(center, ProductType::Ionex, target, sample, None)
3011 }
3012 other => Err(DataCatalogError::UnsupportedProduct {
3013 center: other,
3014 product_type: ProductType::Ionex,
3015 }),
3016 }
3017}
3018
3019pub fn mgex_sp3(
3021 center: AnalysisCenter,
3022 date: ProductDate,
3023 sample: Option<&str>,
3024) -> Result<ProductSpec, DataCatalogError> {
3025 product(center, ProductType::Sp3, date, sample, None)
3026}
3027
3028pub fn ops_ultra_sp3(
3030 center: AnalysisCenter,
3031 date: ProductDate,
3032 sample: Option<&str>,
3033 issue: Option<&str>,
3034) -> Result<ProductSpec, DataCatalogError> {
3035 let issue = issue.unwrap_or("0000");
3036 product(center, ProductType::Sp3, date, sample, Some(issue))
3037}
3038
3039pub fn ultra_sp3_locations(
3049 center: AnalysisCenter,
3050 date: ProductDate,
3051 issue: &str,
3052) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
3053 validate_issue_for_center(center, Some(issue))?;
3054 validate_product_date(center, ProductType::Sp3, date)?;
3055 match center {
3056 AnalysisCenter::IgsUlt
3057 | AnalysisCenter::CodUlt
3058 | AnalysisCenter::EsaUlt
3059 | AnalysisCenter::GfzUlt
3060 | AnalysisCenter::WumNrt => {}
3061 other => {
3062 return Err(DataCatalogError::UnsupportedProduct {
3063 center: other,
3064 product_type: ProductType::Sp3,
3065 })
3066 }
3067 };
3068 let default_sample =
3069 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
3070 let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
3071 samples.sort_by_key(|sample| *sample != default_sample);
3072
3073 samples
3074 .into_iter()
3075 .map(|sample| {
3076 let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3080 let identity = spec.identity()?;
3081 let filename = spec.canonical_filename()?;
3082 let url = spec.archive_url()?;
3083 let convention = product_convention(center, ProductType::Sp3)?;
3084 let compression = product_archive_compression(
3085 center,
3086 ProductType::Sp3,
3087 date,
3088 convention.compression,
3089 )?;
3090 Ok(UltraSp3Location {
3091 pattern: if sample == default_sample {
3092 format!("primary_{}_{}", identity.span, sample)
3093 } else {
3094 format!("alternate_{}_{}", identity.span, sample)
3095 },
3096 span: identity.span,
3097 sample: sample.to_string(),
3098 url,
3099 filename,
3100 compression,
3101 })
3102 })
3103 .collect()
3104}
3105
3106pub fn ops_ultra_clk(
3108 center: AnalysisCenter,
3109 date: ProductDate,
3110 sample: Option<&str>,
3111 issue: Option<&str>,
3112) -> Result<ProductSpec, DataCatalogError> {
3113 let issue = issue.unwrap_or("0000");
3114 product(center, ProductType::Clk, date, sample, Some(issue))
3115}
3116
3117pub fn latest_ops_ultra_sp3(
3119 center: AnalysisCenter,
3120 target: ProductDateTime,
3121 sample: Option<&str>,
3122 available_issues: Option<&[UltraIssue]>,
3123) -> Result<ProductSpec, DataCatalogError> {
3124 let selected = latest_ultra_issue(center, target, available_issues)?;
3125 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3126}
3127
3128pub fn ultra_issue_candidates(
3130 center: AnalysisCenter,
3131 target: ProductDateTime,
3132) -> Result<Vec<UltraIssue>, DataCatalogError> {
3133 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3134 let _ = product_convention(center, ProductType::Sp3)?;
3135 if entry.issues.is_empty() {
3136 return Err(DataCatalogError::UnsupportedProduct {
3137 center,
3138 product_type: ProductType::Sp3,
3139 });
3140 }
3141 validate_product_date(center, ProductType::Sp3, target.date)?;
3142
3143 let mut candidates = Vec::new();
3144 for date in [target.date, target.date.add_days(-1)?] {
3145 match validate_product_date(center, ProductType::Sp3, date) {
3146 Ok(()) => {}
3147 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3148 Err(error) => return Err(error),
3149 }
3150 for issue in entry.issues.iter().rev() {
3151 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3152 candidates.push(UltraIssue::new(date, issue)?);
3153 }
3154 }
3155 }
3156 Ok(candidates)
3157}
3158
3159pub fn latest_ultra_issue(
3161 center: AnalysisCenter,
3162 target: ProductDateTime,
3163 available_issues: Option<&[UltraIssue]>,
3164) -> Result<UltraIssue, DataCatalogError> {
3165 let candidates = ultra_issue_candidates(center, target)?;
3166 if candidates.is_empty() {
3167 return Err(DataCatalogError::NoUltraIssue);
3168 }
3169 if let Some(available) = available_issues {
3170 candidates
3171 .into_iter()
3172 .find(|candidate| {
3173 available
3174 .iter()
3175 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3176 })
3177 .ok_or(DataCatalogError::NoAvailableUltraIssue)
3178 } else {
3179 Ok(candidates[0].clone())
3180 }
3181}
3182
3183pub fn predicted_ionex_line_candidates(
3214 map_date: ProductDate,
3215 sample: Option<&str>,
3216) -> Result<Vec<ProductSpec>, DataCatalogError> {
3217 let one_day = predicted_ionex(AnalysisCenter::CodPrd1, map_date, sample)?;
3218 let two_day_production_date = map_date.add_days(-1)?;
3219 let two_day = predicted_ionex(AnalysisCenter::CodPrd2, two_day_production_date, sample)?;
3220 if one_day.date != map_date || two_day.date != map_date {
3224 return Err(DataCatalogError::InconsistentProductIdentity {
3225 field: "predicted_ionex_map_date",
3226 });
3227 }
3228 Ok(vec![one_day, two_day])
3229}
3230
3231pub fn gim_date_candidates(
3233 center: AnalysisCenter,
3234 target: ProductDate,
3235 lookback: u32,
3236) -> Result<Vec<ProductDate>, DataCatalogError> {
3237 let _ = product_convention(center, ProductType::Ionex)?;
3238 let base = target.add_days(predicted_day_offset(center))?;
3239 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3240 for back in 0..=lookback {
3241 out.push(base.add_days(-i64::from(back))?);
3242 }
3243 Ok(out)
3244}
3245
3246#[derive(Debug, Clone, PartialEq, Eq)]
3277pub struct PublishedObject {
3278 pub path: String,
3280 pub observed_at: Option<String>,
3282}
3283
3284#[derive(Debug, Clone, PartialEq, Eq)]
3287pub struct PublishedProduct {
3288 pub date: ProductDate,
3290 pub issue: String,
3292 pub filename: String,
3294 pub observed_at: Option<String>,
3296}
3297
3298pub fn parse_archive_listing(body: &str) -> Result<Vec<PublishedObject>, DataCatalogError> {
3327 let mut seen: Vec<PublishedObject> = Vec::new();
3328 let mut push = |path: String, observed_at: Option<String>| {
3329 if let Some(existing) = seen.iter_mut().find(|object| object.path == path) {
3330 if existing.observed_at.is_none() {
3331 existing.observed_at = observed_at;
3332 }
3333 } else {
3334 seen.push(PublishedObject { path, observed_at });
3335 }
3336 };
3337 let unrecognized = |reason: &str| DataCatalogError::UnrecognizedArchiveListing {
3338 reason: reason.to_string(),
3339 };
3340
3341 let non_empty: Vec<&str> = body
3342 .lines()
3343 .map(str::trim_end)
3344 .filter(|line| !line.trim().is_empty())
3345 .collect();
3346 if non_empty.is_empty() {
3347 return Err(unrecognized("empty body"));
3348 }
3349 let has_markup = body.contains('<');
3350
3351 if !has_markup && non_empty[0].matches(';').count() >= 3 {
3353 for line in &non_empty {
3354 if line.matches(';').count() < 3 {
3355 return Err(unrecognized("CSV row without its four fields"));
3356 }
3357 let mut fields = line.split(';');
3358 let (Some(path), Some(_bytes), Some(observed)) =
3359 (fields.next(), fields.next(), fields.next())
3360 else {
3361 return Err(unrecognized("CSV row without its four fields"));
3362 };
3363 if path.is_empty() || path.contains(' ') {
3364 return Err(unrecognized("CSV row without an archive path"));
3365 }
3366 if path.ends_with('/') {
3368 continue;
3369 }
3370 let observed_at =
3371 (!observed.is_empty() && observed != "-1").then(|| observed.to_string());
3372 push(path.to_string(), observed_at);
3373 }
3374 return Ok(seen);
3375 }
3376
3377 if !has_markup && non_empty[0].starts_with(['-', 'd', 'l']) {
3379 for (index, line) in non_empty.iter().enumerate() {
3380 if index == 0 && line.starts_with("total ") {
3381 continue;
3382 }
3383 let mode_shaped = line.len() > 10
3384 && line.starts_with(['-', 'd', 'l'])
3385 && line.as_bytes()[1..10]
3386 .iter()
3387 .all(|byte| matches!(byte, b'r' | b'w' | b'x' | b'-' | b's' | b't'));
3388 if !mode_shaped {
3389 return Err(unrecognized("FTP LIST row without a Unix mode field"));
3390 }
3391 if !line.starts_with('-') {
3393 continue;
3394 }
3395 let fields: Vec<&str> = line.split_whitespace().collect();
3396 if fields.len() < 9 {
3397 return Err(unrecognized("FTP LIST file row without nine fields"));
3398 }
3399 push(fields[8..].join(" "), Some(fields[5..8].join(" ")));
3400 }
3401 return Ok(seen);
3402 }
3403
3404 if has_markup && body.contains("Index of") {
3406 for line in &non_empty {
3407 let mut rest = *line;
3410 while let Some(start) = rest.find("<a href=\"") {
3411 rest = &rest[start + 9..];
3412 let Some(end) = rest.find('"') else { break };
3413 let target = &rest[..end];
3414 rest = &rest[end..];
3415 if target.is_empty()
3416 || target.starts_with('?')
3417 || target.starts_with('/')
3418 || target.starts_with('#')
3419 || target.contains("://")
3420 || target.ends_with('/')
3421 {
3422 continue;
3423 }
3424 let observed_at = find_listing_datetime(rest).map(str::to_string);
3425 push(target.to_string(), observed_at);
3426 }
3427 }
3428 return Ok(seen);
3429 }
3430
3431 Err(unrecognized(if has_markup {
3432 "markup without an autoindex marker"
3433 } else {
3434 "no known listing grammar"
3435 }))
3436}
3437
3438fn find_listing_datetime(rest: &str) -> Option<&str> {
3440 let bytes = rest.as_bytes();
3441 let is_digit = |index: usize| bytes.get(index).is_some_and(u8::is_ascii_digit);
3442 for start in 0..bytes.len().saturating_sub(15) {
3443 let shape_matches = is_digit(start)
3444 && is_digit(start + 1)
3445 && is_digit(start + 2)
3446 && is_digit(start + 3)
3447 && bytes[start + 4] == b'-'
3448 && is_digit(start + 5)
3449 && is_digit(start + 6)
3450 && bytes[start + 7] == b'-'
3451 && is_digit(start + 8)
3452 && is_digit(start + 9)
3453 && bytes[start + 10] == b' '
3454 && is_digit(start + 11)
3455 && is_digit(start + 12)
3456 && bytes[start + 13] == b':'
3457 && is_digit(start + 14)
3458 && is_digit(start + 15);
3459 if shape_matches {
3460 return Some(&rest[start..start + 16]);
3461 }
3462 }
3463 None
3464}
3465
3466const fn center_path_marker(center: AnalysisCenter) -> Option<&'static str> {
3469 match center {
3470 AnalysisCenter::CodPrd1 => Some("/IONO/P1/"),
3471 AnalysisCenter::CodPrd2 => Some("/IONO/P2/"),
3472 _ => None,
3473 }
3474}
3475
3476fn object_matches_center(center: AnalysisCenter, path: &str) -> bool {
3477 match center_path_marker(center) {
3478 Some(marker) => {
3481 let slashed = format!("/{path}");
3482 slashed.contains(marker)
3483 }
3484 None => true,
3485 }
3486}
3487
3488pub fn newest_published_product(
3502 center: AnalysisCenter,
3503 product_type: ProductType,
3504 objects: &[PublishedObject],
3505) -> Result<Option<PublishedProduct>, DataCatalogError> {
3506 let convention = product_convention(center, product_type)?;
3507 let descriptor = product_type_convention(product_type);
3508 let suffix = format!(".{}", descriptor.extension);
3509 let tail = format!("_{}{}", descriptor.content_code, suffix);
3510
3511 let mut newest: Option<(i64, PublishedProduct)> = None;
3512 for object in objects {
3513 if !object_matches_center(center, &object.path) {
3514 continue;
3515 }
3516 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3517 let stripped = listed_name
3518 .strip_suffix(".gz")
3519 .or_else(|| listed_name.strip_suffix(".Z"))
3520 .unwrap_or(listed_name);
3521 let Some(after_token) = stripped
3522 .strip_prefix(convention.token)
3523 .and_then(|rest| rest.strip_prefix('_'))
3524 else {
3525 continue;
3526 };
3527 let Some(middle) = after_token.strip_suffix(&tail) else {
3528 continue;
3529 };
3530 let mut parts = middle.split('_');
3531 let (Some(block), Some(span), Some(sample), None) =
3532 (parts.next(), parts.next(), parts.next(), parts.next())
3533 else {
3534 continue;
3535 };
3536 if span != convention.span || block.len() != 11 {
3537 continue;
3538 }
3539 let (Ok(year), Ok(day_of_year)) = (block[0..4].parse::<i32>(), block[4..7].parse::<u16>())
3540 else {
3541 continue;
3542 };
3543 let issue = &block[7..11];
3544 let Ok(date) = product_date_from_year_day(year, day_of_year) else {
3545 continue;
3546 };
3547 if validate_issue(issue).is_err() {
3548 continue;
3549 }
3550 let issue_argument = (!center_catalog(center)
3553 .expect("catalog entry exists for enum variant")
3554 .issues
3555 .is_empty())
3556 .then_some(issue);
3557 match product(center, product_type, date, Some(sample), issue_argument) {
3558 Ok(spec) => {
3559 if spec.canonical_filename()? != stripped {
3560 continue;
3561 }
3562 }
3563 Err(_) => continue,
3564 }
3565 let ordering = issue_ordering_minutes(date, issue)?;
3566 let replace = newest
3567 .as_ref()
3568 .is_none_or(|(newest_ordering, _)| ordering > *newest_ordering);
3569 if replace {
3570 newest = Some((
3571 ordering,
3572 PublishedProduct {
3573 date,
3574 issue: issue.to_string(),
3575 filename: stripped.to_string(),
3576 observed_at: object.observed_at.clone(),
3577 },
3578 ));
3579 }
3580 }
3581 Ok(newest.map(|(_, product)| product))
3582}
3583
3584pub fn published_issue_age_minutes(
3592 published: &PublishedProduct,
3593 now: ProductDateTime,
3594) -> Result<i64, DataCatalogError> {
3595 Ok(now.ordering_minutes() - issue_ordering_minutes(published.date, &published.issue)?)
3596}
3597
3598pub fn publication_listing_urls(
3609 center: AnalysisCenter,
3610 product_type: ProductType,
3611 around: ProductDate,
3612) -> Result<Vec<String>, DataCatalogError> {
3613 let convention = product_convention(center, product_type)?;
3614 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3615 match convention.layout {
3616 ArchiveLayout::AiubCodeRoot
3617 | ArchiveLayout::AiubCodeYear
3618 | ArchiveLayout::AiubCodeMgexYear => {
3619 Ok(vec![format!("{}/full_listing.csv", entry.root_url)])
3620 }
3621 _ => {
3622 let current = format!(
3623 "{}/{}/",
3624 entry.root_url,
3625 product_dir_path(center, convention.layout, around)?
3626 );
3627 let previous_week_date = around.add_days(-7)?;
3628 let previous = format!(
3629 "{}/{}/",
3630 entry.root_url,
3631 product_dir_path(center, convention.layout, previous_week_date)?
3632 );
3633 let mut urls = vec![current];
3634 if !urls.contains(&previous) {
3635 urls.push(previous);
3636 }
3637 Ok(urls)
3638 }
3639 }
3640}
3641
3642pub fn resolve_first_published(
3652 candidates: &[ProductSpec],
3653 objects: &[PublishedObject],
3654) -> Result<Option<usize>, DataCatalogError> {
3655 for (index, candidate) in candidates.iter().enumerate() {
3656 let filename = candidate.canonical_filename()?;
3657 let convention = product_convention(candidate.center, candidate.product_type)?;
3658 let compression = product_archive_compression(
3659 candidate.center,
3660 candidate.product_type,
3661 candidate.date,
3662 convention.compression,
3663 )?;
3664 let archive_name = format!("{filename}{}", compression.suffix());
3665 let found = objects.iter().any(|object| {
3666 if !object_matches_center(candidate.center, &object.path) {
3667 return false;
3668 }
3669 let listed_name = object.path.rsplit('/').next().unwrap_or(&object.path);
3670 listed_name == archive_name || listed_name == filename
3671 });
3672 if found {
3673 return Ok(Some(index));
3674 }
3675 }
3676 Ok(None)
3677}
3678
3679fn product_date_from_year_day(
3680 year: i32,
3681 day_of_year: u16,
3682) -> Result<ProductDate, DataCatalogError> {
3683 if day_of_year == 0 {
3684 return Err(DataCatalogError::DateOutOfRange);
3685 }
3686 ProductDate::new(year, 1, 1)?
3687 .add_days(i64::from(day_of_year) - 1)
3688 .and_then(|date| {
3689 if date.year == year {
3690 Ok(date)
3691 } else {
3692 Err(DataCatalogError::DateOutOfRange)
3693 }
3694 })
3695}
3696
3697pub fn station_obs(
3699 station: &str,
3700 date: ProductDate,
3701 sample: Option<&str>,
3702) -> Result<StationObservationSpec, DataCatalogError> {
3703 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3704}
3705
3706pub fn station_obs_filename(
3708 station: &str,
3709 date: ProductDate,
3710 sample: &str,
3711) -> Result<String, DataCatalogError> {
3712 validate_station(station)?;
3713 validate_sample(sample)?;
3714 Ok(format!(
3715 "{}_R_{}_01D_{}_MO.crx",
3716 station,
3717 date_block(date, None),
3718 sample
3719 ))
3720}
3721
3722pub fn station_obs_url(
3724 station: &str,
3725 date: ProductDate,
3726 sample: &str,
3727) -> Result<String, DataCatalogError> {
3728 let filename = station_obs_filename(station, date, sample)?;
3729 Ok(format!(
3730 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3731 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3732 filename
3733 ))
3734}
3735
3736#[must_use]
3738pub const fn station_obs_protocol() -> ArchiveProtocol {
3739 ArchiveProtocol::Https
3740}
3741
3742fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3743 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3744 Ok(())
3745 } else {
3746 Err(DataCatalogError::InvalidTileIndex {
3747 lat_index,
3748 lon_index: 0,
3749 })
3750 }
3751}
3752
3753fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3754 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3755 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3756 {
3757 Ok(())
3758 } else {
3759 Err(DataCatalogError::InvalidTileIndex {
3760 lat_index,
3761 lon_index,
3762 })
3763 }
3764}
3765
3766fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3767 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3768 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3769 {
3770 Ok(())
3771 } else {
3772 Err(HgtConversionError::InvalidTileIndex {
3773 lat_index,
3774 lon_index,
3775 })
3776 }
3777}
3778
3779fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3780 let hemi = match (is_longitude, index >= 0) {
3781 (true, true) => 'E',
3782 (true, false) => 'W',
3783 (false, true) => 'N',
3784 (false, false) => 'S',
3785 };
3786 format!("{:03}0000{hemi}", index.abs())
3787}
3788
3789fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3790 if sample == i16::MIN {
3791 0
3792 } else if sample >= 0 {
3793 sample as u16
3794 } else {
3795 0x8000 | (-i32::from(sample) as u16)
3796 }
3797}
3798
3799fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3800 PRODUCT_TYPE_CONVENTIONS
3801 .iter()
3802 .find(|descriptor| descriptor.product_type == product_type)
3803 .expect("product descriptor exists for enum variant")
3804}
3805
3806const fn product_format(product_type: ProductType) -> ProductFormat {
3807 match product_type {
3808 ProductType::Sp3 => ProductFormat::Sp3,
3809 ProductType::Ionex => ProductFormat::Ionex,
3810 ProductType::Clk => ProductFormat::RinexClock,
3811 ProductType::Nav => ProductFormat::RinexNavigation,
3812 }
3813}
3814
3815fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3816 if filename.is_empty()
3817 || filename == "."
3818 || filename == ".."
3819 || filename.contains('/')
3820 || filename.contains('\\')
3821 || filename.contains('\0')
3822 || filename.contains("..")
3823 {
3824 Err(DataCatalogError::InvalidOfficialFilename(
3825 filename.to_string(),
3826 ))
3827 } else {
3828 Ok(())
3829 }
3830}
3831
3832fn validate_product(
3833 center: AnalysisCenter,
3834 product_type: ProductType,
3835 date: ProductDate,
3836 sample: &str,
3837 issue: Option<&str>,
3838) -> Result<&'static CenterProductConvention, DataCatalogError> {
3839 let convention = product_convention(center, product_type)?;
3840 validate_sample(sample)?;
3841 validate_issue_for_center(center, issue)?;
3842 validate_product_date(center, product_type, date)?;
3843 validate_catalog_sample(center, product_type, date, sample, issue)?;
3844 Ok(convention)
3845}
3846
3847fn validate_catalog_sample(
3848 center: AnalysisCenter,
3849 product_type: ProductType,
3850 date: ProductDate,
3851 sample: &str,
3852 issue: Option<&str>,
3853) -> Result<(), DataCatalogError> {
3854 let supported = supported_samples_inner(center, product_type, date, issue)?;
3855 if supported.contains(&sample) {
3856 return Ok(());
3857 }
3858 Err(DataCatalogError::UnsupportedSample {
3859 center,
3860 product_type,
3861 sample: sample.to_string(),
3862 })
3863}
3864
3865pub fn supported_samples(
3876 center: AnalysisCenter,
3877 product_type: ProductType,
3878 date: ProductDate,
3879 issue: Option<&str>,
3880) -> Result<&'static [&'static str], DataCatalogError> {
3881 ProductDate::new(date.year, date.month, date.day)?;
3882 product_convention(center, product_type)?;
3883 validate_product_date(center, product_type, date)?;
3884
3885 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3886 if entry.issues.is_empty() {
3887 validate_issue_for_center(center, issue)?;
3888 } else {
3889 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3890 }
3891 supported_samples_inner(center, product_type, date, issue)
3892}
3893
3894fn supported_samples_inner(
3895 center: AnalysisCenter,
3896 product_type: ProductType,
3897 date: ProductDate,
3898 issue: Option<&str>,
3899) -> Result<&'static [&'static str], DataCatalogError> {
3900 if product_type != ProductType::Sp3 {
3901 let convention = product_convention(center, product_type)?;
3902 return Ok(match convention.default_sample {
3903 "30S" => &["30S"],
3904 "01H" => &["01H"],
3905 "02H" => &["02H"],
3906 "01D" => &["01D"],
3907 _ => &[],
3908 });
3909 }
3910
3911 Ok(match center {
3912 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3913 AnalysisCenter::Esa
3914 | AnalysisCenter::Cod
3915 | AnalysisCenter::CodUlt
3916 | AnalysisCenter::WumNrt => &["05M"],
3917 AnalysisCenter::Gfz => {
3918 if date < GFZ_RAPID_5M_START_DATE {
3919 &["15M"]
3920 } else {
3921 &["05M"]
3922 }
3923 }
3924 AnalysisCenter::EsaUlt => {
3925 let issue = issue.unwrap_or("0000");
3926 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3927 || (date == ESA_ULTRA_15M_LAST_DATE
3928 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3929 if at_or_before_last_15m {
3930 &["15M"]
3931 } else {
3932 &["05M"]
3933 }
3934 }
3935 AnalysisCenter::GfzUlt => {
3936 if date < GFZ_ULTRA_15M_LAST_DATE {
3937 &["15M"]
3938 } else if date == GFZ_ULTRA_15M_LAST_DATE {
3939 if issue.unwrap_or("0000") == "0000" {
3940 &["15M", "05M"]
3941 } else {
3942 &["15M"]
3943 }
3944 } else {
3945 &["05M"]
3946 }
3947 }
3948 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3949 })
3950}
3951
3952fn validate_product_date(
3953 center: AnalysisCenter,
3954 product_type: ProductType,
3955 date: ProductDate,
3956) -> Result<(), DataCatalogError> {
3957 if center == AnalysisCenter::Igs
3961 && product_type == ProductType::Sp3
3962 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3963 {
3964 return Err(DataCatalogError::UnsupportedProductEra {
3965 center,
3966 product_type,
3967 date,
3968 });
3969 }
3970
3971 if center == AnalysisCenter::Cod
3976 && matches!(
3977 product_type,
3978 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3979 )
3980 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3981 {
3982 return Err(DataCatalogError::UnsupportedProductEra {
3983 center,
3984 product_type,
3985 date,
3986 });
3987 }
3988
3989 let start_date = match (center, product_type) {
3990 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
3991 Some(ESA_FINAL_SERIES_START_DATE)
3992 }
3993 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
3994 Some(GFZ_RAPID_SERIES_START_DATE)
3995 }
3996 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
3997 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
3998 (AnalysisCenter::WumNrt, ProductType::Sp3) => Some(WUM_NRT_SP3_START_DATE),
3999 _ => None,
4000 };
4001 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
4002 && product_type == ProductType::Sp3
4003 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
4004 if before_long_name_start || start_date.is_some_and(|start| date < start) {
4005 return Err(DataCatalogError::UnsupportedProductEra {
4006 center,
4007 product_type,
4008 date,
4009 });
4010 }
4011 Ok(())
4012}
4013
4014fn default_sample_for_product_issue(
4015 center: AnalysisCenter,
4016 product_type: ProductType,
4017 date: ProductDate,
4018 issue: Option<&str>,
4019) -> Result<&'static str, DataCatalogError> {
4020 ProductDate::new(date.year, date.month, date.day)?;
4021 let current = default_sample(center, product_type)?;
4022 validate_product_date(center, product_type, date)?;
4023
4024 if product_type != ProductType::Sp3 {
4025 return Ok(current);
4026 }
4027 match center {
4028 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
4029 AnalysisCenter::EsaUlt => {
4030 let issue = issue.unwrap_or("0000");
4034 validate_issue_for_center(center, Some(issue))?;
4035 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
4036 || (date == ESA_ULTRA_15M_LAST_DATE
4037 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
4038 if at_or_before_last_15m {
4039 Ok("15M")
4040 } else {
4041 Ok(current)
4042 }
4043 }
4044 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
4045 _ => Ok(current),
4046 }
4047}
4048
4049fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
4050 let gps_week = identity.date.gps_week()?;
4051 let esa_mgex_final_sp3 =
4052 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
4053 if identity.analysis_center == AnalysisCenter::WumNrt {
4057 return Err(DataCatalogError::UnsupportedDistributionEra {
4058 source: DistributionSource::NasaCddis,
4059 center: identity.analysis_center,
4060 product_type: identity.family,
4061 date: identity.date,
4062 });
4063 }
4064 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
4065 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
4066 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
4067 let unmodeled_pretransition_ionex =
4068 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
4069 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
4070 Err(DataCatalogError::UnsupportedDistributionEra {
4071 source: DistributionSource::NasaCddis,
4072 center: identity.analysis_center,
4073 product_type: identity.family,
4074 date: identity.date,
4075 })
4076 } else {
4077 Ok(())
4078 }
4079}
4080
4081fn validate_issue_for_center(
4082 center: AnalysisCenter,
4083 issue: Option<&str>,
4084) -> Result<(), DataCatalogError> {
4085 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
4086 match (entry.issues.is_empty(), issue) {
4087 (true, None) => Ok(()),
4088 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
4089 (false, None) => Err(DataCatalogError::MissingIssue { center }),
4090 (false, Some(issue)) => {
4091 validate_issue(issue)?;
4092 if entry.issues.contains(&issue) {
4093 Ok(())
4094 } else {
4095 Err(DataCatalogError::UnsupportedIssue {
4096 center,
4097 issue: issue.to_string(),
4098 })
4099 }
4100 }
4101 }
4102}
4103
4104fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
4105 if validate_period_token(sample) {
4106 Ok(())
4107 } else {
4108 Err(DataCatalogError::InvalidSample(sample.to_string()))
4109 }
4110}
4111
4112fn validate_span(span: &str) -> Result<(), DataCatalogError> {
4113 if validate_period_token(span) {
4114 Ok(())
4115 } else {
4116 Err(DataCatalogError::InvalidSpan(span.to_string()))
4117 }
4118}
4119
4120fn validate_period_token(token: &str) -> bool {
4121 let bytes = token.as_bytes();
4122 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
4123 return false;
4124 }
4125 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
4126 match bytes[2] {
4127 b'S' | b'M' => amount > 0 && amount % 60 != 0,
4132 b'H' => amount > 0 && amount % 24 != 0,
4133 b'D' | b'W' | b'L' | b'Y' => amount > 0,
4134 b'U' => amount == 0,
4137 _ => false,
4138 }
4139}
4140
4141fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
4142 let bytes = issue.as_bytes();
4143 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
4144 if !valid_digits {
4145 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
4146 }
4147 let hour = issue[0..2]
4148 .parse::<u8>()
4149 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4150 let minute = issue[2..4]
4151 .parse::<u8>()
4152 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4153 if hour <= 23 && minute <= 59 {
4154 Ok(())
4155 } else {
4156 Err(DataCatalogError::InvalidIssue(issue.to_string()))
4157 }
4158}
4159
4160fn validate_station(station: &str) -> Result<(), DataCatalogError> {
4161 let bytes = station.as_bytes();
4162 let valid = bytes.len() == 9
4163 && bytes
4164 .iter()
4165 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
4166 if valid {
4167 Ok(())
4168 } else {
4169 Err(DataCatalogError::InvalidStation(station.to_string()))
4170 }
4171}
4172
4173fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
4174 validate_issue(issue)?;
4175 let hour = issue[0..2]
4176 .parse::<u16>()
4177 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4178 let minute = issue[2..4]
4179 .parse::<u16>()
4180 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
4181 Ok(hour * 60 + minute)
4182}
4183
4184fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
4185 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
4186}
4187
4188fn date_block(date: ProductDate, issue: Option<&str>) -> String {
4189 format!(
4190 "{}{:03}{}",
4191 date.year,
4192 date.day_of_year(),
4193 issue.unwrap_or("0000")
4194 )
4195}
4196
4197fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
4198 Ok(match layout {
4199 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
4200 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
4201 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
4202 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
4203 ArchiveLayout::BkgBrdcYearDoy => {
4204 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
4205 }
4206 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
4207 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
4208 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
4209 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
4210 })
4211}
4212
4213fn product_dir_path(
4214 center: AnalysisCenter,
4215 layout: ArchiveLayout,
4216 date: ProductDate,
4217) -> Result<String, DataCatalogError> {
4218 match center {
4219 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
4220 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
4221 _ => dir_path(layout, date),
4222 }
4223}
4224
4225fn uses_legacy_igs_final_name(
4226 center: AnalysisCenter,
4227 product_type: ProductType,
4228 date: ProductDate,
4229) -> Result<bool, DataCatalogError> {
4230 Ok(center == AnalysisCenter::Igs
4231 && product_type == ProductType::Sp3
4232 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
4233}
4234
4235fn product_archive_compression(
4236 center: AnalysisCenter,
4237 product_type: ProductType,
4238 date: ProductDate,
4239 default: ArchiveCompression,
4240) -> Result<ArchiveCompression, DataCatalogError> {
4241 if uses_legacy_igs_final_name(center, product_type, date)? {
4242 Ok(ArchiveCompression::UnixCompress)
4243 } else {
4244 Ok(default)
4245 }
4246}
4247
4248fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
4249 let (year, month, day) = civil_from_julian_day_number(jdn);
4250 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
4251 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
4252 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
4253 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
4254}
4255
4256#[cfg(test)]
4257mod content_start_tests {
4258 use super::*;
4259
4260 const GFZ_ISSUES: [&str; 8] = [
4261 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
4262 ];
4263
4264 fn date(year: i32, month: u8, day: u8) -> ProductDate {
4265 ProductDate::new(year, month, day).expect("test date")
4266 }
4267
4268 fn offset(
4269 center: AnalysisCenter,
4270 product_date: ProductDate,
4271 sample: &str,
4272 issue: Option<&str>,
4273 ) -> i64 {
4274 let identity =
4275 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
4276 .expect("cataloged SP3 identity");
4277 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
4278 }
4279
4280 #[test]
4281 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
4282 for issue in GFZ_ISSUES {
4283 assert_eq!(
4284 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
4285 -86_400,
4286 "2022-09-06 issue {issue}"
4287 );
4288 }
4289 }
4290
4291 #[test]
4292 fn gfz_ultra_transition_is_cataloged_per_issue() {
4293 let day_seven = [
4294 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
4295 ];
4296 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
4297
4298 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
4299 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
4300 assert_eq!(
4301 offset(
4302 AnalysisCenter::GfzUlt,
4303 date(2022, 9, product_day),
4304 "05M",
4305 Some(issue)
4306 ),
4307 expected_offset,
4308 "2022-09-{product_day:02} issue {issue}"
4309 );
4310 }
4311 }
4312 }
4313
4314 #[test]
4315 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
4316 for issue in GFZ_ISSUES {
4317 assert_eq!(
4318 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
4319 0,
4320 "2022-09-09 issue {issue}"
4321 );
4322 }
4323
4324 let current = date(2026, 7, 20);
4325 let cases = [
4326 (AnalysisCenter::Igs, "15M", None),
4327 (AnalysisCenter::Esa, "05M", None),
4328 (AnalysisCenter::Cod, "05M", None),
4329 (AnalysisCenter::Gfz, "05M", None),
4330 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
4331 (AnalysisCenter::CodUlt, "05M", Some("0000")),
4332 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
4333 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
4334 ];
4335 for (center, sample, issue) in cases {
4336 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
4337 }
4338 }
4339
4340 #[test]
4341 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
4342 assert_eq!(
4343 offset(
4344 AnalysisCenter::GfzUlt,
4345 date(2021, 5, 15),
4346 "15M",
4347 Some("0000")
4348 ),
4349 -86_400
4350 );
4351 assert_eq!(
4352 offset(
4353 AnalysisCenter::GfzUlt,
4354 date(2021, 5, 16),
4355 "05M",
4356 Some("0000")
4357 ),
4358 -86_400
4359 );
4360 }
4361
4362 #[test]
4363 fn public_content_start_query_enforces_center_issue_rules() {
4364 assert_eq!(
4365 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
4366 Err(DataCatalogError::UnsupportedIssue {
4367 center: AnalysisCenter::GfzUlt,
4368 issue: "0130".to_owned(),
4369 })
4370 );
4371 assert_eq!(
4372 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
4373 Err(DataCatalogError::UnexpectedIssue {
4374 center: AnalysisCenter::Gfz,
4375 })
4376 );
4377 assert_eq!(
4378 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
4379 Err(DataCatalogError::MissingIssue {
4380 center: AnalysisCenter::GfzUlt,
4381 })
4382 );
4383 }
4384}