1use core::fmt;
9use core::str::FromStr;
10
11use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
12use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
13use crate::astro::time::model::TimeScale;
14use crate::astro::time::scales::julian_day_number;
15use crate::terrain;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum AnalysisCenter {
20 Igs,
22 CodRap,
24 CodPrd1,
26 CodPrd2,
28 Esa,
30 Cod,
32 Gfz,
34 IgsUlt,
36 CodUlt,
38 EsaUlt,
40 GfzUlt,
42}
43
44impl AnalysisCenter {
45 #[must_use]
47 pub const fn code(self) -> &'static str {
48 match self {
49 Self::Igs => "igs",
50 Self::CodRap => "cod_rap",
51 Self::CodPrd1 => "cod_prd1",
52 Self::CodPrd2 => "cod_prd2",
53 Self::Esa => "esa",
54 Self::Cod => "cod",
55 Self::Gfz => "gfz",
56 Self::IgsUlt => "igs_ult",
57 Self::CodUlt => "cod_ult",
58 Self::EsaUlt => "esa_ult",
59 Self::GfzUlt => "gfz_ult",
60 }
61 }
62
63 #[must_use]
65 pub fn from_code(code: &str) -> Option<Self> {
66 match code {
67 "igs" => Some(Self::Igs),
68 "cod_rap" => Some(Self::CodRap),
69 "cod_prd1" => Some(Self::CodPrd1),
70 "cod_prd2" => Some(Self::CodPrd2),
71 "esa" => Some(Self::Esa),
72 "cod" => Some(Self::Cod),
73 "gfz" => Some(Self::Gfz),
74 "igs_ult" => Some(Self::IgsUlt),
75 "cod_ult" => Some(Self::CodUlt),
76 "esa_ult" => Some(Self::EsaUlt),
77 "gfz_ult" => Some(Self::GfzUlt),
78 _ => None,
79 }
80 }
81
82 #[must_use]
84 pub const fn publisher(self) -> ProductPublisher {
85 match self {
86 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
87 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
88 ProductPublisher::Code
89 }
90 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
91 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
92 }
93 }
94
95 #[must_use]
97 pub const fn solution_class(self) -> SolutionClass {
98 match self {
99 Self::Igs => SolutionClass::Broadcast,
100 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
101 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
102 Self::Esa | Self::Cod => SolutionClass::Final,
103 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
104 }
105 }
106
107 #[must_use]
109 pub const fn prediction_horizon_days(self) -> Option<u8> {
110 match self {
111 Self::CodPrd1 => Some(1),
112 Self::CodPrd2 => Some(2),
113 _ => None,
114 }
115 }
116}
117
118impl fmt::Display for AnalysisCenter {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(self.code())
121 }
122}
123
124impl FromStr for AnalysisCenter {
125 type Err = DataCatalogError;
126
127 fn from_str(s: &str) -> Result<Self, Self::Err> {
128 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
134pub enum ProductType {
135 Sp3,
137 Clk,
139 Nav,
141 Ionex,
143}
144
145impl ProductType {
146 #[must_use]
148 pub const fn code(self) -> &'static str {
149 match self {
150 Self::Sp3 => "sp3",
151 Self::Clk => "clk",
152 Self::Nav => "nav",
153 Self::Ionex => "ionex",
154 }
155 }
156
157 #[must_use]
159 pub fn from_code(code: &str) -> Option<Self> {
160 match code {
161 "sp3" => Some(Self::Sp3),
162 "clk" => Some(Self::Clk),
163 "nav" => Some(Self::Nav),
164 "ionex" => Some(Self::Ionex),
165 _ => None,
166 }
167 }
168}
169
170impl fmt::Display for ProductType {
171 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172 f.write_str(self.code())
173 }
174}
175
176impl FromStr for ProductType {
177 type Err = DataCatalogError;
178
179 fn from_str(s: &str) -> Result<Self, Self::Err> {
180 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
190pub enum ProductPublisher {
191 Igs,
193 Code,
195 Esa,
197 Gfz,
199}
200
201impl ProductPublisher {
202 #[must_use]
204 pub const fn code(self) -> &'static str {
205 match self {
206 Self::Igs => "IGS",
207 Self::Code => "COD",
208 Self::Esa => "ESA",
209 Self::Gfz => "GFZ",
210 }
211 }
212}
213
214impl fmt::Display for ProductPublisher {
215 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216 f.write_str(self.code())
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
222pub enum SolutionClass {
223 Final,
225 Rapid,
227 UltraRapid,
229 Predicted,
231 Broadcast,
233}
234
235impl SolutionClass {
236 #[must_use]
238 pub const fn code(self) -> &'static str {
239 match self {
240 Self::Final => "final",
241 Self::Rapid => "rapid",
242 Self::UltraRapid => "ultra_rapid",
243 Self::Predicted => "predicted",
244 Self::Broadcast => "broadcast",
245 }
246 }
247
248 #[must_use]
250 pub const fn filename_token(self) -> Option<&'static str> {
251 match self {
252 Self::Final => Some("FIN"),
253 Self::Rapid => Some("RAP"),
254 Self::UltraRapid => Some("ULT"),
255 Self::Predicted => Some("PRD"),
256 Self::Broadcast => None,
257 }
258 }
259}
260
261impl fmt::Display for SolutionClass {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 f.write_str(self.code())
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
269pub enum ProductCampaign {
270 Operational,
272 MultiGnss,
274 MultiGnssExperiment,
276 Broadcast,
278}
279
280impl ProductCampaign {
281 #[must_use]
283 pub const fn code(self) -> &'static str {
284 match self {
285 Self::Operational => "OPS",
286 Self::MultiGnss => "MGN",
287 Self::MultiGnssExperiment => "MGX",
288 Self::Broadcast => "BRD",
289 }
290 }
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
295pub enum ProductFormat {
296 Sp3,
298 Ionex,
300 RinexClock,
302 RinexNavigation,
304}
305
306impl ProductFormat {
307 #[must_use]
309 pub const fn code(self) -> &'static str {
310 match self {
311 Self::Sp3 => "SP3",
312 Self::Ionex => "IONEX",
313 Self::RinexClock => "RINEX_CLK",
314 Self::RinexNavigation => "RINEX_NAV",
315 }
316 }
317}
318
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
324pub enum DistributionSource {
325 Direct,
327 NasaCddis,
329 LocalFile,
331 InMemory,
333}
334
335impl DistributionSource {
336 #[must_use]
338 pub const fn code(self) -> &'static str {
339 match self {
340 Self::Direct => "direct",
341 Self::NasaCddis => "nasa_cddis",
342 Self::LocalFile => "local_file",
343 Self::InMemory => "in_memory",
344 }
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
350pub enum SpaceWeatherProduct {
351 All,
353 Last5Years,
355}
356
357impl SpaceWeatherProduct {
358 #[must_use]
360 pub const fn code(self) -> &'static str {
361 match self {
362 Self::All => "sw_all",
363 Self::Last5Years => "sw_last5",
364 }
365 }
366
367 #[must_use]
369 pub fn from_code(code: &str) -> Option<Self> {
370 match code {
371 "sw_all" => Some(Self::All),
372 "sw_last5" => Some(Self::Last5Years),
373 _ => None,
374 }
375 }
376}
377
378impl fmt::Display for SpaceWeatherProduct {
379 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380 f.write_str(self.code())
381 }
382}
383
384impl FromStr for SpaceWeatherProduct {
385 type Err = DataCatalogError;
386
387 fn from_str(s: &str) -> Result<Self, Self::Err> {
388 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
394pub enum ArchiveProtocol {
395 Http,
397 Https,
399}
400
401impl ArchiveProtocol {
402 #[must_use]
404 pub const fn as_str(self) -> &'static str {
405 match self {
406 Self::Http => "http",
407 Self::Https => "https",
408 }
409 }
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
414pub enum ArchiveCompression {
415 Gzip,
417 None,
419}
420
421impl ArchiveCompression {
422 #[must_use]
424 pub const fn as_str(self) -> &'static str {
425 match self {
426 Self::Gzip => "gzip",
427 Self::None => "none",
428 }
429 }
430
431 const fn suffix(self) -> &'static str {
432 match self {
433 Self::Gzip => ".gz",
434 Self::None => "",
435 }
436 }
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
441pub enum ArchiveLayout {
442 GfzRapidWeek,
444 GfzUltraWeek,
446 GpsWeek,
448 BkgProductsWeek,
450 BkgBrdcYearDoy,
452 BkgObsYearDoy,
454 AiubCodeMgexYear,
456 AiubCodeYear,
458 AiubCodeRoot,
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum ProductFilenameKind {
465 Sampled,
467 Nav,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub struct ProductTypeConvention {
474 pub product_type: ProductType,
476 pub content_code: &'static str,
478 pub extension: &'static str,
480 pub kind: ProductFilenameKind,
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub struct CenterProductConvention {
487 pub product_type: ProductType,
489 pub token: &'static str,
491 pub layout: ArchiveLayout,
493 pub span: &'static str,
495 pub default_sample: &'static str,
497 pub compression: ArchiveCompression,
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub struct CenterCatalogEntry {
504 pub center: AnalysisCenter,
506 pub code: &'static str,
508 pub protocol: ArchiveProtocol,
510 pub host: &'static str,
512 pub root_url: &'static str,
514 pub products: &'static [CenterProductConvention],
516 pub issues: &'static [&'static str],
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522pub struct TerrainSourceEntry {
523 pub protocol: ArchiveProtocol,
525 pub host: &'static str,
527 pub compression: ArchiveCompression,
529 pub root_url: &'static str,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub struct SpaceWeatherSourceEntry {
536 pub protocol: ArchiveProtocol,
538 pub host: &'static str,
540 pub compression: ArchiveCompression,
542 pub root_url: &'static str,
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
548pub struct NoOpenMirrorProduct {
549 pub center: &'static str,
551 pub product_type: &'static str,
553}
554
555const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
556 ProductTypeConvention {
557 product_type: ProductType::Sp3,
558 content_code: "ORB",
559 extension: "SP3",
560 kind: ProductFilenameKind::Sampled,
561 },
562 ProductTypeConvention {
563 product_type: ProductType::Clk,
564 content_code: "CLK",
565 extension: "CLK",
566 kind: ProductFilenameKind::Sampled,
567 },
568 ProductTypeConvention {
569 product_type: ProductType::Nav,
570 content_code: "MN",
571 extension: "rnx",
572 kind: ProductFilenameKind::Nav,
573 },
574 ProductTypeConvention {
575 product_type: ProductType::Ionex,
576 content_code: "GIM",
577 extension: "INX",
578 kind: ProductFilenameKind::Sampled,
579 },
580];
581
582const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
583 product_type: ProductType::Ionex,
584 token: "COD0OPSRAP",
585 layout: ArchiveLayout::AiubCodeRoot,
586 span: "01D",
587 default_sample: "01H",
588 compression: ArchiveCompression::Gzip,
589}];
590
591const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
592 product_type: ProductType::Ionex,
593 token: "COD0OPSPRD",
594 layout: ArchiveLayout::AiubCodeRoot,
595 span: "01D",
596 default_sample: "01H",
597 compression: ArchiveCompression::Gzip,
598}];
599
600const ESA_PRODUCTS: [CenterProductConvention; 3] = [
601 CenterProductConvention {
602 product_type: ProductType::Sp3,
603 token: "ESA0MGNFIN",
604 layout: ArchiveLayout::GpsWeek,
605 span: "01D",
606 default_sample: "05M",
607 compression: ArchiveCompression::Gzip,
608 },
609 CenterProductConvention {
610 product_type: ProductType::Clk,
611 token: "ESA0MGNFIN",
612 layout: ArchiveLayout::GpsWeek,
613 span: "01D",
614 default_sample: "30S",
615 compression: ArchiveCompression::Gzip,
616 },
617 CenterProductConvention {
618 product_type: ProductType::Ionex,
619 token: "ESA0OPSFIN",
620 layout: ArchiveLayout::GpsWeek,
621 span: "01D",
622 default_sample: "02H",
623 compression: ArchiveCompression::Gzip,
624 },
625];
626
627const COD_PRODUCTS: [CenterProductConvention; 3] = [
628 CenterProductConvention {
629 product_type: ProductType::Sp3,
630 token: "COD0MGXFIN",
631 layout: ArchiveLayout::AiubCodeMgexYear,
632 span: "01D",
633 default_sample: "05M",
634 compression: ArchiveCompression::Gzip,
635 },
636 CenterProductConvention {
637 product_type: ProductType::Clk,
638 token: "COD0MGXFIN",
639 layout: ArchiveLayout::AiubCodeMgexYear,
640 span: "01D",
641 default_sample: "30S",
642 compression: ArchiveCompression::Gzip,
643 },
644 CenterProductConvention {
645 product_type: ProductType::Ionex,
646 token: "COD0OPSFIN",
647 layout: ArchiveLayout::AiubCodeYear,
648 span: "01D",
649 default_sample: "01H",
650 compression: ArchiveCompression::Gzip,
651 },
652];
653
654const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
655 CenterProductConvention {
656 product_type: ProductType::Sp3,
657 token: "GFZ0OPSRAP",
658 layout: ArchiveLayout::GfzRapidWeek,
659 span: "01D",
660 default_sample: "15M",
661 compression: ArchiveCompression::Gzip,
662 },
663 CenterProductConvention {
664 product_type: ProductType::Clk,
665 token: "GFZ0OPSRAP",
666 layout: ArchiveLayout::GfzRapidWeek,
667 span: "01D",
668 default_sample: "30S",
669 compression: ArchiveCompression::Gzip,
670 },
671];
672
673const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
674 product_type: ProductType::Nav,
675 token: "BRDC00WRD",
676 layout: ArchiveLayout::BkgBrdcYearDoy,
677 span: "01D",
678 default_sample: "01D",
679 compression: ArchiveCompression::Gzip,
680}];
681
682const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
683 product_type: ProductType::Sp3,
684 token: "IGS0OPSULT",
685 layout: ArchiveLayout::BkgProductsWeek,
686 span: "02D",
687 default_sample: "15M",
688 compression: ArchiveCompression::Gzip,
689}];
690
691const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
692 product_type: ProductType::Sp3,
693 token: "COD0OPSULT",
694 layout: ArchiveLayout::AiubCodeRoot,
695 span: "01D",
696 default_sample: "05M",
697 compression: ArchiveCompression::None,
698}];
699
700const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
701 product_type: ProductType::Sp3,
702 token: "ESA0OPSULT",
703 layout: ArchiveLayout::GpsWeek,
704 span: "02D",
705 default_sample: "05M",
706 compression: ArchiveCompression::Gzip,
707}];
708
709const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
710 product_type: ProductType::Sp3,
711 token: "GFZ0OPSULT",
712 layout: ArchiveLayout::GfzUltraWeek,
713 span: "02D",
714 default_sample: "05M",
715 compression: ArchiveCompression::Gzip,
716}];
717
718const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
719const COD_ULT_ISSUES: [&str; 1] = ["0000"];
720const GFZ_ULT_ISSUES: [&str; 8] = [
721 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
722];
723
724const CENTER_ORDER: [AnalysisCenter; 11] = [
725 AnalysisCenter::CodRap,
726 AnalysisCenter::CodPrd1,
727 AnalysisCenter::CodPrd2,
728 AnalysisCenter::Igs,
729 AnalysisCenter::Esa,
730 AnalysisCenter::Cod,
731 AnalysisCenter::Gfz,
732 AnalysisCenter::IgsUlt,
733 AnalysisCenter::CodUlt,
734 AnalysisCenter::EsaUlt,
735 AnalysisCenter::GfzUlt,
736];
737
738const CATALOG: [CenterCatalogEntry; 11] = [
739 CenterCatalogEntry {
740 center: AnalysisCenter::CodRap,
741 code: "cod_rap",
742 protocol: ArchiveProtocol::Http,
743 host: "ftp.aiub.unibe.ch",
744 root_url: "http://ftp.aiub.unibe.ch",
745 products: &COD_RAP_PRODUCTS,
746 issues: &[],
747 },
748 CenterCatalogEntry {
749 center: AnalysisCenter::CodPrd1,
750 code: "cod_prd1",
751 protocol: ArchiveProtocol::Https,
752 host: "www.aiub.unibe.ch",
753 root_url: "https://www.aiub.unibe.ch/download",
754 products: &COD_PRD_PRODUCTS,
755 issues: &[],
756 },
757 CenterCatalogEntry {
758 center: AnalysisCenter::CodPrd2,
759 code: "cod_prd2",
760 protocol: ArchiveProtocol::Https,
761 host: "www.aiub.unibe.ch",
762 root_url: "https://www.aiub.unibe.ch/download",
763 products: &COD_PRD_PRODUCTS,
764 issues: &[],
765 },
766 CenterCatalogEntry {
767 center: AnalysisCenter::Igs,
768 code: "igs",
769 protocol: ArchiveProtocol::Https,
770 host: "igs.bkg.bund.de",
771 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
772 products: &IGS_PRODUCTS,
773 issues: &[],
774 },
775 CenterCatalogEntry {
776 center: AnalysisCenter::Esa,
777 code: "esa",
778 protocol: ArchiveProtocol::Https,
779 host: "navigation-office.esa.int",
780 root_url: "https://navigation-office.esa.int/products/gnss-products",
781 products: &ESA_PRODUCTS,
782 issues: &[],
783 },
784 CenterCatalogEntry {
785 center: AnalysisCenter::Cod,
786 code: "cod",
787 protocol: ArchiveProtocol::Http,
788 host: "ftp.aiub.unibe.ch",
789 root_url: "http://ftp.aiub.unibe.ch",
790 products: &COD_PRODUCTS,
791 issues: &[],
792 },
793 CenterCatalogEntry {
794 center: AnalysisCenter::Gfz,
795 code: "gfz",
796 protocol: ArchiveProtocol::Https,
797 host: "isdc-data.gfz.de",
798 root_url: "https://isdc-data.gfz.de/gnss/products",
799 products: &GFZ_PRODUCTS,
800 issues: &[],
801 },
802 CenterCatalogEntry {
803 center: AnalysisCenter::IgsUlt,
804 code: "igs_ult",
805 protocol: ArchiveProtocol::Https,
806 host: "igs.bkg.bund.de",
807 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
808 products: &IGS_ULT_PRODUCTS,
809 issues: &OPSULT_ISSUES,
810 },
811 CenterCatalogEntry {
812 center: AnalysisCenter::CodUlt,
813 code: "cod_ult",
814 protocol: ArchiveProtocol::Https,
815 host: "www.aiub.unibe.ch",
816 root_url: "https://www.aiub.unibe.ch/download",
820 products: &COD_ULT_PRODUCTS,
821 issues: &COD_ULT_ISSUES,
822 },
823 CenterCatalogEntry {
824 center: AnalysisCenter::EsaUlt,
825 code: "esa_ult",
826 protocol: ArchiveProtocol::Https,
827 host: "navigation-office.esa.int",
828 root_url: "https://navigation-office.esa.int/products/gnss-products",
829 products: &ESA_ULT_PRODUCTS,
830 issues: &OPSULT_ISSUES,
831 },
832 CenterCatalogEntry {
833 center: AnalysisCenter::GfzUlt,
834 code: "gfz_ult",
835 protocol: ArchiveProtocol::Https,
836 host: "isdc-data.gfz.de",
837 root_url: "https://isdc-data.gfz.de/gnss/products",
838 products: &GFZ_ULT_PRODUCTS,
839 issues: &GFZ_ULT_ISSUES,
840 },
841];
842
843const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
844 protocol: ArchiveProtocol::Https,
845 host: "s3.amazonaws.com",
846 compression: ArchiveCompression::Gzip,
847 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
848};
849
850const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
851 protocol: ArchiveProtocol::Https,
852 host: "celestrak.org",
853 compression: ArchiveCompression::None,
854 root_url: "https://celestrak.org/SpaceData",
855};
856
857const ALLOWED_HOSTS: [&str; 11] = [
858 "ftp.aiub.unibe.ch",
859 "www.aiub.unibe.ch",
860 "download.aiub.unibe.ch",
861 "zhw-b.s3.cloud.switch.ch",
862 "navigation-office.esa.int",
863 "isdc-data.gfz.de",
864 "igs.bkg.bund.de",
865 "s3.amazonaws.com",
866 "celestrak.org",
867 "cddis.nasa.gov",
868 "urs.earthdata.nasa.gov",
869];
870
871const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
872 NoOpenMirrorProduct {
873 center: "grg",
874 product_type: "sp3",
875 },
876 NoOpenMirrorProduct {
877 center: "grg",
878 product_type: "clk",
879 },
880 NoOpenMirrorProduct {
881 center: "wum",
882 product_type: "sp3",
883 },
884 NoOpenMirrorProduct {
885 center: "wum",
886 product_type: "clk",
887 },
888 NoOpenMirrorProduct {
889 center: "grg_ult",
890 product_type: "sp3",
891 },
892 NoOpenMirrorProduct {
893 center: "grg_ult",
894 product_type: "clk",
895 },
896 NoOpenMirrorProduct {
897 center: "igs",
898 product_type: "ionex",
899 },
900];
901
902#[derive(Debug, Clone, PartialEq, Eq)]
904pub enum DataCatalogError {
905 UnknownCenter(String),
907 UnknownProductType(String),
909 UnsupportedProduct {
911 center: AnalysisCenter,
913 product_type: ProductType,
915 },
916 UnsupportedDistribution {
918 source: DistributionSource,
920 product_type: ProductType,
922 },
923 NoDistributionSources,
925 InvalidOfficialFilename(String),
927 InconsistentProductIdentity {
929 field: &'static str,
931 },
932 NoOpenMirror {
934 center: String,
936 product_type: String,
938 },
939 InvalidDate {
941 year: i32,
943 month: u8,
945 day: u8,
947 },
948 DateOutOfRange,
950 DateBeforeGpsEpoch(ProductDate),
952 InvalidGpsDayOfWeek(u8),
954 InvalidSample(String),
956 InvalidIssue(String),
958 MissingIssue {
960 center: AnalysisCenter,
962 },
963 UnexpectedIssue {
965 center: AnalysisCenter,
967 },
968 UnsupportedIssue {
970 center: AnalysisCenter,
972 issue: String,
974 },
975 InvalidDateTime {
977 hour: u8,
979 minute: u8,
981 second: u8,
983 },
984 NoUltraIssue,
986 NoAvailableUltraIssue,
988 InvalidStation(String),
990 InvalidCoordinate {
992 lat_deg_bits: u64,
994 lon_deg_bits: u64,
996 },
997 InvalidTileIndex {
999 lat_index: i32,
1001 lon_index: i32,
1003 },
1004 InvalidTileId(String),
1006}
1007
1008impl fmt::Display for DataCatalogError {
1009 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1010 match self {
1011 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1012 Self::UnknownProductType(product_type) => {
1013 write!(f, "unknown product type {product_type:?}")
1014 }
1015 Self::UnsupportedProduct {
1016 center,
1017 product_type,
1018 } => write!(f, "{center} does not serve {product_type}"),
1019 Self::UnsupportedDistribution {
1020 source,
1021 product_type,
1022 } => write!(
1023 f,
1024 "distributor {} does not serve {product_type}",
1025 source.code()
1026 ),
1027 Self::NoDistributionSources => {
1028 write!(f, "exact product request has no distributors")
1029 }
1030 Self::InvalidOfficialFilename(filename) => {
1031 write!(f, "invalid official product filename {filename:?}")
1032 }
1033 Self::InconsistentProductIdentity { field } => {
1034 write!(
1035 f,
1036 "product identity field {field:?} disagrees with its official filename"
1037 )
1038 }
1039 Self::NoOpenMirror {
1040 center,
1041 product_type,
1042 } => write!(f, "{center}/{product_type} has no open mirror"),
1043 Self::InvalidDate { year, month, day } => {
1044 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1045 }
1046 Self::DateOutOfRange => write!(f, "product date is out of range"),
1047 Self::DateBeforeGpsEpoch(date) => {
1048 write!(f, "product date {date} is before the GPS week epoch")
1049 }
1050 Self::InvalidGpsDayOfWeek(day) => {
1051 write!(f, "invalid GPS day-of-week {day}")
1052 }
1053 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1054 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1055 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1056 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1057 Self::UnsupportedIssue { center, issue } => {
1058 write!(f, "{center} does not publish issue {issue:?}")
1059 }
1060 Self::InvalidDateTime {
1061 hour,
1062 minute,
1063 second,
1064 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1065 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1066 Self::NoAvailableUltraIssue => {
1067 write!(f, "no available ultra-rapid issue at or before target")
1068 }
1069 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1070 Self::InvalidCoordinate {
1071 lat_deg_bits,
1072 lon_deg_bits,
1073 } => write!(
1074 f,
1075 "invalid terrain coordinate lat={} lon={}",
1076 f64::from_bits(*lat_deg_bits),
1077 f64::from_bits(*lon_deg_bits)
1078 ),
1079 Self::InvalidTileIndex {
1080 lat_index,
1081 lon_index,
1082 } => write!(
1083 f,
1084 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1085 ),
1086 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1087 }
1088 }
1089}
1090
1091impl std::error::Error for DataCatalogError {}
1092
1093#[derive(Debug, Clone, PartialEq, Eq)]
1095pub enum HgtConversionError {
1096 BadLength {
1098 expected: usize,
1100 got: usize,
1102 },
1103 InvalidTileIndex {
1105 lat_index: i32,
1107 lon_index: i32,
1109 },
1110}
1111
1112impl fmt::Display for HgtConversionError {
1113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1114 match self {
1115 Self::BadLength { expected, got } => {
1116 write!(
1117 f,
1118 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1119 )
1120 }
1121 Self::InvalidTileIndex {
1122 lat_index,
1123 lon_index,
1124 } => write!(
1125 f,
1126 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1127 ),
1128 }
1129 }
1130}
1131
1132impl std::error::Error for HgtConversionError {}
1133
1134const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1135const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1136const MIN_TERRAIN_LON_INDEX: i32 = -180;
1137const MAX_TERRAIN_LON_INDEX: i32 = 179;
1138const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1139const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1140const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1141const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1142const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1143const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1144const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1145const DTED_SRTM1_LEN: usize =
1146 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1147
1148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1150pub struct ProductDate {
1151 pub year: i32,
1153 pub month: u8,
1155 pub day: u8,
1157}
1158
1159impl ProductDate {
1160 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1162 let days = days_in_month(i64::from(year), i64::from(month));
1163 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1164 return Err(DataCatalogError::InvalidDate { year, month, day });
1165 }
1166 Ok(Self { year, month, day })
1167 }
1168
1169 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1171 if day_of_week > 6 {
1172 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1173 }
1174 let epoch_jdn =
1175 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1176 let offset_days = i64::from(week)
1177 .checked_mul(7)
1178 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1179 .ok_or(DataCatalogError::DateOutOfRange)?;
1180 product_date_from_jdn(
1181 epoch_jdn
1182 .checked_add(offset_days)
1183 .ok_or(DataCatalogError::DateOutOfRange)?,
1184 )
1185 }
1186
1187 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1189 week_from_calendar(
1190 TimeScale::Gpst,
1191 i64::from(self.year),
1192 i64::from(self.month),
1193 i64::from(self.day),
1194 )
1195 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1196 }
1197
1198 #[must_use]
1200 pub fn day_of_year(self) -> u16 {
1201 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1202 }
1203
1204 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1205 product_date_from_jdn(
1206 self.julian_day_number()
1207 .checked_add(days)
1208 .ok_or(DataCatalogError::DateOutOfRange)?,
1209 )
1210 }
1211
1212 fn julian_day_number(self) -> i64 {
1213 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1214 }
1215}
1216
1217impl fmt::Display for ProductDate {
1218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1219 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1220 }
1221}
1222
1223#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1225pub struct ProductDateTime {
1226 pub date: ProductDate,
1228 pub hour: u8,
1230 pub minute: u8,
1232 pub second: u8,
1234}
1235
1236impl ProductDateTime {
1237 pub fn new(
1239 date: ProductDate,
1240 hour: u8,
1241 minute: u8,
1242 second: u8,
1243 ) -> Result<Self, DataCatalogError> {
1244 if hour > 23 || minute > 59 || second > 59 {
1245 return Err(DataCatalogError::InvalidDateTime {
1246 hour,
1247 minute,
1248 second,
1249 });
1250 }
1251 Ok(Self {
1252 date,
1253 hour,
1254 minute,
1255 second,
1256 })
1257 }
1258
1259 fn ordering_minutes(self) -> i64 {
1260 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1261 }
1262}
1263
1264#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1266pub struct UltraIssue {
1267 pub date: ProductDate,
1269 pub issue: String,
1271}
1272
1273impl UltraIssue {
1274 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1276 validate_issue(issue)?;
1277 Ok(Self {
1278 date,
1279 issue: issue.to_string(),
1280 })
1281 }
1282}
1283
1284#[derive(Debug, Clone, PartialEq, Eq)]
1286pub struct UltraSp3Location {
1287 pub pattern: String,
1289 pub span: String,
1291 pub sample: String,
1293 pub filename: String,
1295 pub url: String,
1297 pub compression: ArchiveCompression,
1299}
1300
1301#[derive(Debug, Clone, Copy)]
1302struct UltraSp3Pattern {
1303 label: &'static str,
1304 span: &'static str,
1305 sample: &'static str,
1306 alias_filename: Option<&'static str>,
1307}
1308
1309const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1310 UltraSp3Pattern {
1311 label: "primary_02D_15M",
1312 span: "02D",
1313 sample: "15M",
1314 alias_filename: None,
1315 },
1316 UltraSp3Pattern {
1317 label: "alternate_02D_05M",
1318 span: "02D",
1319 sample: "05M",
1320 alias_filename: None,
1321 },
1322 UltraSp3Pattern {
1323 label: "alternate_01D_15M",
1324 span: "01D",
1325 sample: "15M",
1326 alias_filename: None,
1327 },
1328];
1329
1330const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1331 UltraSp3Pattern {
1332 label: "primary_01D_05M",
1333 span: "01D",
1334 sample: "05M",
1335 alias_filename: None,
1336 },
1337 UltraSp3Pattern {
1338 label: "alternate_02D_05M",
1339 span: "02D",
1340 sample: "05M",
1341 alias_filename: None,
1342 },
1343 UltraSp3Pattern {
1344 label: "alias_latest",
1345 span: "01D",
1346 sample: "05M",
1347 alias_filename: Some("COD0OPSULT.SP3"),
1348 },
1349];
1350
1351const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1352 UltraSp3Pattern {
1353 label: "primary_02D_05M",
1354 span: "02D",
1355 sample: "05M",
1356 alias_filename: None,
1357 },
1358 UltraSp3Pattern {
1359 label: "alternate_02D_15M",
1360 span: "02D",
1361 sample: "15M",
1362 alias_filename: None,
1363 },
1364 UltraSp3Pattern {
1365 label: "alternate_01D_05M",
1366 span: "01D",
1367 sample: "05M",
1368 alias_filename: None,
1369 },
1370];
1371
1372const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1373 UltraSp3Pattern {
1374 label: "primary_02D_05M",
1375 span: "02D",
1376 sample: "05M",
1377 alias_filename: None,
1378 },
1379 UltraSp3Pattern {
1380 label: "alternate_02D_15M",
1381 span: "02D",
1382 sample: "15M",
1383 alias_filename: None,
1384 },
1385 UltraSp3Pattern {
1386 label: "alternate_01D_05M",
1387 span: "01D",
1388 sample: "05M",
1389 alias_filename: None,
1390 },
1391];
1392
1393#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1399pub struct ProductIdentity {
1400 pub family: ProductType,
1402 pub publisher: ProductPublisher,
1404 pub solution: SolutionClass,
1406 pub campaign: ProductCampaign,
1408 pub version: u8,
1410 pub date: ProductDate,
1412 pub issue: Option<String>,
1414 pub span: String,
1416 pub sample: String,
1418 pub official_filename: String,
1420 pub format: ProductFormat,
1422 pub prediction_horizon_days: Option<u8>,
1424}
1425
1426impl ProductIdentity {
1427 pub fn validate(&self) -> Result<(), DataCatalogError> {
1433 validate_official_filename(&self.official_filename)?;
1434 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1435 validate_sample(&self.sample)?;
1436 if let Some(issue) = self.issue.as_deref() {
1437 validate_issue(issue)?;
1438 }
1439
1440 if self.format != product_format(self.family) {
1441 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1442 }
1443
1444 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1445 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1446 (_, SolutionClass::Predicted, _) => false,
1447 (_, _, None) => true,
1448 (_, _, Some(_)) => false,
1449 };
1450 if !horizon_valid {
1451 return Err(DataCatalogError::InconsistentProductIdentity {
1452 field: "prediction_horizon_days",
1453 });
1454 }
1455
1456 let descriptor = product_type_convention(self.family);
1457 let expected = match descriptor.kind {
1458 ProductFilenameKind::Sampled => {
1459 let solution_token = self
1460 .solution
1461 .filename_token()
1462 .ok_or(DataCatalogError::InconsistentProductIdentity { field: "solution" })?;
1463 format!(
1464 "{}{}{}{}_{}_{}_{}_{}.{}",
1465 self.publisher.code(),
1466 self.version,
1467 self.campaign.code(),
1468 solution_token,
1469 date_block(self.date, self.issue.as_deref()),
1470 self.span,
1471 self.sample,
1472 descriptor.content_code,
1473 descriptor.extension
1474 )
1475 }
1476 ProductFilenameKind::Nav => {
1477 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1478 && self.solution == SolutionClass::Broadcast
1479 && self.campaign == ProductCampaign::Broadcast
1480 && self.version == 0
1481 && self.issue.is_none()
1482 && self.span == "01D"
1483 && self.sample == "01D";
1484 if !nav_fields_valid {
1485 return Err(DataCatalogError::InconsistentProductIdentity {
1486 field: "broadcast_navigation",
1487 });
1488 }
1489 format!(
1490 "BRDC00WRD_R_{}_{}_{}.{}",
1491 date_block(self.date, None),
1492 self.span,
1493 descriptor.content_code,
1494 descriptor.extension
1495 )
1496 }
1497 };
1498 if expected != self.official_filename {
1499 return Err(DataCatalogError::InconsistentProductIdentity {
1500 field: "official_filename",
1501 });
1502 }
1503 Ok(())
1504 }
1505
1506 pub fn key(&self) -> Result<String, DataCatalogError> {
1508 self.validate()?;
1509 let prediction = self.prediction_horizon_days.map_or_else(
1510 || "observed".to_string(),
1511 |days| format!("prediction_{days}d"),
1512 );
1513 Ok(format!(
1514 "{}/{}/{}/{}/{}/{}",
1515 self.family.code(),
1516 self.publisher.code().to_ascii_lowercase(),
1517 self.solution.code(),
1518 self.campaign.code().to_ascii_lowercase(),
1519 prediction,
1520 self.official_filename
1521 ))
1522 }
1523
1524 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1526 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1527 }
1528}
1529
1530#[derive(Debug, Clone, PartialEq, Eq)]
1532pub struct DistributionLocation {
1533 pub source: DistributionSource,
1535 pub original_url: Option<String>,
1537 pub archive_filename: String,
1539 pub compression: ArchiveCompression,
1541}
1542
1543#[derive(Debug, Clone, PartialEq, Eq)]
1545pub struct ProductRequest {
1546 pub identity: ProductIdentity,
1548 pub distributors: Vec<DistributionSource>,
1550}
1551
1552impl ProductRequest {
1553 pub fn new(
1555 identity: ProductIdentity,
1556 distributors: Vec<DistributionSource>,
1557 ) -> Result<Self, DataCatalogError> {
1558 if distributors.is_empty() {
1559 return Err(DataCatalogError::NoDistributionSources);
1560 }
1561 identity.validate()?;
1562 Ok(Self {
1563 identity,
1564 distributors,
1565 })
1566 }
1567}
1568
1569#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct ProductSpec {
1572 pub center: AnalysisCenter,
1574 pub product_type: ProductType,
1576 pub date: ProductDate,
1578 pub sample: String,
1580 pub issue: Option<String>,
1582}
1583
1584impl ProductSpec {
1585 pub fn new(
1587 center: AnalysisCenter,
1588 product_type: ProductType,
1589 date: ProductDate,
1590 sample: &str,
1591 issue: Option<&str>,
1592 ) -> Result<Self, DataCatalogError> {
1593 validate_product(center, product_type, sample, issue)?;
1594 Ok(Self {
1595 center,
1596 product_type,
1597 date,
1598 sample: sample.to_string(),
1599 issue: issue.map(ToOwned::to_owned),
1600 })
1601 }
1602
1603 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1605 self.date.gps_week()
1606 }
1607
1608 #[must_use]
1610 pub fn day_of_year(&self) -> u16 {
1611 self.date.day_of_year()
1612 }
1613
1614 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1616 let convention = validate_product(
1617 self.center,
1618 self.product_type,
1619 &self.sample,
1620 self.issue.as_deref(),
1621 )?;
1622 let descriptor = product_type_convention(self.product_type);
1623 Ok(match descriptor.kind {
1624 ProductFilenameKind::Sampled => format!(
1625 "{}_{}_{}_{}_{}.{}",
1626 convention.token,
1627 date_block(self.date, self.issue.as_deref()),
1628 convention.span,
1629 self.sample,
1630 descriptor.content_code,
1631 descriptor.extension
1632 ),
1633 ProductFilenameKind::Nav => format!(
1634 "{}_R_{}_{}_{}.{}",
1635 convention.token,
1636 date_block(self.date, None),
1637 convention.span,
1638 descriptor.content_code,
1639 descriptor.extension
1640 ),
1641 })
1642 }
1643
1644 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1646 let convention = validate_product(
1647 self.center,
1648 self.product_type,
1649 &self.sample,
1650 self.issue.as_deref(),
1651 )?;
1652 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1653 let filename = self.canonical_filename()?;
1654 Ok(format!(
1655 "{}/{}/{}{}",
1656 entry.root_url,
1657 product_dir_path(self.center, convention.layout, self.date)?,
1658 filename,
1659 convention.compression.suffix()
1660 ))
1661 }
1662
1663 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
1665 let convention = validate_product(
1666 self.center,
1667 self.product_type,
1668 &self.sample,
1669 self.issue.as_deref(),
1670 )?;
1671 let descriptor = product_type_convention(self.product_type);
1672 let campaign = match descriptor.kind {
1673 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
1674 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
1675 Some("OPS") => ProductCampaign::Operational,
1676 Some("MGN") => ProductCampaign::MultiGnss,
1677 Some("MGX") => ProductCampaign::MultiGnssExperiment,
1678 _ => {
1679 return Err(DataCatalogError::InconsistentProductIdentity {
1680 field: "campaign",
1681 });
1682 }
1683 },
1684 };
1685 let identity = ProductIdentity {
1686 family: self.product_type,
1687 publisher: self.center.publisher(),
1688 solution: self.center.solution_class(),
1689 campaign,
1690 version: 0,
1691 date: self.date,
1692 issue: self.issue.clone(),
1693 span: convention.span.to_string(),
1694 sample: self.sample.clone(),
1695 official_filename: self.canonical_filename()?,
1696 format: product_format(self.product_type),
1697 prediction_horizon_days: self.center.prediction_horizon_days(),
1698 };
1699 identity.validate()?;
1700 Ok(identity)
1701 }
1702
1703 pub fn distribution_location(
1705 &self,
1706 source: DistributionSource,
1707 ) -> Result<DistributionLocation, DataCatalogError> {
1708 let identity = self.identity()?;
1709 match source {
1710 DistributionSource::Direct => {
1711 let compression = product_convention(self.center, self.product_type)?.compression;
1712 Ok(DistributionLocation {
1713 source,
1714 original_url: Some(self.archive_url()?),
1715 archive_filename: format!(
1716 "{}{}",
1717 identity.official_filename,
1718 compression.suffix()
1719 ),
1720 compression,
1721 })
1722 }
1723 DistributionSource::NasaCddis => {
1724 let url = cddis_archive_url(&identity)?;
1725 Ok(DistributionLocation {
1726 source,
1727 original_url: Some(url),
1728 archive_filename: format!("{}.gz", identity.official_filename),
1729 compression: ArchiveCompression::Gzip,
1730 })
1731 }
1732 DistributionSource::LocalFile | DistributionSource::InMemory => {
1733 Ok(DistributionLocation {
1734 source,
1735 original_url: None,
1736 archive_filename: identity.official_filename,
1737 compression: ArchiveCompression::None,
1738 })
1739 }
1740 }
1741 }
1742}
1743
1744#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct StationObservationSpec {
1747 pub station: String,
1749 pub date: ProductDate,
1751 pub sample: String,
1753}
1754
1755impl StationObservationSpec {
1756 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1758 validate_station(station)?;
1759 validate_sample(sample)?;
1760 Ok(Self {
1761 station: station.to_string(),
1762 date,
1763 sample: sample.to_string(),
1764 })
1765 }
1766
1767 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1769 station_obs_filename(&self.station, self.date, &self.sample)
1770 }
1771
1772 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1774 station_obs_url(&self.station, self.date, &self.sample)
1775 }
1776}
1777
1778#[must_use]
1780pub const fn catalog() -> &'static [CenterCatalogEntry] {
1781 &CATALOG
1782}
1783
1784#[must_use]
1786pub const fn centers() -> &'static [AnalysisCenter] {
1787 &CENTER_ORDER
1788}
1789
1790#[must_use]
1792pub const fn product_types() -> &'static [ProductTypeConvention] {
1793 &PRODUCT_TYPE_CONVENTIONS
1794}
1795
1796#[must_use]
1798pub const fn allowed_hosts() -> &'static [&'static str] {
1799 &ALLOWED_HOSTS
1800}
1801
1802#[must_use]
1804pub const fn skadi_source_entry() -> TerrainSourceEntry {
1805 SKADI_SOURCE
1806}
1807
1808#[must_use]
1810pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1811 CELESTRAK_SPACE_WEATHER_SOURCE
1812}
1813
1814#[must_use]
1816pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1817 match product {
1818 SpaceWeatherProduct::All => "SW-All.csv",
1819 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1820 }
1821}
1822
1823#[must_use]
1825pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1826 format!(
1827 "{}/{}",
1828 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1829 space_weather_filename(product)
1830 )
1831}
1832
1833#[must_use]
1835pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1836 format!("space-weather/{}", space_weather_filename(product))
1837}
1838
1839pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1841 validate_terrain_tile_index(lat_index, lon_index)?;
1842 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1843 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1844 Ok(format!(
1845 "{lat_hemi}{:02}{lon_hemi}{:03}",
1846 lat_index.abs(),
1847 lon_index.abs()
1848 ))
1849}
1850
1851pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1853 validate_terrain_lat_index(lat_index)?;
1854 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1855 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1856}
1857
1858pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1860 let band = skadi_band(lat_index)?;
1861 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1862 Ok(format!(
1863 "{}/skadi/{}/{}.hgt{}",
1864 SKADI_SOURCE.root_url,
1865 band,
1866 tile_id,
1867 SKADI_SOURCE.compression.suffix()
1868 ))
1869}
1870
1871pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1873 validate_terrain_tile_index(lat_index, lon_index)?;
1874 Ok(format!(
1875 "{}_{}{}",
1876 terrain::format_lat(lat_index),
1877 terrain::format_lon(lon_index),
1878 terrain::DTED_SUFFIX
1879 ))
1880}
1881
1882pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1884 validate_terrain_tile_index(lat_index, lon_index)?;
1885 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1886}
1887
1888pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1890 Ok(format!(
1891 "{}/{}",
1892 dted_block_dir(lat_index, lon_index)?,
1893 dted_tile_filename(lat_index, lon_index)?
1894 ))
1895}
1896
1897pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1899 let bytes = id.as_bytes();
1900 if bytes.len() != 7
1901 || !matches!(bytes[0], b'N' | b'S')
1902 || !matches!(bytes[3], b'E' | b'W')
1903 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1904 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1905 {
1906 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1907 }
1908
1909 let lat_abs = id[1..3]
1910 .parse::<i32>()
1911 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1912 let lon_abs = id[4..7]
1913 .parse::<i32>()
1914 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1915 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1916 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1917 }
1918
1919 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1920 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1921 validate_terrain_tile_index(lat_index, lon_index)?;
1922 Ok((lat_index, lon_index))
1923}
1924
1925pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1927 if !lat_deg.is_finite()
1928 || !lon_deg.is_finite()
1929 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1930 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1931 {
1932 return Err(DataCatalogError::InvalidCoordinate {
1933 lat_deg_bits: lat_deg.to_bits(),
1934 lon_deg_bits: lon_deg.to_bits(),
1935 });
1936 }
1937
1938 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1939 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1940 lat_index = MAX_TERRAIN_LAT_INDEX;
1941 }
1942 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1943 lon_index = MAX_TERRAIN_LON_INDEX;
1944 }
1945 validate_terrain_tile_index(lat_index, lon_index)?;
1946 Ok((lat_index, lon_index))
1947}
1948
1949pub fn hgt_to_dted(
1957 lat_index: i32,
1958 lon_index: i32,
1959 hgt: &[u8],
1960) -> Result<Vec<u8>, HgtConversionError> {
1961 validate_hgt_tile_index(lat_index, lon_index)?;
1962 if hgt.len() != SRTM1_HGT_LEN {
1963 return Err(HgtConversionError::BadLength {
1964 expected: SRTM1_HGT_LEN,
1965 got: hgt.len(),
1966 });
1967 }
1968
1969 let mut out = vec![b' '; DTED_SRTM1_LEN];
1970 out[0..4].copy_from_slice(b"UHL1");
1971 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1972 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1973 out[47..51].copy_from_slice(b"3601");
1974 out[51..55].copy_from_slice(b"3601");
1975
1976 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1977 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1978 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1979 out[block_start] = terrain::DATA_SENTINEL;
1980
1981 let count = (lon_posting as u32).to_be_bytes();
1982 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1983 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1984 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1985
1986 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1987 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1988 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1989 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1990 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1991 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1992 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1993 }
1994
1995 let checksum = out[block_start..checksum_start]
1996 .iter()
1997 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1998 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1999 }
2000
2001 debug_assert_eq!(out.len(), 25_981_042);
2002 Ok(out)
2003}
2004
2005#[must_use]
2007pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2008 &NO_OPEN_MIRRORS
2009}
2010
2011pub fn open_mirror(
2013 center: AnalysisCenter,
2014 product_type: ProductType,
2015) -> Result<(), DataCatalogError> {
2016 open_mirror_code(center.code(), product_type.code())
2017}
2018
2019pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2021 if NO_OPEN_MIRRORS
2022 .iter()
2023 .any(|entry| entry.center == center && entry.product_type == product_type)
2024 {
2025 Err(DataCatalogError::NoOpenMirror {
2026 center: center.to_string(),
2027 product_type: product_type.to_string(),
2028 })
2029 } else {
2030 Ok(())
2031 }
2032}
2033
2034#[must_use]
2036pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2037 CATALOG.iter().find(|entry| entry.center == center)
2038}
2039
2040pub fn product_convention(
2042 center: AnalysisCenter,
2043 product_type: ProductType,
2044) -> Result<&'static CenterProductConvention, DataCatalogError> {
2045 open_mirror(center, product_type)?;
2046 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2047 entry
2048 .products
2049 .iter()
2050 .find(|product| product.product_type == product_type)
2051 .ok_or(DataCatalogError::UnsupportedProduct {
2052 center,
2053 product_type,
2054 })
2055}
2056
2057pub fn default_sample(
2059 center: AnalysisCenter,
2060 product_type: ProductType,
2061) -> Result<&'static str, DataCatalogError> {
2062 Ok(product_convention(center, product_type)?.default_sample)
2063}
2064
2065pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2067 date.gps_week()
2068}
2069
2070#[must_use]
2072pub fn day_of_year(date: ProductDate) -> u16 {
2073 date.day_of_year()
2074}
2075
2076pub fn product(
2078 center: AnalysisCenter,
2079 product_type: ProductType,
2080 date: ProductDate,
2081 sample: Option<&str>,
2082 issue: Option<&str>,
2083) -> Result<ProductSpec, DataCatalogError> {
2084 let sample = match sample {
2085 Some(sample) => sample,
2086 None => default_sample(center, product_type)?,
2087 };
2088 ProductSpec::new(center, product_type, date, sample, issue)
2089}
2090
2091pub fn canonical_filename(
2093 center: AnalysisCenter,
2094 product_type: ProductType,
2095 date: ProductDate,
2096 sample: Option<&str>,
2097 issue: Option<&str>,
2098) -> Result<String, DataCatalogError> {
2099 product(center, product_type, date, sample, issue)?.canonical_filename()
2100}
2101
2102pub fn archive_url(
2104 center: AnalysisCenter,
2105 product_type: ProductType,
2106 date: ProductDate,
2107 sample: Option<&str>,
2108 issue: Option<&str>,
2109) -> Result<String, DataCatalogError> {
2110 product(center, product_type, date, sample, issue)?.archive_url()
2111}
2112
2113pub fn product_identity(
2115 center: AnalysisCenter,
2116 product_type: ProductType,
2117 date: ProductDate,
2118 sample: Option<&str>,
2119 issue: Option<&str>,
2120) -> Result<ProductIdentity, DataCatalogError> {
2121 product(center, product_type, date, sample, issue)?.identity()
2122}
2123
2124pub fn distribution_location(
2126 center: AnalysisCenter,
2127 product_type: ProductType,
2128 date: ProductDate,
2129 sample: Option<&str>,
2130 issue: Option<&str>,
2131 source: DistributionSource,
2132) -> Result<DistributionLocation, DataCatalogError> {
2133 product(center, product_type, date, sample, issue)?.distribution_location(source)
2134}
2135
2136pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2141 identity.validate()?;
2142 match identity.family {
2143 ProductType::Sp3 => Ok(format!(
2144 "https://cddis.nasa.gov/archive/gnss/products/{}/{}.gz",
2145 identity.date.gps_week()?,
2146 identity.official_filename
2147 )),
2148 ProductType::Ionex => Ok(format!(
2149 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2150 identity.date.year,
2151 identity.date.day_of_year(),
2152 identity.official_filename
2153 )),
2154 product_type => Err(DataCatalogError::UnsupportedDistribution {
2155 source: DistributionSource::NasaCddis,
2156 product_type,
2157 }),
2158 }
2159}
2160
2161pub fn mgex_clk(
2163 center: AnalysisCenter,
2164 date: ProductDate,
2165 sample: Option<&str>,
2166) -> Result<ProductSpec, DataCatalogError> {
2167 product(center, ProductType::Clk, date, sample, None)
2168}
2169
2170pub fn mgex_nav(
2172 center: AnalysisCenter,
2173 date: ProductDate,
2174 sample: Option<&str>,
2175) -> Result<ProductSpec, DataCatalogError> {
2176 product(center, ProductType::Nav, date, sample, None)
2177}
2178
2179pub fn mgex_ionex(
2181 center: AnalysisCenter,
2182 date: ProductDate,
2183 sample: Option<&str>,
2184) -> Result<ProductSpec, DataCatalogError> {
2185 product(center, ProductType::Ionex, date, sample, None)
2186}
2187
2188pub fn rapid_ionex(
2190 date: ProductDate,
2191 sample: Option<&str>,
2192) -> Result<ProductSpec, DataCatalogError> {
2193 product(
2194 AnalysisCenter::CodRap,
2195 ProductType::Ionex,
2196 date,
2197 sample,
2198 None,
2199 )
2200}
2201
2202#[must_use]
2204pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2205 match center {
2206 AnalysisCenter::CodPrd2 => 1,
2207 _ => 0,
2208 }
2209}
2210
2211pub fn predicted_ionex(
2213 center: AnalysisCenter,
2214 date: ProductDate,
2215 sample: Option<&str>,
2216) -> Result<ProductSpec, DataCatalogError> {
2217 match center {
2218 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2219 let target = date.add_days(predicted_day_offset(center))?;
2220 product(center, ProductType::Ionex, target, sample, None)
2221 }
2222 other => Err(DataCatalogError::UnsupportedProduct {
2223 center: other,
2224 product_type: ProductType::Ionex,
2225 }),
2226 }
2227}
2228
2229pub fn mgex_sp3(
2231 center: AnalysisCenter,
2232 date: ProductDate,
2233 sample: Option<&str>,
2234) -> Result<ProductSpec, DataCatalogError> {
2235 product(center, ProductType::Sp3, date, sample, None)
2236}
2237
2238pub fn ops_ultra_sp3(
2240 center: AnalysisCenter,
2241 date: ProductDate,
2242 sample: Option<&str>,
2243 issue: Option<&str>,
2244) -> Result<ProductSpec, DataCatalogError> {
2245 let issue = issue.unwrap_or("0000");
2246 product(center, ProductType::Sp3, date, sample, Some(issue))
2247}
2248
2249pub fn ultra_sp3_locations(
2256 center: AnalysisCenter,
2257 date: ProductDate,
2258 issue: &str,
2259) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2260 validate_issue_for_center(center, Some(issue))?;
2261 let patterns: &[UltraSp3Pattern] = match center {
2262 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2263 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2264 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2265 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2266 other => {
2267 return Err(DataCatalogError::UnsupportedProduct {
2268 center: other,
2269 product_type: ProductType::Sp3,
2270 })
2271 }
2272 };
2273 let convention = product_convention(center, ProductType::Sp3)?;
2274 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2275 let directory = dir_path(convention.layout, date)?;
2276 let date = date_block(date, Some(issue));
2277
2278 Ok(patterns
2279 .iter()
2280 .map(|pattern| {
2281 let filename = pattern.alias_filename.map_or_else(
2282 || {
2283 format!(
2284 "{}_{}_{}_{}_ORB.SP3",
2285 convention.token, date, pattern.span, pattern.sample
2286 )
2287 },
2288 ToOwned::to_owned,
2289 );
2290 UltraSp3Location {
2291 pattern: pattern.label.to_string(),
2292 span: pattern.span.to_string(),
2293 sample: pattern.sample.to_string(),
2294 url: format!(
2295 "{}/{}/{}{}",
2296 entry.root_url,
2297 directory,
2298 filename,
2299 convention.compression.suffix()
2300 ),
2301 filename,
2302 compression: convention.compression,
2303 }
2304 })
2305 .collect())
2306}
2307
2308pub fn ops_ultra_clk(
2310 center: AnalysisCenter,
2311 date: ProductDate,
2312 sample: Option<&str>,
2313 issue: Option<&str>,
2314) -> Result<ProductSpec, DataCatalogError> {
2315 let issue = issue.unwrap_or("0000");
2316 product(center, ProductType::Clk, date, sample, Some(issue))
2317}
2318
2319pub fn latest_ops_ultra_sp3(
2321 center: AnalysisCenter,
2322 target: ProductDateTime,
2323 sample: Option<&str>,
2324 available_issues: Option<&[UltraIssue]>,
2325) -> Result<ProductSpec, DataCatalogError> {
2326 let selected = latest_ultra_issue(center, target, available_issues)?;
2327 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2328}
2329
2330pub fn ultra_issue_candidates(
2332 center: AnalysisCenter,
2333 target: ProductDateTime,
2334) -> Result<Vec<UltraIssue>, DataCatalogError> {
2335 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2336 let _ = product_convention(center, ProductType::Sp3)?;
2337 if entry.issues.is_empty() {
2338 return Err(DataCatalogError::UnsupportedProduct {
2339 center,
2340 product_type: ProductType::Sp3,
2341 });
2342 }
2343
2344 let mut candidates = Vec::new();
2345 for date in [target.date, target.date.add_days(-1)?] {
2346 for issue in entry.issues.iter().rev() {
2347 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2348 candidates.push(UltraIssue::new(date, issue)?);
2349 }
2350 }
2351 }
2352 Ok(candidates)
2353}
2354
2355pub fn latest_ultra_issue(
2357 center: AnalysisCenter,
2358 target: ProductDateTime,
2359 available_issues: Option<&[UltraIssue]>,
2360) -> Result<UltraIssue, DataCatalogError> {
2361 let candidates = ultra_issue_candidates(center, target)?;
2362 if candidates.is_empty() {
2363 return Err(DataCatalogError::NoUltraIssue);
2364 }
2365 if let Some(available) = available_issues {
2366 candidates
2367 .into_iter()
2368 .find(|candidate| {
2369 available
2370 .iter()
2371 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2372 })
2373 .ok_or(DataCatalogError::NoAvailableUltraIssue)
2374 } else {
2375 Ok(candidates[0].clone())
2376 }
2377}
2378
2379pub fn gim_date_candidates(
2381 center: AnalysisCenter,
2382 target: ProductDate,
2383 lookback: u32,
2384) -> Result<Vec<ProductDate>, DataCatalogError> {
2385 let _ = product_convention(center, ProductType::Ionex)?;
2386 let base = target.add_days(predicted_day_offset(center))?;
2387 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2388 for back in 0..=lookback {
2389 out.push(base.add_days(-i64::from(back))?);
2390 }
2391 Ok(out)
2392}
2393
2394pub fn station_obs(
2396 station: &str,
2397 date: ProductDate,
2398 sample: Option<&str>,
2399) -> Result<StationObservationSpec, DataCatalogError> {
2400 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2401}
2402
2403pub fn station_obs_filename(
2405 station: &str,
2406 date: ProductDate,
2407 sample: &str,
2408) -> Result<String, DataCatalogError> {
2409 validate_station(station)?;
2410 validate_sample(sample)?;
2411 Ok(format!(
2412 "{}_R_{}_01D_{}_MO.crx",
2413 station,
2414 date_block(date, None),
2415 sample
2416 ))
2417}
2418
2419pub fn station_obs_url(
2421 station: &str,
2422 date: ProductDate,
2423 sample: &str,
2424) -> Result<String, DataCatalogError> {
2425 let filename = station_obs_filename(station, date, sample)?;
2426 Ok(format!(
2427 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
2428 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
2429 filename
2430 ))
2431}
2432
2433#[must_use]
2435pub const fn station_obs_protocol() -> ArchiveProtocol {
2436 ArchiveProtocol::Https
2437}
2438
2439fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
2440 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
2441 Ok(())
2442 } else {
2443 Err(DataCatalogError::InvalidTileIndex {
2444 lat_index,
2445 lon_index: 0,
2446 })
2447 }
2448}
2449
2450fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
2451 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2452 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2453 {
2454 Ok(())
2455 } else {
2456 Err(DataCatalogError::InvalidTileIndex {
2457 lat_index,
2458 lon_index,
2459 })
2460 }
2461}
2462
2463fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
2464 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2465 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2466 {
2467 Ok(())
2468 } else {
2469 Err(HgtConversionError::InvalidTileIndex {
2470 lat_index,
2471 lon_index,
2472 })
2473 }
2474}
2475
2476fn dted_coord_field(index: i32, is_longitude: bool) -> String {
2477 let hemi = match (is_longitude, index >= 0) {
2478 (true, true) => 'E',
2479 (true, false) => 'W',
2480 (false, true) => 'N',
2481 (false, false) => 'S',
2482 };
2483 format!("{:03}0000{hemi}", index.abs())
2484}
2485
2486fn encode_dted_signed_magnitude(sample: i16) -> u16 {
2487 if sample == i16::MIN {
2488 0
2489 } else if sample >= 0 {
2490 sample as u16
2491 } else {
2492 0x8000 | (-i32::from(sample) as u16)
2493 }
2494}
2495
2496fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
2497 PRODUCT_TYPE_CONVENTIONS
2498 .iter()
2499 .find(|descriptor| descriptor.product_type == product_type)
2500 .expect("product descriptor exists for enum variant")
2501}
2502
2503const fn product_format(product_type: ProductType) -> ProductFormat {
2504 match product_type {
2505 ProductType::Sp3 => ProductFormat::Sp3,
2506 ProductType::Ionex => ProductFormat::Ionex,
2507 ProductType::Clk => ProductFormat::RinexClock,
2508 ProductType::Nav => ProductFormat::RinexNavigation,
2509 }
2510}
2511
2512fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
2513 if filename.is_empty()
2514 || filename == "."
2515 || filename == ".."
2516 || filename.contains('/')
2517 || filename.contains('\\')
2518 || filename.contains('\0')
2519 || filename.contains("..")
2520 {
2521 Err(DataCatalogError::InvalidOfficialFilename(
2522 filename.to_string(),
2523 ))
2524 } else {
2525 Ok(())
2526 }
2527}
2528
2529fn validate_product(
2530 center: AnalysisCenter,
2531 product_type: ProductType,
2532 sample: &str,
2533 issue: Option<&str>,
2534) -> Result<&'static CenterProductConvention, DataCatalogError> {
2535 let convention = product_convention(center, product_type)?;
2536 validate_sample(sample)?;
2537 validate_issue_for_center(center, issue)?;
2538 Ok(convention)
2539}
2540
2541fn validate_issue_for_center(
2542 center: AnalysisCenter,
2543 issue: Option<&str>,
2544) -> Result<(), DataCatalogError> {
2545 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2546 match (entry.issues.is_empty(), issue) {
2547 (true, None) => Ok(()),
2548 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
2549 (false, None) => Err(DataCatalogError::MissingIssue { center }),
2550 (false, Some(issue)) => {
2551 validate_issue(issue)?;
2552 if entry.issues.contains(&issue) {
2553 Ok(())
2554 } else {
2555 Err(DataCatalogError::UnsupportedIssue {
2556 center,
2557 issue: issue.to_string(),
2558 })
2559 }
2560 }
2561 }
2562}
2563
2564fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
2565 let bytes = sample.as_bytes();
2566 let valid = bytes.len() == 3
2567 && bytes[0].is_ascii_digit()
2568 && bytes[1].is_ascii_digit()
2569 && bytes[2].is_ascii_uppercase();
2570 if valid {
2571 Ok(())
2572 } else {
2573 Err(DataCatalogError::InvalidSample(sample.to_string()))
2574 }
2575}
2576
2577fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2578 let bytes = issue.as_bytes();
2579 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2580 if !valid_digits {
2581 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2582 }
2583 let hour = issue[0..2]
2584 .parse::<u8>()
2585 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2586 let minute = issue[2..4]
2587 .parse::<u8>()
2588 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2589 if hour <= 23 && minute <= 59 {
2590 Ok(())
2591 } else {
2592 Err(DataCatalogError::InvalidIssue(issue.to_string()))
2593 }
2594}
2595
2596fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2597 let bytes = station.as_bytes();
2598 let valid = bytes.len() == 9
2599 && bytes
2600 .iter()
2601 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2602 if valid {
2603 Ok(())
2604 } else {
2605 Err(DataCatalogError::InvalidStation(station.to_string()))
2606 }
2607}
2608
2609fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2610 validate_issue(issue)?;
2611 let hour = issue[0..2]
2612 .parse::<u16>()
2613 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2614 let minute = issue[2..4]
2615 .parse::<u16>()
2616 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2617 Ok(hour * 60 + minute)
2618}
2619
2620fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2621 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2622}
2623
2624fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2625 format!(
2626 "{}{:03}{}",
2627 date.year,
2628 date.day_of_year(),
2629 issue.unwrap_or("0000")
2630 )
2631}
2632
2633fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2634 Ok(match layout {
2635 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2636 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2637 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2638 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2639 ArchiveLayout::BkgBrdcYearDoy => {
2640 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2641 }
2642 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2643 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2644 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2645 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2646 })
2647}
2648
2649fn product_dir_path(
2650 center: AnalysisCenter,
2651 layout: ArchiveLayout,
2652 date: ProductDate,
2653) -> Result<String, DataCatalogError> {
2654 match center {
2655 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
2656 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
2657 _ => dir_path(layout, date),
2658 }
2659}
2660
2661fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2662 let (year, month, day) = civil_from_julian_day_number(jdn);
2663 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2664 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2665 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2666 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2667}