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}
44
45impl AnalysisCenter {
46 #[must_use]
48 pub const fn code(self) -> &'static str {
49 match self {
50 Self::Igs => "igs",
51 Self::CodRap => "cod_rap",
52 Self::CodPrd1 => "cod_prd1",
53 Self::CodPrd2 => "cod_prd2",
54 Self::Esa => "esa",
55 Self::Cod => "cod",
56 Self::Gfz => "gfz",
57 Self::IgsUlt => "igs_ult",
58 Self::CodUlt => "cod_ult",
59 Self::EsaUlt => "esa_ult",
60 Self::GfzUlt => "gfz_ult",
61 }
62 }
63
64 #[must_use]
66 pub fn from_code(code: &str) -> Option<Self> {
67 match code {
68 "igs" => Some(Self::Igs),
69 "cod_rap" => Some(Self::CodRap),
70 "cod_prd1" => Some(Self::CodPrd1),
71 "cod_prd2" => Some(Self::CodPrd2),
72 "esa" => Some(Self::Esa),
73 "cod" => Some(Self::Cod),
74 "gfz" => Some(Self::Gfz),
75 "igs_ult" => Some(Self::IgsUlt),
76 "cod_ult" => Some(Self::CodUlt),
77 "esa_ult" => Some(Self::EsaUlt),
78 "gfz_ult" => Some(Self::GfzUlt),
79 _ => None,
80 }
81 }
82
83 #[must_use]
85 pub const fn publisher(self) -> ProductPublisher {
86 match self {
87 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
88 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
89 ProductPublisher::Code
90 }
91 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
92 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
93 }
94 }
95
96 #[must_use]
102 pub const fn solution_class(self) -> SolutionClass {
103 match self {
104 Self::Igs => SolutionClass::Broadcast,
105 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
106 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
107 Self::Esa | Self::Cod => SolutionClass::Final,
108 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
109 }
110 }
111
112 #[must_use]
114 pub const fn prediction_horizon_days(self) -> Option<u8> {
115 match self {
116 Self::CodPrd1 => Some(1),
117 Self::CodPrd2 => Some(2),
118 _ => None,
119 }
120 }
121}
122
123impl fmt::Display for AnalysisCenter {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(self.code())
126 }
127}
128
129impl FromStr for AnalysisCenter {
130 type Err = DataCatalogError;
131
132 fn from_str(s: &str) -> Result<Self, Self::Err> {
133 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
139pub enum ProductType {
140 Sp3,
142 Clk,
144 Nav,
146 Ionex,
148}
149
150impl ProductType {
151 #[must_use]
153 pub const fn code(self) -> &'static str {
154 match self {
155 Self::Sp3 => "sp3",
156 Self::Clk => "clk",
157 Self::Nav => "nav",
158 Self::Ionex => "ionex",
159 }
160 }
161
162 #[must_use]
164 pub fn from_code(code: &str) -> Option<Self> {
165 match code {
166 "sp3" => Some(Self::Sp3),
167 "clk" => Some(Self::Clk),
168 "nav" => Some(Self::Nav),
169 "ionex" => Some(Self::Ionex),
170 _ => None,
171 }
172 }
173}
174
175impl fmt::Display for ProductType {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.write_str(self.code())
178 }
179}
180
181impl FromStr for ProductType {
182 type Err = DataCatalogError;
183
184 fn from_str(s: &str) -> Result<Self, Self::Err> {
185 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
195pub enum ProductPublisher {
196 Igs,
198 Code,
200 Esa,
202 Gfz,
204}
205
206impl ProductPublisher {
207 #[must_use]
209 pub const fn code(self) -> &'static str {
210 match self {
211 Self::Igs => "IGS",
212 Self::Code => "COD",
213 Self::Esa => "ESA",
214 Self::Gfz => "GFZ",
215 }
216 }
217}
218
219impl fmt::Display for ProductPublisher {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 f.write_str(self.code())
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
227pub enum SolutionClass {
228 Final,
230 Rapid,
232 UltraRapid,
234 Predicted,
236 Broadcast,
238}
239
240impl SolutionClass {
241 #[must_use]
243 pub const fn code(self) -> &'static str {
244 match self {
245 Self::Final => "final",
246 Self::Rapid => "rapid",
247 Self::UltraRapid => "ultra_rapid",
248 Self::Predicted => "predicted",
249 Self::Broadcast => "broadcast",
250 }
251 }
252
253 #[must_use]
255 pub const fn filename_token(self) -> Option<&'static str> {
256 match self {
257 Self::Final => Some("FIN"),
258 Self::Rapid => Some("RAP"),
259 Self::UltraRapid => Some("ULT"),
260 Self::Predicted => Some("PRD"),
261 Self::Broadcast => None,
262 }
263 }
264}
265
266impl fmt::Display for SolutionClass {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 f.write_str(self.code())
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
274pub enum ProductCampaign {
275 Operational,
277 MultiGnss,
279 MultiGnssExperiment,
281 Broadcast,
283}
284
285impl ProductCampaign {
286 #[must_use]
288 pub const fn code(self) -> &'static str {
289 match self {
290 Self::Operational => "OPS",
291 Self::MultiGnss => "MGN",
292 Self::MultiGnssExperiment => "MGX",
293 Self::Broadcast => "BRD",
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
300pub enum ProductFormat {
301 Sp3,
303 Ionex,
305 RinexClock,
307 RinexNavigation,
309}
310
311impl ProductFormat {
312 #[must_use]
314 pub const fn code(self) -> &'static str {
315 match self {
316 Self::Sp3 => "SP3",
317 Self::Ionex => "IONEX",
318 Self::RinexClock => "RINEX_CLK",
319 Self::RinexNavigation => "RINEX_NAV",
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
329pub enum DistributionSource {
330 Direct,
332 NasaCddis,
334 LocalFile,
336 InMemory,
338}
339
340impl DistributionSource {
341 #[must_use]
343 pub const fn code(self) -> &'static str {
344 match self {
345 Self::Direct => "direct",
346 Self::NasaCddis => "nasa_cddis",
347 Self::LocalFile => "local_file",
348 Self::InMemory => "in_memory",
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
355pub enum SpaceWeatherProduct {
356 All,
358 Last5Years,
360}
361
362impl SpaceWeatherProduct {
363 #[must_use]
365 pub const fn code(self) -> &'static str {
366 match self {
367 Self::All => "sw_all",
368 Self::Last5Years => "sw_last5",
369 }
370 }
371
372 #[must_use]
374 pub fn from_code(code: &str) -> Option<Self> {
375 match code {
376 "sw_all" => Some(Self::All),
377 "sw_last5" => Some(Self::Last5Years),
378 _ => None,
379 }
380 }
381}
382
383impl fmt::Display for SpaceWeatherProduct {
384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385 f.write_str(self.code())
386 }
387}
388
389impl FromStr for SpaceWeatherProduct {
390 type Err = DataCatalogError;
391
392 fn from_str(s: &str) -> Result<Self, Self::Err> {
393 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum ArchiveProtocol {
400 Http,
402 Https,
404}
405
406impl ArchiveProtocol {
407 #[must_use]
409 pub const fn as_str(self) -> &'static str {
410 match self {
411 Self::Http => "http",
412 Self::Https => "https",
413 }
414 }
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
419pub enum ArchiveCompression {
420 Gzip,
422 UnixCompress,
424 None,
426}
427
428impl ArchiveCompression {
429 #[must_use]
431 pub const fn as_str(self) -> &'static str {
432 match self {
433 Self::Gzip => "gzip",
434 Self::UnixCompress => "unix_compress",
435 Self::None => "none",
436 }
437 }
438
439 const fn suffix(self) -> &'static str {
440 match self {
441 Self::Gzip => ".gz",
442 Self::UnixCompress => ".Z",
443 Self::None => "",
444 }
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
450pub enum ArchiveLayout {
451 GfzRapidWeek,
453 GfzUltraWeek,
455 GpsWeek,
457 BkgProductsWeek,
459 BkgBrdcYearDoy,
461 BkgObsYearDoy,
463 AiubCodeMgexYear,
465 AiubCodeYear,
467 AiubCodeRoot,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub enum ProductFilenameKind {
474 Sampled,
476 Nav,
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub struct ProductTypeConvention {
483 pub product_type: ProductType,
485 pub content_code: &'static str,
487 pub extension: &'static str,
489 pub kind: ProductFilenameKind,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub struct CenterProductConvention {
496 pub product_type: ProductType,
498 pub token: &'static str,
500 pub layout: ArchiveLayout,
502 pub span: &'static str,
504 pub default_sample: &'static str,
506 pub compression: ArchiveCompression,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub struct CenterCatalogEntry {
513 pub center: AnalysisCenter,
515 pub code: &'static str,
517 pub protocol: ArchiveProtocol,
519 pub host: &'static str,
521 pub root_url: &'static str,
523 pub products: &'static [CenterProductConvention],
525 pub issues: &'static [&'static str],
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub struct TerrainSourceEntry {
532 pub protocol: ArchiveProtocol,
534 pub host: &'static str,
536 pub compression: ArchiveCompression,
538 pub root_url: &'static str,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub struct SpaceWeatherSourceEntry {
545 pub protocol: ArchiveProtocol,
547 pub host: &'static str,
549 pub compression: ArchiveCompression,
551 pub root_url: &'static str,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
557pub struct NoOpenMirrorProduct {
558 pub center: &'static str,
560 pub product_type: &'static str,
562}
563
564const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
565 ProductTypeConvention {
566 product_type: ProductType::Sp3,
567 content_code: "ORB",
568 extension: "SP3",
569 kind: ProductFilenameKind::Sampled,
570 },
571 ProductTypeConvention {
572 product_type: ProductType::Clk,
573 content_code: "CLK",
574 extension: "CLK",
575 kind: ProductFilenameKind::Sampled,
576 },
577 ProductTypeConvention {
578 product_type: ProductType::Nav,
579 content_code: "MN",
580 extension: "rnx",
581 kind: ProductFilenameKind::Nav,
582 },
583 ProductTypeConvention {
584 product_type: ProductType::Ionex,
585 content_code: "GIM",
586 extension: "INX",
587 kind: ProductFilenameKind::Sampled,
588 },
589];
590
591const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
592 product_type: ProductType::Ionex,
593 token: "COD0OPSRAP",
594 layout: ArchiveLayout::AiubCodeRoot,
595 span: "01D",
596 default_sample: "01H",
597 compression: ArchiveCompression::Gzip,
598}];
599
600const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
601 product_type: ProductType::Ionex,
602 token: "COD0OPSPRD",
603 layout: ArchiveLayout::AiubCodeRoot,
604 span: "01D",
605 default_sample: "01H",
606 compression: ArchiveCompression::Gzip,
607}];
608
609const ESA_PRODUCTS: [CenterProductConvention; 3] = [
610 CenterProductConvention {
611 product_type: ProductType::Sp3,
612 token: "ESA0MGNFIN",
613 layout: ArchiveLayout::GpsWeek,
614 span: "01D",
615 default_sample: "05M",
616 compression: ArchiveCompression::Gzip,
617 },
618 CenterProductConvention {
619 product_type: ProductType::Clk,
620 token: "ESA0MGNFIN",
621 layout: ArchiveLayout::GpsWeek,
622 span: "01D",
623 default_sample: "30S",
624 compression: ArchiveCompression::Gzip,
625 },
626 CenterProductConvention {
627 product_type: ProductType::Ionex,
628 token: "ESA0OPSFIN",
629 layout: ArchiveLayout::GpsWeek,
630 span: "01D",
631 default_sample: "02H",
632 compression: ArchiveCompression::Gzip,
633 },
634];
635
636const COD_PRODUCTS: [CenterProductConvention; 3] = [
637 CenterProductConvention {
638 product_type: ProductType::Sp3,
639 token: "COD0MGXFIN",
640 layout: ArchiveLayout::AiubCodeMgexYear,
641 span: "01D",
642 default_sample: "05M",
643 compression: ArchiveCompression::Gzip,
644 },
645 CenterProductConvention {
646 product_type: ProductType::Clk,
647 token: "COD0MGXFIN",
648 layout: ArchiveLayout::AiubCodeMgexYear,
649 span: "01D",
650 default_sample: "30S",
651 compression: ArchiveCompression::Gzip,
652 },
653 CenterProductConvention {
654 product_type: ProductType::Ionex,
655 token: "COD0OPSFIN",
656 layout: ArchiveLayout::AiubCodeYear,
657 span: "01D",
658 default_sample: "01H",
659 compression: ArchiveCompression::Gzip,
660 },
661];
662
663const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
664 CenterProductConvention {
665 product_type: ProductType::Sp3,
666 token: "GFZ0OPSRAP",
667 layout: ArchiveLayout::GfzRapidWeek,
668 span: "01D",
669 default_sample: "05M",
670 compression: ArchiveCompression::Gzip,
671 },
672 CenterProductConvention {
673 product_type: ProductType::Clk,
674 token: "GFZ0OPSRAP",
675 layout: ArchiveLayout::GfzRapidWeek,
676 span: "01D",
677 default_sample: "30S",
678 compression: ArchiveCompression::Gzip,
679 },
680];
681
682const IGS_PRODUCTS: [CenterProductConvention; 2] = [
683 CenterProductConvention {
684 product_type: ProductType::Sp3,
685 token: "IGS0OPSFIN",
686 layout: ArchiveLayout::BkgProductsWeek,
687 span: "01D",
688 default_sample: "15M",
689 compression: ArchiveCompression::Gzip,
690 },
691 CenterProductConvention {
692 product_type: ProductType::Nav,
693 token: "BRDC00WRD",
694 layout: ArchiveLayout::BkgBrdcYearDoy,
695 span: "01D",
696 default_sample: "01D",
697 compression: ArchiveCompression::Gzip,
698 },
699];
700
701const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
702 product_type: ProductType::Sp3,
703 token: "IGS0OPSULT",
704 layout: ArchiveLayout::BkgProductsWeek,
705 span: "02D",
706 default_sample: "15M",
707 compression: ArchiveCompression::Gzip,
708}];
709
710const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
711 product_type: ProductType::Sp3,
712 token: "COD0OPSULT",
713 layout: ArchiveLayout::AiubCodeRoot,
714 span: "01D",
715 default_sample: "05M",
716 compression: ArchiveCompression::None,
717}];
718
719const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
720 product_type: ProductType::Sp3,
721 token: "ESA0OPSULT",
722 layout: ArchiveLayout::GpsWeek,
723 span: "02D",
724 default_sample: "05M",
725 compression: ArchiveCompression::Gzip,
726}];
727
728const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
729 product_type: ProductType::Sp3,
730 token: "GFZ0OPSULT",
731 layout: ArchiveLayout::GfzUltraWeek,
732 span: "02D",
733 default_sample: "05M",
734 compression: ArchiveCompression::Gzip,
735}];
736
737const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
738const COD_ULT_ISSUES: [&str; 1] = ["0000"];
739const GFZ_ULT_ISSUES: [&str; 8] = [
740 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
741];
742
743const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
747
748const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
754
755const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
758
759const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
764 year: 2021,
765 month: 5,
766 day: 18,
767};
768
769const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
771 year: 2014,
772 month: 1,
773 day: 5,
774};
775
776const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
778 year: 2020,
779 month: 5,
780 day: 13,
781};
782
783const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
785 year: 2022,
786 month: 10,
787 day: 4,
788};
789
790const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
792 year: 2025,
793 month: 2,
794 day: 2,
795};
796const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
797
798const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
800 year: 2020,
801 month: 10,
802 day: 6,
803};
804
805const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
807 year: 2021,
808 month: 5,
809 day: 16,
810};
811
812const GFZ_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
818 year: 2021,
819 month: 5,
820 day: 15,
821};
822
823const GFZ_ULTRA_START_TRANSITION_FIRST_DATE: ProductDate = ProductDate {
830 year: 2022,
831 month: 9,
832 day: 7,
833};
834const GFZ_ULTRA_START_TRANSITION_LAST_DATE: ProductDate = ProductDate {
835 year: 2022,
836 month: 9,
837 day: 8,
838};
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
847#[non_exhaustive]
848pub enum Sp3ContentStartConvention {
849 FilenameEpoch,
851 FilenameEpochMinusOneDay,
853}
854
855impl Sp3ContentStartConvention {
856 #[must_use]
858 pub const fn code(self) -> &'static str {
859 match self {
860 Self::FilenameEpoch => "filename_epoch",
861 Self::FilenameEpochMinusOneDay => "filename_epoch_minus_one_day",
862 }
863 }
864
865 #[must_use]
868 pub const fn content_start_offset_s(self) -> i64 {
869 match self {
870 Self::FilenameEpoch => 0,
871 Self::FilenameEpochMinusOneDay => -86_400,
872 }
873 }
874}
875
876const GFZ_ULTRA_START_TRANSITION: [(ProductDate, &str, Sp3ContentStartConvention); 16] = [
883 (
884 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
885 "0000",
886 Sp3ContentStartConvention::FilenameEpoch,
887 ),
888 (
889 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
890 "0300",
891 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
892 ),
893 (
894 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
895 "0600",
896 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
897 ),
898 (
899 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
900 "0900",
901 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
902 ),
903 (
904 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
905 "1200",
906 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
907 ),
908 (
909 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
910 "1500",
911 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
912 ),
913 (
914 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
915 "1800",
916 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
917 ),
918 (
919 GFZ_ULTRA_START_TRANSITION_FIRST_DATE,
920 "2100",
921 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
922 ),
923 (
924 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
925 "0000",
926 Sp3ContentStartConvention::FilenameEpoch,
927 ),
928 (
929 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
930 "0300",
931 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
932 ),
933 (
934 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
935 "0600",
936 Sp3ContentStartConvention::FilenameEpochMinusOneDay,
937 ),
938 (
939 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
940 "0900",
941 Sp3ContentStartConvention::FilenameEpoch,
942 ),
943 (
944 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
945 "1200",
946 Sp3ContentStartConvention::FilenameEpoch,
947 ),
948 (
949 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
950 "1500",
951 Sp3ContentStartConvention::FilenameEpoch,
952 ),
953 (
954 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
955 "1800",
956 Sp3ContentStartConvention::FilenameEpoch,
957 ),
958 (
959 GFZ_ULTRA_START_TRANSITION_LAST_DATE,
960 "2100",
961 Sp3ContentStartConvention::FilenameEpoch,
962 ),
963];
964
965const CENTER_ORDER: [AnalysisCenter; 11] = [
966 AnalysisCenter::CodRap,
967 AnalysisCenter::CodPrd1,
968 AnalysisCenter::CodPrd2,
969 AnalysisCenter::Igs,
970 AnalysisCenter::Esa,
971 AnalysisCenter::Cod,
972 AnalysisCenter::Gfz,
973 AnalysisCenter::IgsUlt,
974 AnalysisCenter::CodUlt,
975 AnalysisCenter::EsaUlt,
976 AnalysisCenter::GfzUlt,
977];
978
979const CATALOG: [CenterCatalogEntry; 11] = [
980 CenterCatalogEntry {
981 center: AnalysisCenter::CodRap,
982 code: "cod_rap",
983 protocol: ArchiveProtocol::Https,
984 host: "www.aiub.unibe.ch",
985 root_url: "https://www.aiub.unibe.ch/download",
986 products: &COD_RAP_PRODUCTS,
987 issues: &[],
988 },
989 CenterCatalogEntry {
990 center: AnalysisCenter::CodPrd1,
991 code: "cod_prd1",
992 protocol: ArchiveProtocol::Https,
993 host: "www.aiub.unibe.ch",
994 root_url: "https://www.aiub.unibe.ch/download",
995 products: &COD_PRD_PRODUCTS,
996 issues: &[],
997 },
998 CenterCatalogEntry {
999 center: AnalysisCenter::CodPrd2,
1000 code: "cod_prd2",
1001 protocol: ArchiveProtocol::Https,
1002 host: "www.aiub.unibe.ch",
1003 root_url: "https://www.aiub.unibe.ch/download",
1004 products: &COD_PRD_PRODUCTS,
1005 issues: &[],
1006 },
1007 CenterCatalogEntry {
1008 center: AnalysisCenter::Igs,
1009 code: "igs",
1010 protocol: ArchiveProtocol::Https,
1011 host: "igs.bkg.bund.de",
1012 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1013 products: &IGS_PRODUCTS,
1014 issues: &[],
1015 },
1016 CenterCatalogEntry {
1017 center: AnalysisCenter::Esa,
1018 code: "esa",
1019 protocol: ArchiveProtocol::Https,
1020 host: "navigation-office.esa.int",
1021 root_url: "https://navigation-office.esa.int/products/gnss-products",
1022 products: &ESA_PRODUCTS,
1023 issues: &[],
1024 },
1025 CenterCatalogEntry {
1026 center: AnalysisCenter::Cod,
1027 code: "cod",
1028 protocol: ArchiveProtocol::Https,
1029 host: "www.aiub.unibe.ch",
1030 root_url: "https://www.aiub.unibe.ch/download",
1031 products: &COD_PRODUCTS,
1032 issues: &[],
1033 },
1034 CenterCatalogEntry {
1035 center: AnalysisCenter::Gfz,
1036 code: "gfz",
1037 protocol: ArchiveProtocol::Https,
1038 host: "isdc-data.gfz.de",
1039 root_url: "https://isdc-data.gfz.de/gnss/products",
1040 products: &GFZ_PRODUCTS,
1041 issues: &[],
1042 },
1043 CenterCatalogEntry {
1044 center: AnalysisCenter::IgsUlt,
1045 code: "igs_ult",
1046 protocol: ArchiveProtocol::Https,
1047 host: "igs.bkg.bund.de",
1048 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
1049 products: &IGS_ULT_PRODUCTS,
1050 issues: &OPSULT_ISSUES,
1051 },
1052 CenterCatalogEntry {
1053 center: AnalysisCenter::CodUlt,
1054 code: "cod_ult",
1055 protocol: ArchiveProtocol::Https,
1056 host: "www.aiub.unibe.ch",
1057 root_url: "https://www.aiub.unibe.ch/download",
1061 products: &COD_ULT_PRODUCTS,
1062 issues: &COD_ULT_ISSUES,
1063 },
1064 CenterCatalogEntry {
1065 center: AnalysisCenter::EsaUlt,
1066 code: "esa_ult",
1067 protocol: ArchiveProtocol::Https,
1068 host: "navigation-office.esa.int",
1069 root_url: "https://navigation-office.esa.int/products/gnss-products",
1070 products: &ESA_ULT_PRODUCTS,
1071 issues: &OPSULT_ISSUES,
1072 },
1073 CenterCatalogEntry {
1074 center: AnalysisCenter::GfzUlt,
1075 code: "gfz_ult",
1076 protocol: ArchiveProtocol::Https,
1077 host: "isdc-data.gfz.de",
1078 root_url: "https://isdc-data.gfz.de/gnss/products",
1079 products: &GFZ_ULT_PRODUCTS,
1080 issues: &GFZ_ULT_ISSUES,
1081 },
1082];
1083
1084const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
1085 protocol: ArchiveProtocol::Https,
1086 host: "s3.amazonaws.com",
1087 compression: ArchiveCompression::Gzip,
1088 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
1089};
1090
1091const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
1092 protocol: ArchiveProtocol::Https,
1093 host: "celestrak.org",
1094 compression: ArchiveCompression::None,
1095 root_url: "https://celestrak.org/SpaceData",
1096};
1097
1098const ALLOWED_HOSTS: [&str; 10] = [
1099 "www.aiub.unibe.ch",
1100 "download.aiub.unibe.ch",
1101 "zhw-b.s3.cloud.switch.ch",
1102 "navigation-office.esa.int",
1103 "isdc-data.gfz.de",
1104 "igs.bkg.bund.de",
1105 "s3.amazonaws.com",
1106 "celestrak.org",
1107 "cddis.nasa.gov",
1108 "urs.earthdata.nasa.gov",
1109];
1110
1111const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
1112 NoOpenMirrorProduct {
1113 center: "grg",
1114 product_type: "sp3",
1115 },
1116 NoOpenMirrorProduct {
1117 center: "grg",
1118 product_type: "clk",
1119 },
1120 NoOpenMirrorProduct {
1121 center: "wum",
1122 product_type: "sp3",
1123 },
1124 NoOpenMirrorProduct {
1125 center: "wum",
1126 product_type: "clk",
1127 },
1128 NoOpenMirrorProduct {
1129 center: "grg_ult",
1130 product_type: "sp3",
1131 },
1132 NoOpenMirrorProduct {
1133 center: "grg_ult",
1134 product_type: "clk",
1135 },
1136 NoOpenMirrorProduct {
1137 center: "igs",
1138 product_type: "ionex",
1139 },
1140];
1141
1142#[derive(Debug, Clone, PartialEq, Eq)]
1144pub enum DataCatalogError {
1145 UnknownCenter(String),
1147 UnknownProductType(String),
1149 UnsupportedProduct {
1151 center: AnalysisCenter,
1153 product_type: ProductType,
1155 },
1156 UnsupportedDistribution {
1158 source: DistributionSource,
1160 product_type: ProductType,
1162 },
1163 UnsupportedProductEra {
1165 center: AnalysisCenter,
1167 product_type: ProductType,
1169 date: ProductDate,
1171 },
1172 UnsupportedDistributionEra {
1174 source: DistributionSource,
1176 center: AnalysisCenter,
1178 product_type: ProductType,
1180 date: ProductDate,
1182 },
1183 NoDistributionSources,
1185 InvalidOfficialFilename(String),
1187 InconsistentProductIdentity {
1189 field: &'static str,
1191 },
1192 NoOpenMirror {
1194 center: String,
1196 product_type: String,
1198 },
1199 InvalidDate {
1201 year: i32,
1203 month: u8,
1205 day: u8,
1207 },
1208 DateOutOfRange,
1210 DateBeforeGpsEpoch(ProductDate),
1212 InvalidGpsDayOfWeek(u8),
1214 InvalidSample(String),
1216 UnsupportedSample {
1218 center: AnalysisCenter,
1220 product_type: ProductType,
1222 sample: String,
1224 },
1225 InvalidSpan(String),
1227 InvalidIssue(String),
1229 MissingIssue {
1231 center: AnalysisCenter,
1233 },
1234 UnexpectedIssue {
1236 center: AnalysisCenter,
1238 },
1239 UnsupportedIssue {
1241 center: AnalysisCenter,
1243 issue: String,
1245 },
1246 InvalidDateTime {
1248 hour: u8,
1250 minute: u8,
1252 second: u8,
1254 },
1255 NoUltraIssue,
1257 NoAvailableUltraIssue,
1259 InvalidStation(String),
1261 InvalidCoordinate {
1263 lat_deg_bits: u64,
1265 lon_deg_bits: u64,
1267 },
1268 InvalidTileIndex {
1270 lat_index: i32,
1272 lon_index: i32,
1274 },
1275 InvalidTileId(String),
1277}
1278
1279impl fmt::Display for DataCatalogError {
1280 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1281 match self {
1282 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1283 Self::UnknownProductType(product_type) => {
1284 write!(f, "unknown product type {product_type:?}")
1285 }
1286 Self::UnsupportedProduct {
1287 center,
1288 product_type,
1289 } => write!(f, "{center} does not serve {product_type}"),
1290 Self::UnsupportedDistribution {
1291 source,
1292 product_type,
1293 } => write!(
1294 f,
1295 "distributor {} does not serve {product_type}",
1296 source.code()
1297 ),
1298 Self::UnsupportedProductEra {
1299 center,
1300 product_type,
1301 date,
1302 } => write!(
1303 f,
1304 "{center}/{product_type} has no cataloged naming convention for {date}"
1305 ),
1306 Self::UnsupportedDistributionEra {
1307 source,
1308 center,
1309 product_type,
1310 date,
1311 } => write!(
1312 f,
1313 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1314 source.code()
1315 ),
1316 Self::NoDistributionSources => {
1317 write!(f, "exact product request has no distributors")
1318 }
1319 Self::InvalidOfficialFilename(filename) => {
1320 write!(f, "invalid official product filename {filename:?}")
1321 }
1322 Self::InconsistentProductIdentity { field } => {
1323 write!(
1324 f,
1325 "product identity field {field:?} disagrees with its official filename"
1326 )
1327 }
1328 Self::NoOpenMirror {
1329 center,
1330 product_type,
1331 } => write!(f, "{center}/{product_type} has no open mirror"),
1332 Self::InvalidDate { year, month, day } => {
1333 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1334 }
1335 Self::DateOutOfRange => write!(f, "product date is out of range"),
1336 Self::DateBeforeGpsEpoch(date) => {
1337 write!(f, "product date {date} is before the GPS week epoch")
1338 }
1339 Self::InvalidGpsDayOfWeek(day) => {
1340 write!(f, "invalid GPS day-of-week {day}")
1341 }
1342 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1343 Self::UnsupportedSample {
1344 center,
1345 product_type,
1346 sample,
1347 } => write!(
1348 f,
1349 "{center}/{product_type} does not publish sample interval {sample:?}"
1350 ),
1351 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1352 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1353 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1354 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1355 Self::UnsupportedIssue { center, issue } => {
1356 write!(f, "{center} does not publish issue {issue:?}")
1357 }
1358 Self::InvalidDateTime {
1359 hour,
1360 minute,
1361 second,
1362 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1363 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1364 Self::NoAvailableUltraIssue => {
1365 write!(f, "no available ultra-rapid issue at or before target")
1366 }
1367 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1368 Self::InvalidCoordinate {
1369 lat_deg_bits,
1370 lon_deg_bits,
1371 } => write!(
1372 f,
1373 "invalid terrain coordinate lat={} lon={}",
1374 f64::from_bits(*lat_deg_bits),
1375 f64::from_bits(*lon_deg_bits)
1376 ),
1377 Self::InvalidTileIndex {
1378 lat_index,
1379 lon_index,
1380 } => write!(
1381 f,
1382 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1383 ),
1384 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1385 }
1386 }
1387}
1388
1389impl std::error::Error for DataCatalogError {}
1390
1391#[derive(Debug, Clone, PartialEq, Eq)]
1393pub enum HgtConversionError {
1394 BadLength {
1396 expected: usize,
1398 got: usize,
1400 },
1401 InvalidTileIndex {
1403 lat_index: i32,
1405 lon_index: i32,
1407 },
1408}
1409
1410impl fmt::Display for HgtConversionError {
1411 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1412 match self {
1413 Self::BadLength { expected, got } => {
1414 write!(
1415 f,
1416 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1417 )
1418 }
1419 Self::InvalidTileIndex {
1420 lat_index,
1421 lon_index,
1422 } => write!(
1423 f,
1424 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1425 ),
1426 }
1427 }
1428}
1429
1430impl std::error::Error for HgtConversionError {}
1431
1432const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1433const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1434const MIN_TERRAIN_LON_INDEX: i32 = -180;
1435const MAX_TERRAIN_LON_INDEX: i32 = 179;
1436const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1437const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1438const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1439const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1440const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1441const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1442const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1443const DTED_SRTM1_LEN: usize =
1444 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1445
1446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1448pub struct ProductDate {
1449 pub year: i32,
1451 pub month: u8,
1453 pub day: u8,
1455}
1456
1457impl ProductDate {
1458 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1460 let days = days_in_month(i64::from(year), i64::from(month));
1461 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1462 return Err(DataCatalogError::InvalidDate { year, month, day });
1463 }
1464 Ok(Self { year, month, day })
1465 }
1466
1467 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1469 if day_of_week > 6 {
1470 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1471 }
1472 let epoch_jdn =
1473 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1474 let offset_days = i64::from(week)
1475 .checked_mul(7)
1476 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1477 .ok_or(DataCatalogError::DateOutOfRange)?;
1478 product_date_from_jdn(
1479 epoch_jdn
1480 .checked_add(offset_days)
1481 .ok_or(DataCatalogError::DateOutOfRange)?,
1482 )
1483 }
1484
1485 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1487 week_from_calendar(
1488 TimeScale::Gpst,
1489 i64::from(self.year),
1490 i64::from(self.month),
1491 i64::from(self.day),
1492 )
1493 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1494 }
1495
1496 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1498 let epoch_jdn =
1499 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1500 let days = self
1501 .julian_day_number()
1502 .checked_sub(epoch_jdn)
1503 .ok_or(DataCatalogError::DateOutOfRange)?;
1504 if days < 0 {
1505 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1506 }
1507 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1508 }
1509
1510 #[must_use]
1512 pub fn day_of_year(self) -> u16 {
1513 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1514 }
1515
1516 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1517 product_date_from_jdn(
1518 self.julian_day_number()
1519 .checked_add(days)
1520 .ok_or(DataCatalogError::DateOutOfRange)?,
1521 )
1522 }
1523
1524 fn julian_day_number(self) -> i64 {
1525 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1526 }
1527}
1528
1529impl fmt::Display for ProductDate {
1530 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1531 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1532 }
1533}
1534
1535#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1537pub struct ProductDateTime {
1538 pub date: ProductDate,
1540 pub hour: u8,
1542 pub minute: u8,
1544 pub second: u8,
1546}
1547
1548impl ProductDateTime {
1549 pub fn new(
1551 date: ProductDate,
1552 hour: u8,
1553 minute: u8,
1554 second: u8,
1555 ) -> Result<Self, DataCatalogError> {
1556 if hour > 23 || minute > 59 || second > 59 {
1557 return Err(DataCatalogError::InvalidDateTime {
1558 hour,
1559 minute,
1560 second,
1561 });
1562 }
1563 Ok(Self {
1564 date,
1565 hour,
1566 minute,
1567 second,
1568 })
1569 }
1570
1571 fn ordering_minutes(self) -> i64 {
1572 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1573 }
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1578pub struct UltraIssue {
1579 pub date: ProductDate,
1581 pub issue: String,
1583}
1584
1585impl UltraIssue {
1586 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1588 validate_issue(issue)?;
1589 Ok(Self {
1590 date,
1591 issue: issue.to_string(),
1592 })
1593 }
1594}
1595
1596#[derive(Debug, Clone, PartialEq, Eq)]
1598pub struct UltraSp3Location {
1599 pub pattern: String,
1601 pub span: String,
1603 pub sample: String,
1605 pub filename: String,
1607 pub url: String,
1609 pub compression: ArchiveCompression,
1611}
1612
1613#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1619pub struct ProductIdentity {
1620 pub family: ProductType,
1622 pub analysis_center: AnalysisCenter,
1624 pub publisher: ProductPublisher,
1626 pub solution: SolutionClass,
1628 pub campaign: ProductCampaign,
1630 pub version: u8,
1632 pub date: ProductDate,
1638 pub issue: Option<String>,
1640 pub span: String,
1642 pub sample: String,
1644 pub official_filename: String,
1646 pub format: ProductFormat,
1648 pub format_version: Option<String>,
1654 pub prediction_horizon_days: Option<u8>,
1656}
1657
1658impl ProductIdentity {
1659 pub fn validate(&self) -> Result<(), DataCatalogError> {
1665 validate_official_filename(&self.official_filename)?;
1666 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1667 validate_sample(&self.sample)?;
1668 validate_span(&self.span)?;
1669 if let Some(issue) = self.issue.as_deref() {
1670 validate_issue(issue)?;
1671 }
1672
1673 let convention = product_convention(self.analysis_center, self.family)?;
1677 validate_product_date(self.analysis_center, self.family, self.date)?;
1678 if self.span != convention.span {
1679 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1680 }
1681 validate_catalog_sample(
1682 self.analysis_center,
1683 self.family,
1684 self.date,
1685 &self.sample,
1686 self.issue.as_deref(),
1687 )?;
1688
1689 if self.format != product_format(self.family) {
1690 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1691 }
1692
1693 if self
1694 .format_version
1695 .as_deref()
1696 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1697 {
1698 return Err(DataCatalogError::InconsistentProductIdentity {
1699 field: "format_version",
1700 });
1701 }
1702
1703 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1704 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1705 (_, SolutionClass::Predicted, _) => false,
1706 (_, _, None) => true,
1707 (_, _, Some(_)) => false,
1708 };
1709 if !horizon_valid {
1710 return Err(DataCatalogError::InconsistentProductIdentity {
1711 field: "prediction_horizon_days",
1712 });
1713 }
1714 let descriptor = product_type_convention(self.family);
1715 let legacy_igs_final =
1716 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1717 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1718 let entry = center_catalog(self.analysis_center)
1719 .expect("validated analysis center has a catalog entry");
1720 let issue_valid = if entry.issues.is_empty() {
1721 self.issue.as_deref() == Some("0000")
1722 } else {
1723 self.issue
1724 .as_deref()
1725 .is_some_and(|issue| entry.issues.contains(&issue))
1726 };
1727 if !issue_valid {
1728 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1729 }
1730 }
1731 let expected = if legacy_igs_final {
1732 let fields_valid = self.publisher == ProductPublisher::Igs
1733 && self.solution == SolutionClass::Final
1734 && self.campaign == ProductCampaign::Operational
1735 && self.version == 0
1736 && self.issue.as_deref() == Some("0000")
1737 && self.span == convention.span
1738 && self.sample == convention.default_sample;
1739 if !fields_valid {
1740 return Err(DataCatalogError::InconsistentProductIdentity {
1741 field: "legacy_igs_final",
1742 });
1743 }
1744 format!(
1745 "igs{:04}{}.sp3",
1746 self.date.gps_week()?,
1747 self.date.gps_day_of_week()?
1748 )
1749 } else {
1750 match descriptor.kind {
1751 ProductFilenameKind::Sampled => {
1752 let solution_token = self.solution.filename_token().ok_or(
1753 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1754 )?;
1755 format!(
1756 "{}{}{}{}_{}_{}_{}_{}.{}",
1757 self.publisher.code(),
1758 self.version,
1759 self.campaign.code(),
1760 solution_token,
1761 date_block(self.date, self.issue.as_deref()),
1762 self.span,
1763 self.sample,
1764 descriptor.content_code,
1765 descriptor.extension
1766 )
1767 }
1768 ProductFilenameKind::Nav => {
1769 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1770 && self.solution == SolutionClass::Broadcast
1771 && self.campaign == ProductCampaign::Broadcast
1772 && self.version == 0
1773 && self.issue.is_none()
1774 && self.span == "01D"
1775 && self.sample == "01D";
1776 if !nav_fields_valid {
1777 return Err(DataCatalogError::InconsistentProductIdentity {
1778 field: "broadcast_navigation",
1779 });
1780 }
1781 format!(
1782 "BRDC00WRD_R_{}_{}_{}.{}",
1783 date_block(self.date, None),
1784 self.span,
1785 descriptor.content_code,
1786 descriptor.extension
1787 )
1788 }
1789 }
1790 };
1791 if expected != self.official_filename {
1792 return Err(DataCatalogError::InconsistentProductIdentity {
1793 field: "official_filename",
1794 });
1795 }
1796 if self.publisher != self.analysis_center.publisher()
1797 || self.solution != product_solution_class(self.analysis_center, self.family)?
1798 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1799 {
1800 return Err(DataCatalogError::InconsistentProductIdentity {
1801 field: "analysis_center",
1802 });
1803 }
1804
1805 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1806 let expected_catalog_filename = format!(
1807 "{}_{}_{}_{}_{}.{}",
1808 convention.token,
1809 date_block(self.date, self.issue.as_deref()),
1810 self.span,
1811 self.sample,
1812 descriptor.content_code,
1813 descriptor.extension
1814 );
1815 if expected_catalog_filename != self.official_filename {
1816 return Err(DataCatalogError::InconsistentProductIdentity {
1817 field: "analysis_center",
1818 });
1819 }
1820 }
1821 Ok(())
1822 }
1823
1824 pub fn key(&self) -> Result<String, DataCatalogError> {
1826 use sha2::{Digest, Sha256};
1827
1828 let canonical = self.canonical_bytes()?;
1829 let digest = Sha256::digest(canonical);
1830 Ok(format!(
1831 "{}-{}-{}",
1832 self.publisher.code().to_ascii_lowercase(),
1833 self.solution.code(),
1834 digest[..10]
1835 .iter()
1836 .map(|byte| format!("{byte:02x}"))
1837 .collect::<String>()
1838 ))
1839 }
1840
1841 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1847 self.validate()?;
1848 let date = format!(
1849 "{:04}-{:02}-{:02}",
1850 self.date.year, self.date.month, self.date.day
1851 );
1852 let version = self.version.to_string();
1853 let prediction = self
1854 .prediction_horizon_days
1855 .map(|days| days.to_string())
1856 .unwrap_or_default();
1857 let fields = [
1858 self.family.code(),
1859 self.analysis_center.code(),
1860 self.publisher.code(),
1861 self.solution.code(),
1862 self.campaign.code(),
1863 version.as_str(),
1864 date.as_str(),
1865 self.issue.as_deref().unwrap_or_default(),
1866 self.span.as_str(),
1867 self.sample.as_str(),
1868 self.official_filename.as_str(),
1869 self.format.code(),
1870 self.format_version.as_deref().unwrap_or_default(),
1871 prediction.as_str(),
1872 ];
1873 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1874 return Err(DataCatalogError::InconsistentProductIdentity {
1875 field: "canonical_encoding",
1876 });
1877 }
1878 Ok(fields.join("\0").into_bytes())
1879 }
1880
1881 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1883 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1884 }
1885}
1886
1887pub(crate) fn exact_sp3_content_start_offset_s(
1893 identity: &ProductIdentity,
1894) -> Result<i64, DataCatalogError> {
1895 identity.validate()?;
1896 if identity.family != ProductType::Sp3 {
1897 return Err(DataCatalogError::InconsistentProductIdentity { field: "family" });
1898 }
1899
1900 let entry =
1901 center_catalog(identity.analysis_center).expect("a validated identity has a catalog entry");
1902 let catalog_issue = if entry.issues.is_empty() {
1906 None
1907 } else {
1908 identity.issue.as_deref()
1909 };
1910 Ok(
1911 sp3_content_start_convention(identity.analysis_center, identity.date, catalog_issue)?
1912 .content_start_offset_s(),
1913 )
1914}
1915
1916pub fn sp3_content_start_convention(
1924 center: AnalysisCenter,
1925 date: ProductDate,
1926 issue: Option<&str>,
1927) -> Result<Sp3ContentStartConvention, DataCatalogError> {
1928 ProductDate::new(date.year, date.month, date.day)?;
1929 product_convention(center, ProductType::Sp3)?;
1930 validate_product_date(center, ProductType::Sp3, date)?;
1931 validate_issue_for_center(center, issue)?;
1932
1933 sp3_content_start_convention_inner(center, date, issue).ok_or_else(|| {
1934 DataCatalogError::UnsupportedIssue {
1935 center,
1936 issue: issue.unwrap_or_default().to_owned(),
1937 }
1938 })
1939}
1940
1941fn sp3_content_start_convention_inner(
1942 center: AnalysisCenter,
1943 date: ProductDate,
1944 issue: Option<&str>,
1945) -> Option<Sp3ContentStartConvention> {
1946 if center != AnalysisCenter::GfzUlt {
1947 return Some(Sp3ContentStartConvention::FilenameEpoch);
1948 }
1949 if date < GFZ_ULTRA_START_TRANSITION_FIRST_DATE {
1950 return Some(Sp3ContentStartConvention::FilenameEpochMinusOneDay);
1951 }
1952 if date > GFZ_ULTRA_START_TRANSITION_LAST_DATE {
1953 return Some(Sp3ContentStartConvention::FilenameEpoch);
1954 }
1955
1956 let issue = issue?;
1957 GFZ_ULTRA_START_TRANSITION
1958 .iter()
1959 .find(|(entry_date, entry_issue, _)| *entry_date == date && *entry_issue == issue)
1960 .map(|(_, _, convention)| *convention)
1961}
1962
1963#[derive(Debug, Clone, PartialEq, Eq)]
1965pub struct DistributionLocation {
1966 pub source: DistributionSource,
1968 pub original_url: Option<String>,
1970 pub archive_filename: String,
1972 pub compression: ArchiveCompression,
1974}
1975
1976#[derive(Debug, Clone, PartialEq, Eq)]
1978pub struct ProductRequest {
1979 pub identity: ProductIdentity,
1981 pub distributors: Vec<DistributionSource>,
1983}
1984
1985#[derive(Debug, Clone, PartialEq, Eq)]
1987pub enum ExactProductSetError {
1988 EmptyExpected,
1990 InvalidExpected {
1992 index: usize,
1994 source: DataCatalogError,
1996 },
1997 InvalidAvailable {
1999 index: usize,
2001 source: DataCatalogError,
2003 },
2004 Mismatch {
2006 missing: Vec<ProductIdentity>,
2008 unexpected: Vec<ProductIdentity>,
2010 duplicate_expected: Vec<ProductIdentity>,
2012 duplicate_available: Vec<ProductIdentity>,
2014 },
2015}
2016
2017impl fmt::Display for ExactProductSetError {
2018 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2019 match self {
2020 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
2021 Self::InvalidExpected { index, source } => {
2022 write!(f, "expected product {index} is invalid: {source}")
2023 }
2024 Self::InvalidAvailable { index, source } => {
2025 write!(f, "available product {index} is invalid: {source}")
2026 }
2027 Self::Mismatch {
2028 missing,
2029 unexpected,
2030 duplicate_expected,
2031 duplicate_available,
2032 } => write!(
2033 f,
2034 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
2035 identity_list(missing),
2036 identity_list(unexpected),
2037 identity_list(duplicate_expected),
2038 identity_list(duplicate_available),
2039 ),
2040 }
2041 }
2042}
2043
2044impl std::error::Error for ExactProductSetError {}
2045
2046pub fn validate_exact_product_set(
2060 expected: &[ProductIdentity],
2061 available: &[ProductIdentity],
2062) -> Result<(), ExactProductSetError> {
2063 if expected.is_empty() {
2064 return Err(ExactProductSetError::EmptyExpected);
2065 }
2066 for (index, identity) in expected.iter().enumerate() {
2067 identity
2068 .validate()
2069 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
2070 }
2071 for (index, identity) in available.iter().enumerate() {
2072 identity
2073 .validate()
2074 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
2075 }
2076
2077 let expected_counts = identity_counts(expected);
2078 let available_counts = identity_counts(available);
2079 let missing = unique_matching(expected, |identity| {
2080 !available_counts.contains_key(identity)
2081 });
2082 let unexpected = unique_matching(available, |identity| {
2083 !expected_counts.contains_key(identity)
2084 });
2085 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
2086 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
2087
2088 if missing.is_empty()
2089 && unexpected.is_empty()
2090 && duplicate_expected.is_empty()
2091 && duplicate_available.is_empty()
2092 {
2093 Ok(())
2094 } else {
2095 Err(ExactProductSetError::Mismatch {
2096 missing,
2097 unexpected,
2098 duplicate_expected,
2099 duplicate_available,
2100 })
2101 }
2102}
2103
2104fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
2105 let mut counts = HashMap::with_capacity(identities.len());
2106 for identity in identities {
2107 *counts.entry(identity).or_insert(0) += 1;
2108 }
2109 counts
2110}
2111
2112fn unique_matching(
2113 identities: &[ProductIdentity],
2114 mut predicate: impl FnMut(&ProductIdentity) -> bool,
2115) -> Vec<ProductIdentity> {
2116 let mut seen = HashSet::with_capacity(identities.len());
2117 identities
2118 .iter()
2119 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
2120 .cloned()
2121 .collect()
2122}
2123
2124fn identity_list(identities: &[ProductIdentity]) -> String {
2125 if identities.is_empty() {
2126 return "none".to_string();
2127 }
2128 identities
2129 .iter()
2130 .map(|identity| {
2131 identity
2132 .key()
2133 .unwrap_or_else(|_| identity.official_filename.clone())
2134 })
2135 .collect::<Vec<_>>()
2136 .join(", ")
2137}
2138
2139impl ProductRequest {
2140 pub fn new(
2142 identity: ProductIdentity,
2143 distributors: Vec<DistributionSource>,
2144 ) -> Result<Self, DataCatalogError> {
2145 if distributors.is_empty() {
2146 return Err(DataCatalogError::NoDistributionSources);
2147 }
2148 identity.validate()?;
2149 Ok(Self {
2150 identity,
2151 distributors,
2152 })
2153 }
2154}
2155
2156#[derive(Debug, Clone, PartialEq, Eq)]
2158pub struct ProductSpec {
2159 pub center: AnalysisCenter,
2161 pub product_type: ProductType,
2163 pub date: ProductDate,
2165 pub sample: String,
2167 pub issue: Option<String>,
2169}
2170
2171impl ProductSpec {
2172 pub fn new(
2174 center: AnalysisCenter,
2175 product_type: ProductType,
2176 date: ProductDate,
2177 sample: &str,
2178 issue: Option<&str>,
2179 ) -> Result<Self, DataCatalogError> {
2180 ProductDate::new(date.year, date.month, date.day)?;
2181 validate_product(center, product_type, date, sample, issue)?;
2182 Ok(Self {
2183 center,
2184 product_type,
2185 date,
2186 sample: sample.to_string(),
2187 issue: issue.map(ToOwned::to_owned),
2188 })
2189 }
2190
2191 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2193 self.date.gps_week()
2194 }
2195
2196 #[must_use]
2198 pub fn day_of_year(&self) -> u16 {
2199 self.date.day_of_year()
2200 }
2201
2202 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2208 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2209 let convention = validate_product(
2210 self.center,
2211 self.product_type,
2212 self.date,
2213 &self.sample,
2214 self.issue.as_deref(),
2215 )?;
2216 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2217 return Ok(format!(
2218 "igs{:04}{}.sp3",
2219 self.date.gps_week()?,
2220 self.date.gps_day_of_week()?
2221 ));
2222 }
2223 let descriptor = product_type_convention(self.product_type);
2224 Ok(match descriptor.kind {
2225 ProductFilenameKind::Sampled => format!(
2226 "{}_{}_{}_{}_{}.{}",
2227 convention.token,
2228 date_block(self.date, self.issue.as_deref()),
2229 convention.span,
2230 self.sample,
2231 descriptor.content_code,
2232 descriptor.extension
2233 ),
2234 ProductFilenameKind::Nav => format!(
2235 "{}_R_{}_{}_{}.{}",
2236 convention.token,
2237 date_block(self.date, None),
2238 convention.span,
2239 descriptor.content_code,
2240 descriptor.extension
2241 ),
2242 })
2243 }
2244
2245 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2247 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2248 let convention = validate_product(
2249 self.center,
2250 self.product_type,
2251 self.date,
2252 &self.sample,
2253 self.issue.as_deref(),
2254 )?;
2255 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2256 return Err(DataCatalogError::UnsupportedDistributionEra {
2257 source: DistributionSource::Direct,
2258 center: self.center,
2259 product_type: self.product_type,
2260 date: self.date,
2261 });
2262 }
2263 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2264 let filename = self.canonical_filename()?;
2265 let compression = product_archive_compression(
2266 self.center,
2267 self.product_type,
2268 self.date,
2269 convention.compression,
2270 )?;
2271 Ok(format!(
2272 "{}/{}/{}{}",
2273 entry.root_url,
2274 product_dir_path(self.center, convention.layout, self.date)?,
2275 filename,
2276 compression.suffix()
2277 ))
2278 }
2279
2280 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2282 let convention = validate_product(
2283 self.center,
2284 self.product_type,
2285 self.date,
2286 &self.sample,
2287 self.issue.as_deref(),
2288 )?;
2289 let descriptor = product_type_convention(self.product_type);
2290 let campaign = match descriptor.kind {
2291 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2292 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2293 Some("OPS") => ProductCampaign::Operational,
2294 Some("MGN") => ProductCampaign::MultiGnss,
2295 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2296 _ => {
2297 return Err(DataCatalogError::InconsistentProductIdentity {
2298 field: "campaign",
2299 });
2300 }
2301 },
2302 };
2303 let identity = ProductIdentity {
2304 family: self.product_type,
2305 analysis_center: self.center,
2306 publisher: self.center.publisher(),
2307 solution: product_solution_class(self.center, self.product_type)?,
2308 campaign,
2309 version: 0,
2310 date: self.date,
2311 issue: match descriptor.kind {
2312 ProductFilenameKind::Sampled => {
2313 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2314 }
2315 ProductFilenameKind::Nav => None,
2316 },
2317 span: convention.span.to_string(),
2318 sample: self.sample.clone(),
2319 official_filename: self.canonical_filename()?,
2320 format: product_format(self.product_type),
2321 format_version: None,
2322 prediction_horizon_days: self.center.prediction_horizon_days(),
2323 };
2324 identity.validate()?;
2325 Ok(identity)
2326 }
2327
2328 pub fn distribution_location(
2330 &self,
2331 source: DistributionSource,
2332 ) -> Result<DistributionLocation, DataCatalogError> {
2333 let identity = self.identity()?;
2334 distribution_location_for_identity(&identity, source)
2335 }
2336}
2337
2338#[derive(Debug, Clone, PartialEq, Eq)]
2340pub struct StationObservationSpec {
2341 pub station: String,
2343 pub date: ProductDate,
2345 pub sample: String,
2347}
2348
2349impl StationObservationSpec {
2350 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2352 validate_station(station)?;
2353 validate_sample(sample)?;
2354 Ok(Self {
2355 station: station.to_string(),
2356 date,
2357 sample: sample.to_string(),
2358 })
2359 }
2360
2361 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2363 station_obs_filename(&self.station, self.date, &self.sample)
2364 }
2365
2366 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2368 station_obs_url(&self.station, self.date, &self.sample)
2369 }
2370}
2371
2372#[must_use]
2374pub const fn catalog() -> &'static [CenterCatalogEntry] {
2375 &CATALOG
2376}
2377
2378#[must_use]
2380pub const fn centers() -> &'static [AnalysisCenter] {
2381 &CENTER_ORDER
2382}
2383
2384#[must_use]
2386pub const fn product_types() -> &'static [ProductTypeConvention] {
2387 &PRODUCT_TYPE_CONVENTIONS
2388}
2389
2390#[must_use]
2392pub const fn allowed_hosts() -> &'static [&'static str] {
2393 &ALLOWED_HOSTS
2394}
2395
2396#[must_use]
2398pub const fn skadi_source_entry() -> TerrainSourceEntry {
2399 SKADI_SOURCE
2400}
2401
2402#[must_use]
2404pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2405 CELESTRAK_SPACE_WEATHER_SOURCE
2406}
2407
2408#[must_use]
2410pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2411 match product {
2412 SpaceWeatherProduct::All => "SW-All.csv",
2413 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2414 }
2415}
2416
2417#[must_use]
2419pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2420 format!(
2421 "{}/{}",
2422 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2423 space_weather_filename(product)
2424 )
2425}
2426
2427#[must_use]
2429pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2430 format!("space-weather/{}", space_weather_filename(product))
2431}
2432
2433pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2435 validate_terrain_tile_index(lat_index, lon_index)?;
2436 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2437 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2438 Ok(format!(
2439 "{lat_hemi}{:02}{lon_hemi}{:03}",
2440 lat_index.abs(),
2441 lon_index.abs()
2442 ))
2443}
2444
2445pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2447 validate_terrain_lat_index(lat_index)?;
2448 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2449 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2450}
2451
2452pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2454 let band = skadi_band(lat_index)?;
2455 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2456 Ok(format!(
2457 "{}/skadi/{}/{}.hgt{}",
2458 SKADI_SOURCE.root_url,
2459 band,
2460 tile_id,
2461 SKADI_SOURCE.compression.suffix()
2462 ))
2463}
2464
2465pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2467 validate_terrain_tile_index(lat_index, lon_index)?;
2468 Ok(format!(
2469 "{}_{}{}",
2470 terrain::format_lat(lat_index),
2471 terrain::format_lon(lon_index),
2472 terrain::DTED_SUFFIX
2473 ))
2474}
2475
2476pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2478 validate_terrain_tile_index(lat_index, lon_index)?;
2479 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2480}
2481
2482pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2484 Ok(format!(
2485 "{}/{}",
2486 dted_block_dir(lat_index, lon_index)?,
2487 dted_tile_filename(lat_index, lon_index)?
2488 ))
2489}
2490
2491pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2493 let bytes = id.as_bytes();
2494 if bytes.len() != 7
2495 || !matches!(bytes[0], b'N' | b'S')
2496 || !matches!(bytes[3], b'E' | b'W')
2497 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2498 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2499 {
2500 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2501 }
2502
2503 let lat_abs = id[1..3]
2504 .parse::<i32>()
2505 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2506 let lon_abs = id[4..7]
2507 .parse::<i32>()
2508 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2509 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2510 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2511 }
2512
2513 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2514 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2515 validate_terrain_tile_index(lat_index, lon_index)?;
2516 Ok((lat_index, lon_index))
2517}
2518
2519pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2521 if !lat_deg.is_finite()
2522 || !lon_deg.is_finite()
2523 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2524 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2525 {
2526 return Err(DataCatalogError::InvalidCoordinate {
2527 lat_deg_bits: lat_deg.to_bits(),
2528 lon_deg_bits: lon_deg.to_bits(),
2529 });
2530 }
2531
2532 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2533 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2534 lat_index = MAX_TERRAIN_LAT_INDEX;
2535 }
2536 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2537 lon_index = MAX_TERRAIN_LON_INDEX;
2538 }
2539 validate_terrain_tile_index(lat_index, lon_index)?;
2540 Ok((lat_index, lon_index))
2541}
2542
2543pub fn hgt_to_dted(
2551 lat_index: i32,
2552 lon_index: i32,
2553 hgt: &[u8],
2554) -> Result<Vec<u8>, HgtConversionError> {
2555 validate_hgt_tile_index(lat_index, lon_index)?;
2556 if hgt.len() != SRTM1_HGT_LEN {
2557 return Err(HgtConversionError::BadLength {
2558 expected: SRTM1_HGT_LEN,
2559 got: hgt.len(),
2560 });
2561 }
2562
2563 let mut out = vec![b' '; DTED_SRTM1_LEN];
2564 out[0..4].copy_from_slice(b"UHL1");
2565 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2566 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2567 out[47..51].copy_from_slice(b"3601");
2568 out[51..55].copy_from_slice(b"3601");
2569
2570 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2571 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2572 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2573 out[block_start] = terrain::DATA_SENTINEL;
2574
2575 let count = (lon_posting as u32).to_be_bytes();
2576 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2577 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2578 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2579
2580 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2581 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2582 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2583 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2584 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2585 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2586 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2587 }
2588
2589 let checksum = out[block_start..checksum_start]
2590 .iter()
2591 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2592 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2593 }
2594
2595 debug_assert_eq!(out.len(), 25_981_042);
2596 Ok(out)
2597}
2598
2599#[must_use]
2601pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2602 &NO_OPEN_MIRRORS
2603}
2604
2605pub fn open_mirror(
2607 center: AnalysisCenter,
2608 product_type: ProductType,
2609) -> Result<(), DataCatalogError> {
2610 open_mirror_code(center.code(), product_type.code())
2611}
2612
2613pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2615 if NO_OPEN_MIRRORS
2616 .iter()
2617 .any(|entry| entry.center == center && entry.product_type == product_type)
2618 {
2619 Err(DataCatalogError::NoOpenMirror {
2620 center: center.to_string(),
2621 product_type: product_type.to_string(),
2622 })
2623 } else {
2624 Ok(())
2625 }
2626}
2627
2628#[must_use]
2630pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2631 CATALOG.iter().find(|entry| entry.center == center)
2632}
2633
2634pub fn product_convention(
2636 center: AnalysisCenter,
2637 product_type: ProductType,
2638) -> Result<&'static CenterProductConvention, DataCatalogError> {
2639 open_mirror(center, product_type)?;
2640 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2641 entry
2642 .products
2643 .iter()
2644 .find(|product| product.product_type == product_type)
2645 .ok_or(DataCatalogError::UnsupportedProduct {
2646 center,
2647 product_type,
2648 })
2649}
2650
2651pub fn product_solution_class(
2659 center: AnalysisCenter,
2660 product_type: ProductType,
2661) -> Result<SolutionClass, DataCatalogError> {
2662 product_convention(center, product_type)?;
2663 Ok(match (center, product_type) {
2664 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2665 _ => center.solution_class(),
2666 })
2667}
2668
2669pub fn default_sample(
2675 center: AnalysisCenter,
2676 product_type: ProductType,
2677) -> Result<&'static str, DataCatalogError> {
2678 Ok(product_convention(center, product_type)?.default_sample)
2679}
2680
2681pub fn default_sample_for_date(
2689 center: AnalysisCenter,
2690 product_type: ProductType,
2691 date: ProductDate,
2692) -> Result<&'static str, DataCatalogError> {
2693 default_sample_for_product_issue(center, product_type, date, None)
2694}
2695
2696pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2698 date.gps_week()
2699}
2700
2701#[must_use]
2703pub fn day_of_year(date: ProductDate) -> u16 {
2704 date.day_of_year()
2705}
2706
2707pub fn product(
2709 center: AnalysisCenter,
2710 product_type: ProductType,
2711 date: ProductDate,
2712 sample: Option<&str>,
2713 issue: Option<&str>,
2714) -> Result<ProductSpec, DataCatalogError> {
2715 let sample = match sample {
2716 Some(sample) => sample,
2717 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2718 };
2719 ProductSpec::new(center, product_type, date, sample, issue)
2720}
2721
2722pub fn canonical_filename(
2724 center: AnalysisCenter,
2725 product_type: ProductType,
2726 date: ProductDate,
2727 sample: Option<&str>,
2728 issue: Option<&str>,
2729) -> Result<String, DataCatalogError> {
2730 product(center, product_type, date, sample, issue)?.canonical_filename()
2731}
2732
2733pub fn archive_url(
2735 center: AnalysisCenter,
2736 product_type: ProductType,
2737 date: ProductDate,
2738 sample: Option<&str>,
2739 issue: Option<&str>,
2740) -> Result<String, DataCatalogError> {
2741 product(center, product_type, date, sample, issue)?.archive_url()
2742}
2743
2744pub fn product_identity(
2746 center: AnalysisCenter,
2747 product_type: ProductType,
2748 date: ProductDate,
2749 sample: Option<&str>,
2750 issue: Option<&str>,
2751) -> Result<ProductIdentity, DataCatalogError> {
2752 product(center, product_type, date, sample, issue)?.identity()
2753}
2754
2755pub fn distribution_location(
2757 center: AnalysisCenter,
2758 product_type: ProductType,
2759 date: ProductDate,
2760 sample: Option<&str>,
2761 issue: Option<&str>,
2762 source: DistributionSource,
2763) -> Result<DistributionLocation, DataCatalogError> {
2764 product(center, product_type, date, sample, issue)?.distribution_location(source)
2765}
2766
2767pub fn distribution_location_for_identity(
2774 identity: &ProductIdentity,
2775 source: DistributionSource,
2776) -> Result<DistributionLocation, DataCatalogError> {
2777 identity.validate()?;
2778 match source {
2779 DistributionSource::Direct => {
2780 let convention = product_convention(identity.analysis_center, identity.family)?;
2781 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2782 {
2783 return Err(DataCatalogError::UnsupportedDistributionEra {
2784 source,
2785 center: identity.analysis_center,
2786 product_type: identity.family,
2787 date: identity.date,
2788 });
2789 }
2790 let entry = center_catalog(identity.analysis_center)
2791 .expect("validated analysis center has a catalog entry");
2792 let compression = product_archive_compression(
2793 identity.analysis_center,
2794 identity.family,
2795 identity.date,
2796 convention.compression,
2797 )?;
2798 let url = format!(
2799 "{}/{}/{}{}",
2800 entry.root_url,
2801 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2802 identity.official_filename,
2803 compression.suffix()
2804 );
2805 Ok(DistributionLocation {
2806 source,
2807 original_url: Some(url),
2808 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2809 compression,
2810 })
2811 }
2812 DistributionSource::NasaCddis => {
2813 validate_cddis_distribution_era(identity)?;
2814 let compression = product_archive_compression(
2815 identity.analysis_center,
2816 identity.family,
2817 identity.date,
2818 ArchiveCompression::Gzip,
2819 )?;
2820 Ok(DistributionLocation {
2821 source,
2822 original_url: Some(cddis_archive_url(identity)?),
2823 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2824 compression,
2825 })
2826 }
2827 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2828 source,
2829 original_url: None,
2830 archive_filename: identity.official_filename.clone(),
2831 compression: ArchiveCompression::None,
2832 }),
2833 }
2834}
2835
2836pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2845 identity.validate()?;
2846 validate_cddis_distribution_era(identity)?;
2847 match identity.family {
2848 ProductType::Sp3 => {
2849 let compression = product_archive_compression(
2850 identity.analysis_center,
2851 identity.family,
2852 identity.date,
2853 ArchiveCompression::Gzip,
2854 )?;
2855 Ok(format!(
2856 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2857 identity.date.gps_week()?,
2858 identity.official_filename,
2859 compression.suffix()
2860 ))
2861 }
2862 ProductType::Ionex => Ok(format!(
2863 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2864 identity.date.year,
2865 identity.date.day_of_year(),
2866 identity.official_filename
2867 )),
2868 product_type => Err(DataCatalogError::UnsupportedDistribution {
2869 source: DistributionSource::NasaCddis,
2870 product_type,
2871 }),
2872 }
2873}
2874
2875pub fn mgex_clk(
2877 center: AnalysisCenter,
2878 date: ProductDate,
2879 sample: Option<&str>,
2880) -> Result<ProductSpec, DataCatalogError> {
2881 product(center, ProductType::Clk, date, sample, None)
2882}
2883
2884pub fn mgex_nav(
2886 center: AnalysisCenter,
2887 date: ProductDate,
2888 sample: Option<&str>,
2889) -> Result<ProductSpec, DataCatalogError> {
2890 product(center, ProductType::Nav, date, sample, None)
2891}
2892
2893pub fn mgex_ionex(
2895 center: AnalysisCenter,
2896 date: ProductDate,
2897 sample: Option<&str>,
2898) -> Result<ProductSpec, DataCatalogError> {
2899 product(center, ProductType::Ionex, date, sample, None)
2900}
2901
2902pub fn rapid_ionex(
2904 date: ProductDate,
2905 sample: Option<&str>,
2906) -> Result<ProductSpec, DataCatalogError> {
2907 product(
2908 AnalysisCenter::CodRap,
2909 ProductType::Ionex,
2910 date,
2911 sample,
2912 None,
2913 )
2914}
2915
2916#[must_use]
2918pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2919 match center {
2920 AnalysisCenter::CodPrd2 => 1,
2921 _ => 0,
2922 }
2923}
2924
2925pub fn predicted_ionex(
2927 center: AnalysisCenter,
2928 date: ProductDate,
2929 sample: Option<&str>,
2930) -> Result<ProductSpec, DataCatalogError> {
2931 match center {
2932 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2933 let target = date.add_days(predicted_day_offset(center))?;
2934 product(center, ProductType::Ionex, target, sample, None)
2935 }
2936 other => Err(DataCatalogError::UnsupportedProduct {
2937 center: other,
2938 product_type: ProductType::Ionex,
2939 }),
2940 }
2941}
2942
2943pub fn mgex_sp3(
2945 center: AnalysisCenter,
2946 date: ProductDate,
2947 sample: Option<&str>,
2948) -> Result<ProductSpec, DataCatalogError> {
2949 product(center, ProductType::Sp3, date, sample, None)
2950}
2951
2952pub fn ops_ultra_sp3(
2954 center: AnalysisCenter,
2955 date: ProductDate,
2956 sample: Option<&str>,
2957 issue: Option<&str>,
2958) -> Result<ProductSpec, DataCatalogError> {
2959 let issue = issue.unwrap_or("0000");
2960 product(center, ProductType::Sp3, date, sample, Some(issue))
2961}
2962
2963pub fn ultra_sp3_locations(
2973 center: AnalysisCenter,
2974 date: ProductDate,
2975 issue: &str,
2976) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2977 validate_issue_for_center(center, Some(issue))?;
2978 validate_product_date(center, ProductType::Sp3, date)?;
2979 match center {
2980 AnalysisCenter::IgsUlt
2981 | AnalysisCenter::CodUlt
2982 | AnalysisCenter::EsaUlt
2983 | AnalysisCenter::GfzUlt => {}
2984 other => {
2985 return Err(DataCatalogError::UnsupportedProduct {
2986 center: other,
2987 product_type: ProductType::Sp3,
2988 })
2989 }
2990 };
2991 let default_sample =
2992 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
2993 let mut samples = supported_samples(center, ProductType::Sp3, date, Some(issue))?.to_vec();
2994 samples.sort_by_key(|sample| *sample != default_sample);
2995
2996 samples
2997 .into_iter()
2998 .map(|sample| {
2999 let spec = ops_ultra_sp3(center, date, Some(sample), Some(issue))?;
3003 let identity = spec.identity()?;
3004 let filename = spec.canonical_filename()?;
3005 let url = spec.archive_url()?;
3006 let convention = product_convention(center, ProductType::Sp3)?;
3007 let compression = product_archive_compression(
3008 center,
3009 ProductType::Sp3,
3010 date,
3011 convention.compression,
3012 )?;
3013 Ok(UltraSp3Location {
3014 pattern: if sample == default_sample {
3015 format!("primary_{}_{}", identity.span, sample)
3016 } else {
3017 format!("alternate_{}_{}", identity.span, sample)
3018 },
3019 span: identity.span,
3020 sample: sample.to_string(),
3021 url,
3022 filename,
3023 compression,
3024 })
3025 })
3026 .collect()
3027}
3028
3029pub fn ops_ultra_clk(
3031 center: AnalysisCenter,
3032 date: ProductDate,
3033 sample: Option<&str>,
3034 issue: Option<&str>,
3035) -> Result<ProductSpec, DataCatalogError> {
3036 let issue = issue.unwrap_or("0000");
3037 product(center, ProductType::Clk, date, sample, Some(issue))
3038}
3039
3040pub fn latest_ops_ultra_sp3(
3042 center: AnalysisCenter,
3043 target: ProductDateTime,
3044 sample: Option<&str>,
3045 available_issues: Option<&[UltraIssue]>,
3046) -> Result<ProductSpec, DataCatalogError> {
3047 let selected = latest_ultra_issue(center, target, available_issues)?;
3048 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
3049}
3050
3051pub fn ultra_issue_candidates(
3053 center: AnalysisCenter,
3054 target: ProductDateTime,
3055) -> Result<Vec<UltraIssue>, DataCatalogError> {
3056 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3057 let _ = product_convention(center, ProductType::Sp3)?;
3058 if entry.issues.is_empty() {
3059 return Err(DataCatalogError::UnsupportedProduct {
3060 center,
3061 product_type: ProductType::Sp3,
3062 });
3063 }
3064 validate_product_date(center, ProductType::Sp3, target.date)?;
3065
3066 let mut candidates = Vec::new();
3067 for date in [target.date, target.date.add_days(-1)?] {
3068 match validate_product_date(center, ProductType::Sp3, date) {
3069 Ok(()) => {}
3070 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
3071 Err(error) => return Err(error),
3072 }
3073 for issue in entry.issues.iter().rev() {
3074 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
3075 candidates.push(UltraIssue::new(date, issue)?);
3076 }
3077 }
3078 }
3079 Ok(candidates)
3080}
3081
3082pub fn latest_ultra_issue(
3084 center: AnalysisCenter,
3085 target: ProductDateTime,
3086 available_issues: Option<&[UltraIssue]>,
3087) -> Result<UltraIssue, DataCatalogError> {
3088 let candidates = ultra_issue_candidates(center, target)?;
3089 if candidates.is_empty() {
3090 return Err(DataCatalogError::NoUltraIssue);
3091 }
3092 if let Some(available) = available_issues {
3093 candidates
3094 .into_iter()
3095 .find(|candidate| {
3096 available
3097 .iter()
3098 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
3099 })
3100 .ok_or(DataCatalogError::NoAvailableUltraIssue)
3101 } else {
3102 Ok(candidates[0].clone())
3103 }
3104}
3105
3106pub fn gim_date_candidates(
3108 center: AnalysisCenter,
3109 target: ProductDate,
3110 lookback: u32,
3111) -> Result<Vec<ProductDate>, DataCatalogError> {
3112 let _ = product_convention(center, ProductType::Ionex)?;
3113 let base = target.add_days(predicted_day_offset(center))?;
3114 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
3115 for back in 0..=lookback {
3116 out.push(base.add_days(-i64::from(back))?);
3117 }
3118 Ok(out)
3119}
3120
3121pub fn station_obs(
3123 station: &str,
3124 date: ProductDate,
3125 sample: Option<&str>,
3126) -> Result<StationObservationSpec, DataCatalogError> {
3127 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
3128}
3129
3130pub fn station_obs_filename(
3132 station: &str,
3133 date: ProductDate,
3134 sample: &str,
3135) -> Result<String, DataCatalogError> {
3136 validate_station(station)?;
3137 validate_sample(sample)?;
3138 Ok(format!(
3139 "{}_R_{}_01D_{}_MO.crx",
3140 station,
3141 date_block(date, None),
3142 sample
3143 ))
3144}
3145
3146pub fn station_obs_url(
3148 station: &str,
3149 date: ProductDate,
3150 sample: &str,
3151) -> Result<String, DataCatalogError> {
3152 let filename = station_obs_filename(station, date, sample)?;
3153 Ok(format!(
3154 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3155 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3156 filename
3157 ))
3158}
3159
3160#[must_use]
3162pub const fn station_obs_protocol() -> ArchiveProtocol {
3163 ArchiveProtocol::Https
3164}
3165
3166fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3167 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3168 Ok(())
3169 } else {
3170 Err(DataCatalogError::InvalidTileIndex {
3171 lat_index,
3172 lon_index: 0,
3173 })
3174 }
3175}
3176
3177fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3178 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3179 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3180 {
3181 Ok(())
3182 } else {
3183 Err(DataCatalogError::InvalidTileIndex {
3184 lat_index,
3185 lon_index,
3186 })
3187 }
3188}
3189
3190fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3191 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3192 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3193 {
3194 Ok(())
3195 } else {
3196 Err(HgtConversionError::InvalidTileIndex {
3197 lat_index,
3198 lon_index,
3199 })
3200 }
3201}
3202
3203fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3204 let hemi = match (is_longitude, index >= 0) {
3205 (true, true) => 'E',
3206 (true, false) => 'W',
3207 (false, true) => 'N',
3208 (false, false) => 'S',
3209 };
3210 format!("{:03}0000{hemi}", index.abs())
3211}
3212
3213fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3214 if sample == i16::MIN {
3215 0
3216 } else if sample >= 0 {
3217 sample as u16
3218 } else {
3219 0x8000 | (-i32::from(sample) as u16)
3220 }
3221}
3222
3223fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3224 PRODUCT_TYPE_CONVENTIONS
3225 .iter()
3226 .find(|descriptor| descriptor.product_type == product_type)
3227 .expect("product descriptor exists for enum variant")
3228}
3229
3230const fn product_format(product_type: ProductType) -> ProductFormat {
3231 match product_type {
3232 ProductType::Sp3 => ProductFormat::Sp3,
3233 ProductType::Ionex => ProductFormat::Ionex,
3234 ProductType::Clk => ProductFormat::RinexClock,
3235 ProductType::Nav => ProductFormat::RinexNavigation,
3236 }
3237}
3238
3239fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3240 if filename.is_empty()
3241 || filename == "."
3242 || filename == ".."
3243 || filename.contains('/')
3244 || filename.contains('\\')
3245 || filename.contains('\0')
3246 || filename.contains("..")
3247 {
3248 Err(DataCatalogError::InvalidOfficialFilename(
3249 filename.to_string(),
3250 ))
3251 } else {
3252 Ok(())
3253 }
3254}
3255
3256fn validate_product(
3257 center: AnalysisCenter,
3258 product_type: ProductType,
3259 date: ProductDate,
3260 sample: &str,
3261 issue: Option<&str>,
3262) -> Result<&'static CenterProductConvention, DataCatalogError> {
3263 let convention = product_convention(center, product_type)?;
3264 validate_sample(sample)?;
3265 validate_issue_for_center(center, issue)?;
3266 validate_product_date(center, product_type, date)?;
3267 validate_catalog_sample(center, product_type, date, sample, issue)?;
3268 Ok(convention)
3269}
3270
3271fn validate_catalog_sample(
3272 center: AnalysisCenter,
3273 product_type: ProductType,
3274 date: ProductDate,
3275 sample: &str,
3276 issue: Option<&str>,
3277) -> Result<(), DataCatalogError> {
3278 let supported = supported_samples_inner(center, product_type, date, issue)?;
3279 if supported.contains(&sample) {
3280 return Ok(());
3281 }
3282 Err(DataCatalogError::UnsupportedSample {
3283 center,
3284 product_type,
3285 sample: sample.to_string(),
3286 })
3287}
3288
3289pub fn supported_samples(
3300 center: AnalysisCenter,
3301 product_type: ProductType,
3302 date: ProductDate,
3303 issue: Option<&str>,
3304) -> Result<&'static [&'static str], DataCatalogError> {
3305 ProductDate::new(date.year, date.month, date.day)?;
3306 product_convention(center, product_type)?;
3307 validate_product_date(center, product_type, date)?;
3308
3309 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3310 if entry.issues.is_empty() {
3311 validate_issue_for_center(center, issue)?;
3312 } else {
3313 validate_issue_for_center(center, Some(issue.unwrap_or("0000")))?;
3314 }
3315 supported_samples_inner(center, product_type, date, issue)
3316}
3317
3318fn supported_samples_inner(
3319 center: AnalysisCenter,
3320 product_type: ProductType,
3321 date: ProductDate,
3322 issue: Option<&str>,
3323) -> Result<&'static [&'static str], DataCatalogError> {
3324 if product_type != ProductType::Sp3 {
3325 let convention = product_convention(center, product_type)?;
3326 return Ok(match convention.default_sample {
3327 "30S" => &["30S"],
3328 "01H" => &["01H"],
3329 "02H" => &["02H"],
3330 "01D" => &["01D"],
3331 _ => &[],
3332 });
3333 }
3334
3335 Ok(match center {
3336 AnalysisCenter::Igs | AnalysisCenter::IgsUlt => &["15M"],
3337 AnalysisCenter::Esa | AnalysisCenter::Cod | AnalysisCenter::CodUlt => &["05M"],
3338 AnalysisCenter::Gfz => {
3339 if date < GFZ_RAPID_5M_START_DATE {
3340 &["15M"]
3341 } else {
3342 &["05M"]
3343 }
3344 }
3345 AnalysisCenter::EsaUlt => {
3346 let issue = issue.unwrap_or("0000");
3347 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3348 || (date == ESA_ULTRA_15M_LAST_DATE
3349 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3350 if at_or_before_last_15m {
3351 &["15M"]
3352 } else {
3353 &["05M"]
3354 }
3355 }
3356 AnalysisCenter::GfzUlt => {
3357 if date < GFZ_ULTRA_15M_LAST_DATE {
3358 &["15M"]
3359 } else if date == GFZ_ULTRA_15M_LAST_DATE {
3360 if issue.unwrap_or("0000") == "0000" {
3361 &["15M", "05M"]
3362 } else {
3363 &["15M"]
3364 }
3365 } else {
3366 &["05M"]
3367 }
3368 }
3369 AnalysisCenter::CodRap | AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => &[],
3370 })
3371}
3372
3373fn validate_product_date(
3374 center: AnalysisCenter,
3375 product_type: ProductType,
3376 date: ProductDate,
3377) -> Result<(), DataCatalogError> {
3378 if center == AnalysisCenter::Igs
3382 && product_type == ProductType::Sp3
3383 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3384 {
3385 return Err(DataCatalogError::UnsupportedProductEra {
3386 center,
3387 product_type,
3388 date,
3389 });
3390 }
3391
3392 if center == AnalysisCenter::Cod
3397 && matches!(
3398 product_type,
3399 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3400 )
3401 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3402 {
3403 return Err(DataCatalogError::UnsupportedProductEra {
3404 center,
3405 product_type,
3406 date,
3407 });
3408 }
3409
3410 let start_date = match (center, product_type) {
3411 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
3412 Some(ESA_FINAL_SERIES_START_DATE)
3413 }
3414 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
3415 Some(GFZ_RAPID_SERIES_START_DATE)
3416 }
3417 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
3418 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
3419 _ => None,
3420 };
3421 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
3422 && product_type == ProductType::Sp3
3423 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
3424 if before_long_name_start || start_date.is_some_and(|start| date < start) {
3425 return Err(DataCatalogError::UnsupportedProductEra {
3426 center,
3427 product_type,
3428 date,
3429 });
3430 }
3431 Ok(())
3432}
3433
3434fn default_sample_for_product_issue(
3435 center: AnalysisCenter,
3436 product_type: ProductType,
3437 date: ProductDate,
3438 issue: Option<&str>,
3439) -> Result<&'static str, DataCatalogError> {
3440 ProductDate::new(date.year, date.month, date.day)?;
3441 let current = default_sample(center, product_type)?;
3442 validate_product_date(center, product_type, date)?;
3443
3444 if product_type != ProductType::Sp3 {
3445 return Ok(current);
3446 }
3447 match center {
3448 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
3449 AnalysisCenter::EsaUlt => {
3450 let issue = issue.unwrap_or("0000");
3454 validate_issue_for_center(center, Some(issue))?;
3455 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3456 || (date == ESA_ULTRA_15M_LAST_DATE
3457 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3458 if at_or_before_last_15m {
3459 Ok("15M")
3460 } else {
3461 Ok(current)
3462 }
3463 }
3464 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
3465 _ => Ok(current),
3466 }
3467}
3468
3469fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
3470 let gps_week = identity.date.gps_week()?;
3471 let esa_mgex_final_sp3 =
3472 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
3473 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
3474 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
3475 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
3476 let unmodeled_pretransition_ionex =
3477 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
3478 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
3479 Err(DataCatalogError::UnsupportedDistributionEra {
3480 source: DistributionSource::NasaCddis,
3481 center: identity.analysis_center,
3482 product_type: identity.family,
3483 date: identity.date,
3484 })
3485 } else {
3486 Ok(())
3487 }
3488}
3489
3490fn validate_issue_for_center(
3491 center: AnalysisCenter,
3492 issue: Option<&str>,
3493) -> Result<(), DataCatalogError> {
3494 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3495 match (entry.issues.is_empty(), issue) {
3496 (true, None) => Ok(()),
3497 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
3498 (false, None) => Err(DataCatalogError::MissingIssue { center }),
3499 (false, Some(issue)) => {
3500 validate_issue(issue)?;
3501 if entry.issues.contains(&issue) {
3502 Ok(())
3503 } else {
3504 Err(DataCatalogError::UnsupportedIssue {
3505 center,
3506 issue: issue.to_string(),
3507 })
3508 }
3509 }
3510 }
3511}
3512
3513fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
3514 if validate_period_token(sample) {
3515 Ok(())
3516 } else {
3517 Err(DataCatalogError::InvalidSample(sample.to_string()))
3518 }
3519}
3520
3521fn validate_span(span: &str) -> Result<(), DataCatalogError> {
3522 if validate_period_token(span) {
3523 Ok(())
3524 } else {
3525 Err(DataCatalogError::InvalidSpan(span.to_string()))
3526 }
3527}
3528
3529fn validate_period_token(token: &str) -> bool {
3530 let bytes = token.as_bytes();
3531 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
3532 return false;
3533 }
3534 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
3535 match bytes[2] {
3536 b'S' | b'M' => amount > 0 && amount % 60 != 0,
3541 b'H' => amount > 0 && amount % 24 != 0,
3542 b'D' | b'W' | b'L' | b'Y' => amount > 0,
3543 b'U' => amount == 0,
3546 _ => false,
3547 }
3548}
3549
3550fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
3551 let bytes = issue.as_bytes();
3552 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
3553 if !valid_digits {
3554 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
3555 }
3556 let hour = issue[0..2]
3557 .parse::<u8>()
3558 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3559 let minute = issue[2..4]
3560 .parse::<u8>()
3561 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3562 if hour <= 23 && minute <= 59 {
3563 Ok(())
3564 } else {
3565 Err(DataCatalogError::InvalidIssue(issue.to_string()))
3566 }
3567}
3568
3569fn validate_station(station: &str) -> Result<(), DataCatalogError> {
3570 let bytes = station.as_bytes();
3571 let valid = bytes.len() == 9
3572 && bytes
3573 .iter()
3574 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
3575 if valid {
3576 Ok(())
3577 } else {
3578 Err(DataCatalogError::InvalidStation(station.to_string()))
3579 }
3580}
3581
3582fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
3583 validate_issue(issue)?;
3584 let hour = issue[0..2]
3585 .parse::<u16>()
3586 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3587 let minute = issue[2..4]
3588 .parse::<u16>()
3589 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3590 Ok(hour * 60 + minute)
3591}
3592
3593fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
3594 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
3595}
3596
3597fn date_block(date: ProductDate, issue: Option<&str>) -> String {
3598 format!(
3599 "{}{:03}{}",
3600 date.year,
3601 date.day_of_year(),
3602 issue.unwrap_or("0000")
3603 )
3604}
3605
3606fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
3607 Ok(match layout {
3608 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
3609 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
3610 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
3611 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
3612 ArchiveLayout::BkgBrdcYearDoy => {
3613 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
3614 }
3615 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
3616 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
3617 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
3618 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
3619 })
3620}
3621
3622fn product_dir_path(
3623 center: AnalysisCenter,
3624 layout: ArchiveLayout,
3625 date: ProductDate,
3626) -> Result<String, DataCatalogError> {
3627 match center {
3628 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
3629 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
3630 _ => dir_path(layout, date),
3631 }
3632}
3633
3634fn uses_legacy_igs_final_name(
3635 center: AnalysisCenter,
3636 product_type: ProductType,
3637 date: ProductDate,
3638) -> Result<bool, DataCatalogError> {
3639 Ok(center == AnalysisCenter::Igs
3640 && product_type == ProductType::Sp3
3641 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
3642}
3643
3644fn product_archive_compression(
3645 center: AnalysisCenter,
3646 product_type: ProductType,
3647 date: ProductDate,
3648 default: ArchiveCompression,
3649) -> Result<ArchiveCompression, DataCatalogError> {
3650 if uses_legacy_igs_final_name(center, product_type, date)? {
3651 Ok(ArchiveCompression::UnixCompress)
3652 } else {
3653 Ok(default)
3654 }
3655}
3656
3657fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
3658 let (year, month, day) = civil_from_julian_day_number(jdn);
3659 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
3660 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
3661 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
3662 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
3663}
3664
3665#[cfg(test)]
3666mod content_start_tests {
3667 use super::*;
3668
3669 const GFZ_ISSUES: [&str; 8] = [
3670 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
3671 ];
3672
3673 fn date(year: i32, month: u8, day: u8) -> ProductDate {
3674 ProductDate::new(year, month, day).expect("test date")
3675 }
3676
3677 fn offset(
3678 center: AnalysisCenter,
3679 product_date: ProductDate,
3680 sample: &str,
3681 issue: Option<&str>,
3682 ) -> i64 {
3683 let identity =
3684 product_identity(center, ProductType::Sp3, product_date, Some(sample), issue)
3685 .expect("cataloged SP3 identity");
3686 exact_sp3_content_start_offset_s(&identity).expect("content-start convention")
3687 }
3688
3689 #[test]
3690 fn gfz_ultra_pre_transition_issues_start_one_day_before_filename_epoch() {
3691 for issue in GFZ_ISSUES {
3692 assert_eq!(
3693 offset(AnalysisCenter::GfzUlt, date(2022, 9, 6), "05M", Some(issue)),
3694 -86_400,
3695 "2022-09-06 issue {issue}"
3696 );
3697 }
3698 }
3699
3700 #[test]
3701 fn gfz_ultra_transition_is_cataloged_per_issue() {
3702 let day_seven = [
3703 0, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400, -86_400,
3704 ];
3705 let day_eight = [0, -86_400, -86_400, 0, 0, 0, 0, 0];
3706
3707 for (product_day, expected) in [(7, day_seven), (8, day_eight)] {
3708 for (issue, expected_offset) in GFZ_ISSUES.iter().zip(expected) {
3709 assert_eq!(
3710 offset(
3711 AnalysisCenter::GfzUlt,
3712 date(2022, 9, product_day),
3713 "05M",
3714 Some(issue)
3715 ),
3716 expected_offset,
3717 "2022-09-{product_day:02} issue {issue}"
3718 );
3719 }
3720 }
3721 }
3722
3723 #[test]
3724 fn gfz_ultra_post_transition_and_other_product_lines_use_filename_epoch() {
3725 for issue in GFZ_ISSUES {
3726 assert_eq!(
3727 offset(AnalysisCenter::GfzUlt, date(2022, 9, 9), "05M", Some(issue)),
3728 0,
3729 "2022-09-09 issue {issue}"
3730 );
3731 }
3732
3733 let current = date(2026, 7, 20);
3734 let cases = [
3735 (AnalysisCenter::Igs, "15M", None),
3736 (AnalysisCenter::Esa, "05M", None),
3737 (AnalysisCenter::Cod, "05M", None),
3738 (AnalysisCenter::Gfz, "05M", None),
3739 (AnalysisCenter::IgsUlt, "15M", Some("1200")),
3740 (AnalysisCenter::CodUlt, "05M", Some("0000")),
3741 (AnalysisCenter::EsaUlt, "05M", Some("1800")),
3742 (AnalysisCenter::GfzUlt, "05M", Some("2100")),
3743 ];
3744 for (center, sample, issue) in cases {
3745 assert_eq!(offset(center, current, sample, issue), 0, "{center:?}");
3746 }
3747 }
3748
3749 #[test]
3750 fn gfz_ultra_content_start_is_independent_of_its_cadence_transition() {
3751 assert_eq!(
3752 offset(
3753 AnalysisCenter::GfzUlt,
3754 date(2021, 5, 15),
3755 "15M",
3756 Some("0000")
3757 ),
3758 -86_400
3759 );
3760 assert_eq!(
3761 offset(
3762 AnalysisCenter::GfzUlt,
3763 date(2021, 5, 16),
3764 "05M",
3765 Some("0000")
3766 ),
3767 -86_400
3768 );
3769 }
3770
3771 #[test]
3772 fn public_content_start_query_enforces_center_issue_rules() {
3773 assert_eq!(
3774 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), Some("0130")),
3775 Err(DataCatalogError::UnsupportedIssue {
3776 center: AnalysisCenter::GfzUlt,
3777 issue: "0130".to_owned(),
3778 })
3779 );
3780 assert_eq!(
3781 sp3_content_start_convention(AnalysisCenter::Gfz, date(2022, 9, 7), Some("0000")),
3782 Err(DataCatalogError::UnexpectedIssue {
3783 center: AnalysisCenter::Gfz,
3784 })
3785 );
3786 assert_eq!(
3787 sp3_content_start_convention(AnalysisCenter::GfzUlt, date(2022, 9, 7), None),
3788 Err(DataCatalogError::MissingIssue {
3789 center: AnalysisCenter::GfzUlt,
3790 })
3791 );
3792 }
3793}