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::Http,
752 host: "ftp.aiub.unibe.ch",
753 root_url: "http://ftp.aiub.unibe.ch",
754 products: &COD_PRD_PRODUCTS,
755 issues: &[],
756 },
757 CenterCatalogEntry {
758 center: AnalysisCenter::CodPrd2,
759 code: "cod_prd2",
760 protocol: ArchiveProtocol::Http,
761 host: "ftp.aiub.unibe.ch",
762 root_url: "http://ftp.aiub.unibe.ch",
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; 9] = [
858 "ftp.aiub.unibe.ch",
859 "www.aiub.unibe.ch",
860 "navigation-office.esa.int",
861 "isdc-data.gfz.de",
862 "igs.bkg.bund.de",
863 "s3.amazonaws.com",
864 "celestrak.org",
865 "cddis.nasa.gov",
866 "urs.earthdata.nasa.gov",
867];
868
869const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
870 NoOpenMirrorProduct {
871 center: "grg",
872 product_type: "sp3",
873 },
874 NoOpenMirrorProduct {
875 center: "grg",
876 product_type: "clk",
877 },
878 NoOpenMirrorProduct {
879 center: "wum",
880 product_type: "sp3",
881 },
882 NoOpenMirrorProduct {
883 center: "wum",
884 product_type: "clk",
885 },
886 NoOpenMirrorProduct {
887 center: "grg_ult",
888 product_type: "sp3",
889 },
890 NoOpenMirrorProduct {
891 center: "grg_ult",
892 product_type: "clk",
893 },
894 NoOpenMirrorProduct {
895 center: "igs",
896 product_type: "ionex",
897 },
898];
899
900#[derive(Debug, Clone, PartialEq, Eq)]
902pub enum DataCatalogError {
903 UnknownCenter(String),
905 UnknownProductType(String),
907 UnsupportedProduct {
909 center: AnalysisCenter,
911 product_type: ProductType,
913 },
914 UnsupportedDistribution {
916 source: DistributionSource,
918 product_type: ProductType,
920 },
921 NoDistributionSources,
923 InvalidOfficialFilename(String),
925 InconsistentProductIdentity {
927 field: &'static str,
929 },
930 NoOpenMirror {
932 center: String,
934 product_type: String,
936 },
937 InvalidDate {
939 year: i32,
941 month: u8,
943 day: u8,
945 },
946 DateOutOfRange,
948 DateBeforeGpsEpoch(ProductDate),
950 InvalidGpsDayOfWeek(u8),
952 InvalidSample(String),
954 InvalidIssue(String),
956 MissingIssue {
958 center: AnalysisCenter,
960 },
961 UnexpectedIssue {
963 center: AnalysisCenter,
965 },
966 UnsupportedIssue {
968 center: AnalysisCenter,
970 issue: String,
972 },
973 InvalidDateTime {
975 hour: u8,
977 minute: u8,
979 second: u8,
981 },
982 NoUltraIssue,
984 NoAvailableUltraIssue,
986 InvalidStation(String),
988 InvalidCoordinate {
990 lat_deg_bits: u64,
992 lon_deg_bits: u64,
994 },
995 InvalidTileIndex {
997 lat_index: i32,
999 lon_index: i32,
1001 },
1002 InvalidTileId(String),
1004}
1005
1006impl fmt::Display for DataCatalogError {
1007 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1008 match self {
1009 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1010 Self::UnknownProductType(product_type) => {
1011 write!(f, "unknown product type {product_type:?}")
1012 }
1013 Self::UnsupportedProduct {
1014 center,
1015 product_type,
1016 } => write!(f, "{center} does not serve {product_type}"),
1017 Self::UnsupportedDistribution {
1018 source,
1019 product_type,
1020 } => write!(
1021 f,
1022 "distributor {} does not serve {product_type}",
1023 source.code()
1024 ),
1025 Self::NoDistributionSources => {
1026 write!(f, "exact product request has no distributors")
1027 }
1028 Self::InvalidOfficialFilename(filename) => {
1029 write!(f, "invalid official product filename {filename:?}")
1030 }
1031 Self::InconsistentProductIdentity { field } => {
1032 write!(
1033 f,
1034 "product identity field {field:?} disagrees with its official filename"
1035 )
1036 }
1037 Self::NoOpenMirror {
1038 center,
1039 product_type,
1040 } => write!(f, "{center}/{product_type} has no open mirror"),
1041 Self::InvalidDate { year, month, day } => {
1042 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1043 }
1044 Self::DateOutOfRange => write!(f, "product date is out of range"),
1045 Self::DateBeforeGpsEpoch(date) => {
1046 write!(f, "product date {date} is before the GPS week epoch")
1047 }
1048 Self::InvalidGpsDayOfWeek(day) => {
1049 write!(f, "invalid GPS day-of-week {day}")
1050 }
1051 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1052 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1053 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1054 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1055 Self::UnsupportedIssue { center, issue } => {
1056 write!(f, "{center} does not publish issue {issue:?}")
1057 }
1058 Self::InvalidDateTime {
1059 hour,
1060 minute,
1061 second,
1062 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1063 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1064 Self::NoAvailableUltraIssue => {
1065 write!(f, "no available ultra-rapid issue at or before target")
1066 }
1067 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1068 Self::InvalidCoordinate {
1069 lat_deg_bits,
1070 lon_deg_bits,
1071 } => write!(
1072 f,
1073 "invalid terrain coordinate lat={} lon={}",
1074 f64::from_bits(*lat_deg_bits),
1075 f64::from_bits(*lon_deg_bits)
1076 ),
1077 Self::InvalidTileIndex {
1078 lat_index,
1079 lon_index,
1080 } => write!(
1081 f,
1082 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1083 ),
1084 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1085 }
1086 }
1087}
1088
1089impl std::error::Error for DataCatalogError {}
1090
1091#[derive(Debug, Clone, PartialEq, Eq)]
1093pub enum HgtConversionError {
1094 BadLength {
1096 expected: usize,
1098 got: usize,
1100 },
1101 InvalidTileIndex {
1103 lat_index: i32,
1105 lon_index: i32,
1107 },
1108}
1109
1110impl fmt::Display for HgtConversionError {
1111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1112 match self {
1113 Self::BadLength { expected, got } => {
1114 write!(
1115 f,
1116 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1117 )
1118 }
1119 Self::InvalidTileIndex {
1120 lat_index,
1121 lon_index,
1122 } => write!(
1123 f,
1124 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1125 ),
1126 }
1127 }
1128}
1129
1130impl std::error::Error for HgtConversionError {}
1131
1132const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1133const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1134const MIN_TERRAIN_LON_INDEX: i32 = -180;
1135const MAX_TERRAIN_LON_INDEX: i32 = 179;
1136const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1137const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1138const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1139const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1140const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1141const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1142const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1143const DTED_SRTM1_LEN: usize =
1144 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1145
1146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1148pub struct ProductDate {
1149 pub year: i32,
1151 pub month: u8,
1153 pub day: u8,
1155}
1156
1157impl ProductDate {
1158 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1160 let days = days_in_month(i64::from(year), i64::from(month));
1161 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1162 return Err(DataCatalogError::InvalidDate { year, month, day });
1163 }
1164 Ok(Self { year, month, day })
1165 }
1166
1167 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1169 if day_of_week > 6 {
1170 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1171 }
1172 let epoch_jdn =
1173 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1174 let offset_days = i64::from(week)
1175 .checked_mul(7)
1176 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1177 .ok_or(DataCatalogError::DateOutOfRange)?;
1178 product_date_from_jdn(
1179 epoch_jdn
1180 .checked_add(offset_days)
1181 .ok_or(DataCatalogError::DateOutOfRange)?,
1182 )
1183 }
1184
1185 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1187 week_from_calendar(
1188 TimeScale::Gpst,
1189 i64::from(self.year),
1190 i64::from(self.month),
1191 i64::from(self.day),
1192 )
1193 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1194 }
1195
1196 #[must_use]
1198 pub fn day_of_year(self) -> u16 {
1199 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1200 }
1201
1202 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1203 product_date_from_jdn(
1204 self.julian_day_number()
1205 .checked_add(days)
1206 .ok_or(DataCatalogError::DateOutOfRange)?,
1207 )
1208 }
1209
1210 fn julian_day_number(self) -> i64 {
1211 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1212 }
1213}
1214
1215impl fmt::Display for ProductDate {
1216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1217 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1218 }
1219}
1220
1221#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1223pub struct ProductDateTime {
1224 pub date: ProductDate,
1226 pub hour: u8,
1228 pub minute: u8,
1230 pub second: u8,
1232}
1233
1234impl ProductDateTime {
1235 pub fn new(
1237 date: ProductDate,
1238 hour: u8,
1239 minute: u8,
1240 second: u8,
1241 ) -> Result<Self, DataCatalogError> {
1242 if hour > 23 || minute > 59 || second > 59 {
1243 return Err(DataCatalogError::InvalidDateTime {
1244 hour,
1245 minute,
1246 second,
1247 });
1248 }
1249 Ok(Self {
1250 date,
1251 hour,
1252 minute,
1253 second,
1254 })
1255 }
1256
1257 fn ordering_minutes(self) -> i64 {
1258 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1259 }
1260}
1261
1262#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1264pub struct UltraIssue {
1265 pub date: ProductDate,
1267 pub issue: String,
1269}
1270
1271impl UltraIssue {
1272 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1274 validate_issue(issue)?;
1275 Ok(Self {
1276 date,
1277 issue: issue.to_string(),
1278 })
1279 }
1280}
1281
1282#[derive(Debug, Clone, PartialEq, Eq)]
1284pub struct UltraSp3Location {
1285 pub pattern: String,
1287 pub span: String,
1289 pub sample: String,
1291 pub filename: String,
1293 pub url: String,
1295 pub compression: ArchiveCompression,
1297}
1298
1299#[derive(Debug, Clone, Copy)]
1300struct UltraSp3Pattern {
1301 label: &'static str,
1302 span: &'static str,
1303 sample: &'static str,
1304 alias_filename: Option<&'static str>,
1305}
1306
1307const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1308 UltraSp3Pattern {
1309 label: "primary_02D_15M",
1310 span: "02D",
1311 sample: "15M",
1312 alias_filename: None,
1313 },
1314 UltraSp3Pattern {
1315 label: "alternate_02D_05M",
1316 span: "02D",
1317 sample: "05M",
1318 alias_filename: None,
1319 },
1320 UltraSp3Pattern {
1321 label: "alternate_01D_15M",
1322 span: "01D",
1323 sample: "15M",
1324 alias_filename: None,
1325 },
1326];
1327
1328const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1329 UltraSp3Pattern {
1330 label: "primary_01D_05M",
1331 span: "01D",
1332 sample: "05M",
1333 alias_filename: None,
1334 },
1335 UltraSp3Pattern {
1336 label: "alternate_02D_05M",
1337 span: "02D",
1338 sample: "05M",
1339 alias_filename: None,
1340 },
1341 UltraSp3Pattern {
1342 label: "alias_latest",
1343 span: "01D",
1344 sample: "05M",
1345 alias_filename: Some("COD0OPSULT.SP3"),
1346 },
1347];
1348
1349const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1350 UltraSp3Pattern {
1351 label: "primary_02D_05M",
1352 span: "02D",
1353 sample: "05M",
1354 alias_filename: None,
1355 },
1356 UltraSp3Pattern {
1357 label: "alternate_02D_15M",
1358 span: "02D",
1359 sample: "15M",
1360 alias_filename: None,
1361 },
1362 UltraSp3Pattern {
1363 label: "alternate_01D_05M",
1364 span: "01D",
1365 sample: "05M",
1366 alias_filename: None,
1367 },
1368];
1369
1370const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1371 UltraSp3Pattern {
1372 label: "primary_02D_05M",
1373 span: "02D",
1374 sample: "05M",
1375 alias_filename: None,
1376 },
1377 UltraSp3Pattern {
1378 label: "alternate_02D_15M",
1379 span: "02D",
1380 sample: "15M",
1381 alias_filename: None,
1382 },
1383 UltraSp3Pattern {
1384 label: "alternate_01D_05M",
1385 span: "01D",
1386 sample: "05M",
1387 alias_filename: None,
1388 },
1389];
1390
1391#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1397pub struct ProductIdentity {
1398 pub family: ProductType,
1400 pub publisher: ProductPublisher,
1402 pub solution: SolutionClass,
1404 pub campaign: ProductCampaign,
1406 pub version: u8,
1408 pub date: ProductDate,
1410 pub issue: Option<String>,
1412 pub span: String,
1414 pub sample: String,
1416 pub official_filename: String,
1418 pub format: ProductFormat,
1420 pub prediction_horizon_days: Option<u8>,
1422}
1423
1424impl ProductIdentity {
1425 pub fn validate(&self) -> Result<(), DataCatalogError> {
1431 validate_official_filename(&self.official_filename)?;
1432 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1433 validate_sample(&self.sample)?;
1434 if let Some(issue) = self.issue.as_deref() {
1435 validate_issue(issue)?;
1436 }
1437
1438 if self.format != product_format(self.family) {
1439 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1440 }
1441
1442 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1443 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1444 (_, SolutionClass::Predicted, _) => false,
1445 (_, _, None) => true,
1446 (_, _, Some(_)) => false,
1447 };
1448 if !horizon_valid {
1449 return Err(DataCatalogError::InconsistentProductIdentity {
1450 field: "prediction_horizon_days",
1451 });
1452 }
1453
1454 let descriptor = product_type_convention(self.family);
1455 let expected = match descriptor.kind {
1456 ProductFilenameKind::Sampled => {
1457 let solution_token = self
1458 .solution
1459 .filename_token()
1460 .ok_or(DataCatalogError::InconsistentProductIdentity { field: "solution" })?;
1461 format!(
1462 "{}{}{}{}_{}_{}_{}_{}.{}",
1463 self.publisher.code(),
1464 self.version,
1465 self.campaign.code(),
1466 solution_token,
1467 date_block(self.date, self.issue.as_deref()),
1468 self.span,
1469 self.sample,
1470 descriptor.content_code,
1471 descriptor.extension
1472 )
1473 }
1474 ProductFilenameKind::Nav => {
1475 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1476 && self.solution == SolutionClass::Broadcast
1477 && self.campaign == ProductCampaign::Broadcast
1478 && self.version == 0
1479 && self.issue.is_none()
1480 && self.span == "01D"
1481 && self.sample == "01D";
1482 if !nav_fields_valid {
1483 return Err(DataCatalogError::InconsistentProductIdentity {
1484 field: "broadcast_navigation",
1485 });
1486 }
1487 format!(
1488 "BRDC00WRD_R_{}_{}_{}.{}",
1489 date_block(self.date, None),
1490 self.span,
1491 descriptor.content_code,
1492 descriptor.extension
1493 )
1494 }
1495 };
1496 if expected != self.official_filename {
1497 return Err(DataCatalogError::InconsistentProductIdentity {
1498 field: "official_filename",
1499 });
1500 }
1501 Ok(())
1502 }
1503
1504 pub fn key(&self) -> Result<String, DataCatalogError> {
1506 self.validate()?;
1507 let prediction = self.prediction_horizon_days.map_or_else(
1508 || "observed".to_string(),
1509 |days| format!("prediction_{days}d"),
1510 );
1511 Ok(format!(
1512 "{}/{}/{}/{}/{}/{}",
1513 self.family.code(),
1514 self.publisher.code().to_ascii_lowercase(),
1515 self.solution.code(),
1516 self.campaign.code().to_ascii_lowercase(),
1517 prediction,
1518 self.official_filename
1519 ))
1520 }
1521
1522 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1524 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1525 }
1526}
1527
1528#[derive(Debug, Clone, PartialEq, Eq)]
1530pub struct DistributionLocation {
1531 pub source: DistributionSource,
1533 pub original_url: Option<String>,
1535 pub archive_filename: String,
1537 pub compression: ArchiveCompression,
1539}
1540
1541#[derive(Debug, Clone, PartialEq, Eq)]
1543pub struct ProductRequest {
1544 pub identity: ProductIdentity,
1546 pub distributors: Vec<DistributionSource>,
1548}
1549
1550impl ProductRequest {
1551 pub fn new(
1553 identity: ProductIdentity,
1554 distributors: Vec<DistributionSource>,
1555 ) -> Result<Self, DataCatalogError> {
1556 if distributors.is_empty() {
1557 return Err(DataCatalogError::NoDistributionSources);
1558 }
1559 identity.validate()?;
1560 Ok(Self {
1561 identity,
1562 distributors,
1563 })
1564 }
1565}
1566
1567#[derive(Debug, Clone, PartialEq, Eq)]
1569pub struct ProductSpec {
1570 pub center: AnalysisCenter,
1572 pub product_type: ProductType,
1574 pub date: ProductDate,
1576 pub sample: String,
1578 pub issue: Option<String>,
1580}
1581
1582impl ProductSpec {
1583 pub fn new(
1585 center: AnalysisCenter,
1586 product_type: ProductType,
1587 date: ProductDate,
1588 sample: &str,
1589 issue: Option<&str>,
1590 ) -> Result<Self, DataCatalogError> {
1591 validate_product(center, product_type, sample, issue)?;
1592 Ok(Self {
1593 center,
1594 product_type,
1595 date,
1596 sample: sample.to_string(),
1597 issue: issue.map(ToOwned::to_owned),
1598 })
1599 }
1600
1601 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1603 self.date.gps_week()
1604 }
1605
1606 #[must_use]
1608 pub fn day_of_year(&self) -> u16 {
1609 self.date.day_of_year()
1610 }
1611
1612 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1614 let convention = validate_product(
1615 self.center,
1616 self.product_type,
1617 &self.sample,
1618 self.issue.as_deref(),
1619 )?;
1620 let descriptor = product_type_convention(self.product_type);
1621 Ok(match descriptor.kind {
1622 ProductFilenameKind::Sampled => format!(
1623 "{}_{}_{}_{}_{}.{}",
1624 convention.token,
1625 date_block(self.date, self.issue.as_deref()),
1626 convention.span,
1627 self.sample,
1628 descriptor.content_code,
1629 descriptor.extension
1630 ),
1631 ProductFilenameKind::Nav => format!(
1632 "{}_R_{}_{}_{}.{}",
1633 convention.token,
1634 date_block(self.date, None),
1635 convention.span,
1636 descriptor.content_code,
1637 descriptor.extension
1638 ),
1639 })
1640 }
1641
1642 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1644 let convention = validate_product(
1645 self.center,
1646 self.product_type,
1647 &self.sample,
1648 self.issue.as_deref(),
1649 )?;
1650 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1651 let filename = self.canonical_filename()?;
1652 Ok(format!(
1653 "{}/{}/{}{}",
1654 entry.root_url,
1655 dir_path(convention.layout, self.date)?,
1656 filename,
1657 convention.compression.suffix()
1658 ))
1659 }
1660
1661 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
1663 let convention = validate_product(
1664 self.center,
1665 self.product_type,
1666 &self.sample,
1667 self.issue.as_deref(),
1668 )?;
1669 let descriptor = product_type_convention(self.product_type);
1670 let campaign = match descriptor.kind {
1671 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
1672 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
1673 Some("OPS") => ProductCampaign::Operational,
1674 Some("MGN") => ProductCampaign::MultiGnss,
1675 Some("MGX") => ProductCampaign::MultiGnssExperiment,
1676 _ => {
1677 return Err(DataCatalogError::InconsistentProductIdentity {
1678 field: "campaign",
1679 });
1680 }
1681 },
1682 };
1683 let identity = ProductIdentity {
1684 family: self.product_type,
1685 publisher: self.center.publisher(),
1686 solution: self.center.solution_class(),
1687 campaign,
1688 version: 0,
1689 date: self.date,
1690 issue: self.issue.clone(),
1691 span: convention.span.to_string(),
1692 sample: self.sample.clone(),
1693 official_filename: self.canonical_filename()?,
1694 format: product_format(self.product_type),
1695 prediction_horizon_days: self.center.prediction_horizon_days(),
1696 };
1697 identity.validate()?;
1698 Ok(identity)
1699 }
1700
1701 pub fn distribution_location(
1703 &self,
1704 source: DistributionSource,
1705 ) -> Result<DistributionLocation, DataCatalogError> {
1706 let identity = self.identity()?;
1707 match source {
1708 DistributionSource::Direct => {
1709 let compression = product_convention(self.center, self.product_type)?.compression;
1710 Ok(DistributionLocation {
1711 source,
1712 original_url: Some(self.archive_url()?),
1713 archive_filename: format!(
1714 "{}{}",
1715 identity.official_filename,
1716 compression.suffix()
1717 ),
1718 compression,
1719 })
1720 }
1721 DistributionSource::NasaCddis => {
1722 let url = cddis_archive_url(&identity)?;
1723 Ok(DistributionLocation {
1724 source,
1725 original_url: Some(url),
1726 archive_filename: format!("{}.gz", identity.official_filename),
1727 compression: ArchiveCompression::Gzip,
1728 })
1729 }
1730 DistributionSource::LocalFile | DistributionSource::InMemory => {
1731 Ok(DistributionLocation {
1732 source,
1733 original_url: None,
1734 archive_filename: identity.official_filename,
1735 compression: ArchiveCompression::None,
1736 })
1737 }
1738 }
1739 }
1740}
1741
1742#[derive(Debug, Clone, PartialEq, Eq)]
1744pub struct StationObservationSpec {
1745 pub station: String,
1747 pub date: ProductDate,
1749 pub sample: String,
1751}
1752
1753impl StationObservationSpec {
1754 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1756 validate_station(station)?;
1757 validate_sample(sample)?;
1758 Ok(Self {
1759 station: station.to_string(),
1760 date,
1761 sample: sample.to_string(),
1762 })
1763 }
1764
1765 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1767 station_obs_filename(&self.station, self.date, &self.sample)
1768 }
1769
1770 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1772 station_obs_url(&self.station, self.date, &self.sample)
1773 }
1774}
1775
1776#[must_use]
1778pub const fn catalog() -> &'static [CenterCatalogEntry] {
1779 &CATALOG
1780}
1781
1782#[must_use]
1784pub const fn centers() -> &'static [AnalysisCenter] {
1785 &CENTER_ORDER
1786}
1787
1788#[must_use]
1790pub const fn product_types() -> &'static [ProductTypeConvention] {
1791 &PRODUCT_TYPE_CONVENTIONS
1792}
1793
1794#[must_use]
1796pub const fn allowed_hosts() -> &'static [&'static str] {
1797 &ALLOWED_HOSTS
1798}
1799
1800#[must_use]
1802pub const fn skadi_source_entry() -> TerrainSourceEntry {
1803 SKADI_SOURCE
1804}
1805
1806#[must_use]
1808pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1809 CELESTRAK_SPACE_WEATHER_SOURCE
1810}
1811
1812#[must_use]
1814pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1815 match product {
1816 SpaceWeatherProduct::All => "SW-All.csv",
1817 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1818 }
1819}
1820
1821#[must_use]
1823pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1824 format!(
1825 "{}/{}",
1826 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1827 space_weather_filename(product)
1828 )
1829}
1830
1831#[must_use]
1833pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1834 format!("space-weather/{}", space_weather_filename(product))
1835}
1836
1837pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1839 validate_terrain_tile_index(lat_index, lon_index)?;
1840 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1841 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1842 Ok(format!(
1843 "{lat_hemi}{:02}{lon_hemi}{:03}",
1844 lat_index.abs(),
1845 lon_index.abs()
1846 ))
1847}
1848
1849pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1851 validate_terrain_lat_index(lat_index)?;
1852 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1853 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1854}
1855
1856pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1858 let band = skadi_band(lat_index)?;
1859 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1860 Ok(format!(
1861 "{}/skadi/{}/{}.hgt{}",
1862 SKADI_SOURCE.root_url,
1863 band,
1864 tile_id,
1865 SKADI_SOURCE.compression.suffix()
1866 ))
1867}
1868
1869pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1871 validate_terrain_tile_index(lat_index, lon_index)?;
1872 Ok(format!(
1873 "{}_{}{}",
1874 terrain::format_lat(lat_index),
1875 terrain::format_lon(lon_index),
1876 terrain::DTED_SUFFIX
1877 ))
1878}
1879
1880pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1882 validate_terrain_tile_index(lat_index, lon_index)?;
1883 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1884}
1885
1886pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1888 Ok(format!(
1889 "{}/{}",
1890 dted_block_dir(lat_index, lon_index)?,
1891 dted_tile_filename(lat_index, lon_index)?
1892 ))
1893}
1894
1895pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1897 let bytes = id.as_bytes();
1898 if bytes.len() != 7
1899 || !matches!(bytes[0], b'N' | b'S')
1900 || !matches!(bytes[3], b'E' | b'W')
1901 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1902 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1903 {
1904 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1905 }
1906
1907 let lat_abs = id[1..3]
1908 .parse::<i32>()
1909 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1910 let lon_abs = id[4..7]
1911 .parse::<i32>()
1912 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1913 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1914 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1915 }
1916
1917 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1918 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1919 validate_terrain_tile_index(lat_index, lon_index)?;
1920 Ok((lat_index, lon_index))
1921}
1922
1923pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1925 if !lat_deg.is_finite()
1926 || !lon_deg.is_finite()
1927 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1928 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1929 {
1930 return Err(DataCatalogError::InvalidCoordinate {
1931 lat_deg_bits: lat_deg.to_bits(),
1932 lon_deg_bits: lon_deg.to_bits(),
1933 });
1934 }
1935
1936 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1937 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1938 lat_index = MAX_TERRAIN_LAT_INDEX;
1939 }
1940 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1941 lon_index = MAX_TERRAIN_LON_INDEX;
1942 }
1943 validate_terrain_tile_index(lat_index, lon_index)?;
1944 Ok((lat_index, lon_index))
1945}
1946
1947pub fn hgt_to_dted(
1955 lat_index: i32,
1956 lon_index: i32,
1957 hgt: &[u8],
1958) -> Result<Vec<u8>, HgtConversionError> {
1959 validate_hgt_tile_index(lat_index, lon_index)?;
1960 if hgt.len() != SRTM1_HGT_LEN {
1961 return Err(HgtConversionError::BadLength {
1962 expected: SRTM1_HGT_LEN,
1963 got: hgt.len(),
1964 });
1965 }
1966
1967 let mut out = vec![b' '; DTED_SRTM1_LEN];
1968 out[0..4].copy_from_slice(b"UHL1");
1969 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1970 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1971 out[47..51].copy_from_slice(b"3601");
1972 out[51..55].copy_from_slice(b"3601");
1973
1974 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1975 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1976 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1977 out[block_start] = terrain::DATA_SENTINEL;
1978
1979 let count = (lon_posting as u32).to_be_bytes();
1980 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1981 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1982 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1983
1984 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1985 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1986 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1987 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1988 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1989 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1990 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1991 }
1992
1993 let checksum = out[block_start..checksum_start]
1994 .iter()
1995 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1996 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1997 }
1998
1999 debug_assert_eq!(out.len(), 25_981_042);
2000 Ok(out)
2001}
2002
2003#[must_use]
2005pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2006 &NO_OPEN_MIRRORS
2007}
2008
2009pub fn open_mirror(
2011 center: AnalysisCenter,
2012 product_type: ProductType,
2013) -> Result<(), DataCatalogError> {
2014 open_mirror_code(center.code(), product_type.code())
2015}
2016
2017pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2019 if NO_OPEN_MIRRORS
2020 .iter()
2021 .any(|entry| entry.center == center && entry.product_type == product_type)
2022 {
2023 Err(DataCatalogError::NoOpenMirror {
2024 center: center.to_string(),
2025 product_type: product_type.to_string(),
2026 })
2027 } else {
2028 Ok(())
2029 }
2030}
2031
2032#[must_use]
2034pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2035 CATALOG.iter().find(|entry| entry.center == center)
2036}
2037
2038pub fn product_convention(
2040 center: AnalysisCenter,
2041 product_type: ProductType,
2042) -> Result<&'static CenterProductConvention, DataCatalogError> {
2043 open_mirror(center, product_type)?;
2044 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2045 entry
2046 .products
2047 .iter()
2048 .find(|product| product.product_type == product_type)
2049 .ok_or(DataCatalogError::UnsupportedProduct {
2050 center,
2051 product_type,
2052 })
2053}
2054
2055pub fn default_sample(
2057 center: AnalysisCenter,
2058 product_type: ProductType,
2059) -> Result<&'static str, DataCatalogError> {
2060 Ok(product_convention(center, product_type)?.default_sample)
2061}
2062
2063pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2065 date.gps_week()
2066}
2067
2068#[must_use]
2070pub fn day_of_year(date: ProductDate) -> u16 {
2071 date.day_of_year()
2072}
2073
2074pub fn product(
2076 center: AnalysisCenter,
2077 product_type: ProductType,
2078 date: ProductDate,
2079 sample: Option<&str>,
2080 issue: Option<&str>,
2081) -> Result<ProductSpec, DataCatalogError> {
2082 let sample = match sample {
2083 Some(sample) => sample,
2084 None => default_sample(center, product_type)?,
2085 };
2086 ProductSpec::new(center, product_type, date, sample, issue)
2087}
2088
2089pub fn canonical_filename(
2091 center: AnalysisCenter,
2092 product_type: ProductType,
2093 date: ProductDate,
2094 sample: Option<&str>,
2095 issue: Option<&str>,
2096) -> Result<String, DataCatalogError> {
2097 product(center, product_type, date, sample, issue)?.canonical_filename()
2098}
2099
2100pub fn archive_url(
2102 center: AnalysisCenter,
2103 product_type: ProductType,
2104 date: ProductDate,
2105 sample: Option<&str>,
2106 issue: Option<&str>,
2107) -> Result<String, DataCatalogError> {
2108 product(center, product_type, date, sample, issue)?.archive_url()
2109}
2110
2111pub fn product_identity(
2113 center: AnalysisCenter,
2114 product_type: ProductType,
2115 date: ProductDate,
2116 sample: Option<&str>,
2117 issue: Option<&str>,
2118) -> Result<ProductIdentity, DataCatalogError> {
2119 product(center, product_type, date, sample, issue)?.identity()
2120}
2121
2122pub fn distribution_location(
2124 center: AnalysisCenter,
2125 product_type: ProductType,
2126 date: ProductDate,
2127 sample: Option<&str>,
2128 issue: Option<&str>,
2129 source: DistributionSource,
2130) -> Result<DistributionLocation, DataCatalogError> {
2131 product(center, product_type, date, sample, issue)?.distribution_location(source)
2132}
2133
2134pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2139 identity.validate()?;
2140 match identity.family {
2141 ProductType::Sp3 => Ok(format!(
2142 "https://cddis.nasa.gov/archive/gnss/products/{}/{}.gz",
2143 identity.date.gps_week()?,
2144 identity.official_filename
2145 )),
2146 ProductType::Ionex => Ok(format!(
2147 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2148 identity.date.year,
2149 identity.date.day_of_year(),
2150 identity.official_filename
2151 )),
2152 product_type => Err(DataCatalogError::UnsupportedDistribution {
2153 source: DistributionSource::NasaCddis,
2154 product_type,
2155 }),
2156 }
2157}
2158
2159pub fn mgex_clk(
2161 center: AnalysisCenter,
2162 date: ProductDate,
2163 sample: Option<&str>,
2164) -> Result<ProductSpec, DataCatalogError> {
2165 product(center, ProductType::Clk, date, sample, None)
2166}
2167
2168pub fn mgex_nav(
2170 center: AnalysisCenter,
2171 date: ProductDate,
2172 sample: Option<&str>,
2173) -> Result<ProductSpec, DataCatalogError> {
2174 product(center, ProductType::Nav, date, sample, None)
2175}
2176
2177pub fn mgex_ionex(
2179 center: AnalysisCenter,
2180 date: ProductDate,
2181 sample: Option<&str>,
2182) -> Result<ProductSpec, DataCatalogError> {
2183 product(center, ProductType::Ionex, date, sample, None)
2184}
2185
2186pub fn rapid_ionex(
2188 date: ProductDate,
2189 sample: Option<&str>,
2190) -> Result<ProductSpec, DataCatalogError> {
2191 product(
2192 AnalysisCenter::CodRap,
2193 ProductType::Ionex,
2194 date,
2195 sample,
2196 None,
2197 )
2198}
2199
2200#[must_use]
2202pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2203 match center {
2204 AnalysisCenter::CodPrd2 => 1,
2205 _ => 0,
2206 }
2207}
2208
2209pub fn predicted_ionex(
2211 center: AnalysisCenter,
2212 date: ProductDate,
2213 sample: Option<&str>,
2214) -> Result<ProductSpec, DataCatalogError> {
2215 match center {
2216 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2217 let target = date.add_days(predicted_day_offset(center))?;
2218 product(center, ProductType::Ionex, target, sample, None)
2219 }
2220 other => Err(DataCatalogError::UnsupportedProduct {
2221 center: other,
2222 product_type: ProductType::Ionex,
2223 }),
2224 }
2225}
2226
2227pub fn mgex_sp3(
2229 center: AnalysisCenter,
2230 date: ProductDate,
2231 sample: Option<&str>,
2232) -> Result<ProductSpec, DataCatalogError> {
2233 product(center, ProductType::Sp3, date, sample, None)
2234}
2235
2236pub fn ops_ultra_sp3(
2238 center: AnalysisCenter,
2239 date: ProductDate,
2240 sample: Option<&str>,
2241 issue: Option<&str>,
2242) -> Result<ProductSpec, DataCatalogError> {
2243 let issue = issue.unwrap_or("0000");
2244 product(center, ProductType::Sp3, date, sample, Some(issue))
2245}
2246
2247pub fn ultra_sp3_locations(
2254 center: AnalysisCenter,
2255 date: ProductDate,
2256 issue: &str,
2257) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2258 validate_issue_for_center(center, Some(issue))?;
2259 let patterns: &[UltraSp3Pattern] = match center {
2260 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2261 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2262 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2263 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2264 other => {
2265 return Err(DataCatalogError::UnsupportedProduct {
2266 center: other,
2267 product_type: ProductType::Sp3,
2268 })
2269 }
2270 };
2271 let convention = product_convention(center, ProductType::Sp3)?;
2272 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2273 let directory = dir_path(convention.layout, date)?;
2274 let date = date_block(date, Some(issue));
2275
2276 Ok(patterns
2277 .iter()
2278 .map(|pattern| {
2279 let filename = pattern.alias_filename.map_or_else(
2280 || {
2281 format!(
2282 "{}_{}_{}_{}_ORB.SP3",
2283 convention.token, date, pattern.span, pattern.sample
2284 )
2285 },
2286 ToOwned::to_owned,
2287 );
2288 UltraSp3Location {
2289 pattern: pattern.label.to_string(),
2290 span: pattern.span.to_string(),
2291 sample: pattern.sample.to_string(),
2292 url: format!(
2293 "{}/{}/{}{}",
2294 entry.root_url,
2295 directory,
2296 filename,
2297 convention.compression.suffix()
2298 ),
2299 filename,
2300 compression: convention.compression,
2301 }
2302 })
2303 .collect())
2304}
2305
2306pub fn ops_ultra_clk(
2308 center: AnalysisCenter,
2309 date: ProductDate,
2310 sample: Option<&str>,
2311 issue: Option<&str>,
2312) -> Result<ProductSpec, DataCatalogError> {
2313 let issue = issue.unwrap_or("0000");
2314 product(center, ProductType::Clk, date, sample, Some(issue))
2315}
2316
2317pub fn latest_ops_ultra_sp3(
2319 center: AnalysisCenter,
2320 target: ProductDateTime,
2321 sample: Option<&str>,
2322 available_issues: Option<&[UltraIssue]>,
2323) -> Result<ProductSpec, DataCatalogError> {
2324 let selected = latest_ultra_issue(center, target, available_issues)?;
2325 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2326}
2327
2328pub fn ultra_issue_candidates(
2330 center: AnalysisCenter,
2331 target: ProductDateTime,
2332) -> Result<Vec<UltraIssue>, DataCatalogError> {
2333 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2334 let _ = product_convention(center, ProductType::Sp3)?;
2335 if entry.issues.is_empty() {
2336 return Err(DataCatalogError::UnsupportedProduct {
2337 center,
2338 product_type: ProductType::Sp3,
2339 });
2340 }
2341
2342 let mut candidates = Vec::new();
2343 for date in [target.date, target.date.add_days(-1)?] {
2344 for issue in entry.issues.iter().rev() {
2345 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2346 candidates.push(UltraIssue::new(date, issue)?);
2347 }
2348 }
2349 }
2350 Ok(candidates)
2351}
2352
2353pub fn latest_ultra_issue(
2355 center: AnalysisCenter,
2356 target: ProductDateTime,
2357 available_issues: Option<&[UltraIssue]>,
2358) -> Result<UltraIssue, DataCatalogError> {
2359 let candidates = ultra_issue_candidates(center, target)?;
2360 if candidates.is_empty() {
2361 return Err(DataCatalogError::NoUltraIssue);
2362 }
2363 if let Some(available) = available_issues {
2364 candidates
2365 .into_iter()
2366 .find(|candidate| {
2367 available
2368 .iter()
2369 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2370 })
2371 .ok_or(DataCatalogError::NoAvailableUltraIssue)
2372 } else {
2373 Ok(candidates[0].clone())
2374 }
2375}
2376
2377pub fn gim_date_candidates(
2379 center: AnalysisCenter,
2380 target: ProductDate,
2381 lookback: u32,
2382) -> Result<Vec<ProductDate>, DataCatalogError> {
2383 let _ = product_convention(center, ProductType::Ionex)?;
2384 let base = target.add_days(predicted_day_offset(center))?;
2385 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2386 for back in 0..=lookback {
2387 out.push(base.add_days(-i64::from(back))?);
2388 }
2389 Ok(out)
2390}
2391
2392pub fn station_obs(
2394 station: &str,
2395 date: ProductDate,
2396 sample: Option<&str>,
2397) -> Result<StationObservationSpec, DataCatalogError> {
2398 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2399}
2400
2401pub fn station_obs_filename(
2403 station: &str,
2404 date: ProductDate,
2405 sample: &str,
2406) -> Result<String, DataCatalogError> {
2407 validate_station(station)?;
2408 validate_sample(sample)?;
2409 Ok(format!(
2410 "{}_R_{}_01D_{}_MO.crx",
2411 station,
2412 date_block(date, None),
2413 sample
2414 ))
2415}
2416
2417pub fn station_obs_url(
2419 station: &str,
2420 date: ProductDate,
2421 sample: &str,
2422) -> Result<String, DataCatalogError> {
2423 let filename = station_obs_filename(station, date, sample)?;
2424 Ok(format!(
2425 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
2426 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
2427 filename
2428 ))
2429}
2430
2431#[must_use]
2433pub const fn station_obs_protocol() -> ArchiveProtocol {
2434 ArchiveProtocol::Https
2435}
2436
2437fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
2438 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
2439 Ok(())
2440 } else {
2441 Err(DataCatalogError::InvalidTileIndex {
2442 lat_index,
2443 lon_index: 0,
2444 })
2445 }
2446}
2447
2448fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
2449 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2450 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2451 {
2452 Ok(())
2453 } else {
2454 Err(DataCatalogError::InvalidTileIndex {
2455 lat_index,
2456 lon_index,
2457 })
2458 }
2459}
2460
2461fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
2462 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2463 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2464 {
2465 Ok(())
2466 } else {
2467 Err(HgtConversionError::InvalidTileIndex {
2468 lat_index,
2469 lon_index,
2470 })
2471 }
2472}
2473
2474fn dted_coord_field(index: i32, is_longitude: bool) -> String {
2475 let hemi = match (is_longitude, index >= 0) {
2476 (true, true) => 'E',
2477 (true, false) => 'W',
2478 (false, true) => 'N',
2479 (false, false) => 'S',
2480 };
2481 format!("{:03}0000{hemi}", index.abs())
2482}
2483
2484fn encode_dted_signed_magnitude(sample: i16) -> u16 {
2485 if sample == i16::MIN {
2486 0
2487 } else if sample >= 0 {
2488 sample as u16
2489 } else {
2490 0x8000 | (-i32::from(sample) as u16)
2491 }
2492}
2493
2494fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
2495 PRODUCT_TYPE_CONVENTIONS
2496 .iter()
2497 .find(|descriptor| descriptor.product_type == product_type)
2498 .expect("product descriptor exists for enum variant")
2499}
2500
2501const fn product_format(product_type: ProductType) -> ProductFormat {
2502 match product_type {
2503 ProductType::Sp3 => ProductFormat::Sp3,
2504 ProductType::Ionex => ProductFormat::Ionex,
2505 ProductType::Clk => ProductFormat::RinexClock,
2506 ProductType::Nav => ProductFormat::RinexNavigation,
2507 }
2508}
2509
2510fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
2511 if filename.is_empty()
2512 || filename == "."
2513 || filename == ".."
2514 || filename.contains('/')
2515 || filename.contains('\\')
2516 || filename.contains('\0')
2517 || filename.contains("..")
2518 {
2519 Err(DataCatalogError::InvalidOfficialFilename(
2520 filename.to_string(),
2521 ))
2522 } else {
2523 Ok(())
2524 }
2525}
2526
2527fn validate_product(
2528 center: AnalysisCenter,
2529 product_type: ProductType,
2530 sample: &str,
2531 issue: Option<&str>,
2532) -> Result<&'static CenterProductConvention, DataCatalogError> {
2533 let convention = product_convention(center, product_type)?;
2534 validate_sample(sample)?;
2535 validate_issue_for_center(center, issue)?;
2536 Ok(convention)
2537}
2538
2539fn validate_issue_for_center(
2540 center: AnalysisCenter,
2541 issue: Option<&str>,
2542) -> Result<(), DataCatalogError> {
2543 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2544 match (entry.issues.is_empty(), issue) {
2545 (true, None) => Ok(()),
2546 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
2547 (false, None) => Err(DataCatalogError::MissingIssue { center }),
2548 (false, Some(issue)) => {
2549 validate_issue(issue)?;
2550 if entry.issues.contains(&issue) {
2551 Ok(())
2552 } else {
2553 Err(DataCatalogError::UnsupportedIssue {
2554 center,
2555 issue: issue.to_string(),
2556 })
2557 }
2558 }
2559 }
2560}
2561
2562fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
2563 let bytes = sample.as_bytes();
2564 let valid = bytes.len() == 3
2565 && bytes[0].is_ascii_digit()
2566 && bytes[1].is_ascii_digit()
2567 && bytes[2].is_ascii_uppercase();
2568 if valid {
2569 Ok(())
2570 } else {
2571 Err(DataCatalogError::InvalidSample(sample.to_string()))
2572 }
2573}
2574
2575fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2576 let bytes = issue.as_bytes();
2577 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2578 if !valid_digits {
2579 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2580 }
2581 let hour = issue[0..2]
2582 .parse::<u8>()
2583 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2584 let minute = issue[2..4]
2585 .parse::<u8>()
2586 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2587 if hour <= 23 && minute <= 59 {
2588 Ok(())
2589 } else {
2590 Err(DataCatalogError::InvalidIssue(issue.to_string()))
2591 }
2592}
2593
2594fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2595 let bytes = station.as_bytes();
2596 let valid = bytes.len() == 9
2597 && bytes
2598 .iter()
2599 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2600 if valid {
2601 Ok(())
2602 } else {
2603 Err(DataCatalogError::InvalidStation(station.to_string()))
2604 }
2605}
2606
2607fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2608 validate_issue(issue)?;
2609 let hour = issue[0..2]
2610 .parse::<u16>()
2611 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2612 let minute = issue[2..4]
2613 .parse::<u16>()
2614 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2615 Ok(hour * 60 + minute)
2616}
2617
2618fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2619 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2620}
2621
2622fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2623 format!(
2624 "{}{:03}{}",
2625 date.year,
2626 date.day_of_year(),
2627 issue.unwrap_or("0000")
2628 )
2629}
2630
2631fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2632 Ok(match layout {
2633 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2634 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2635 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2636 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2637 ArchiveLayout::BkgBrdcYearDoy => {
2638 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2639 }
2640 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2641 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2642 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2643 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2644 })
2645}
2646
2647fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2648 let (year, month, day) = civil_from_julian_day_number(jdn);
2649 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2650 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2651 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2652 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2653}