1use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11
12use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
13use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
14use crate::astro::time::model::TimeScale;
15use crate::astro::time::scales::julian_day_number;
16use crate::terrain;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum AnalysisCenter {
21 Igs,
23 CodRap,
25 CodPrd1,
27 CodPrd2,
29 Esa,
31 Cod,
33 Gfz,
35 IgsUlt,
37 CodUlt,
39 EsaUlt,
41 GfzUlt,
43}
44
45impl AnalysisCenter {
46 #[must_use]
48 pub const fn code(self) -> &'static str {
49 match self {
50 Self::Igs => "igs",
51 Self::CodRap => "cod_rap",
52 Self::CodPrd1 => "cod_prd1",
53 Self::CodPrd2 => "cod_prd2",
54 Self::Esa => "esa",
55 Self::Cod => "cod",
56 Self::Gfz => "gfz",
57 Self::IgsUlt => "igs_ult",
58 Self::CodUlt => "cod_ult",
59 Self::EsaUlt => "esa_ult",
60 Self::GfzUlt => "gfz_ult",
61 }
62 }
63
64 #[must_use]
66 pub fn from_code(code: &str) -> Option<Self> {
67 match code {
68 "igs" => Some(Self::Igs),
69 "cod_rap" => Some(Self::CodRap),
70 "cod_prd1" => Some(Self::CodPrd1),
71 "cod_prd2" => Some(Self::CodPrd2),
72 "esa" => Some(Self::Esa),
73 "cod" => Some(Self::Cod),
74 "gfz" => Some(Self::Gfz),
75 "igs_ult" => Some(Self::IgsUlt),
76 "cod_ult" => Some(Self::CodUlt),
77 "esa_ult" => Some(Self::EsaUlt),
78 "gfz_ult" => Some(Self::GfzUlt),
79 _ => None,
80 }
81 }
82
83 #[must_use]
85 pub const fn publisher(self) -> ProductPublisher {
86 match self {
87 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
88 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
89 ProductPublisher::Code
90 }
91 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
92 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
93 }
94 }
95
96 #[must_use]
98 pub const fn solution_class(self) -> SolutionClass {
99 match self {
100 Self::Igs => SolutionClass::Broadcast,
101 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
102 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
103 Self::Esa | Self::Cod => SolutionClass::Final,
104 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
105 }
106 }
107
108 #[must_use]
110 pub const fn prediction_horizon_days(self) -> Option<u8> {
111 match self {
112 Self::CodPrd1 => Some(1),
113 Self::CodPrd2 => Some(2),
114 _ => None,
115 }
116 }
117}
118
119impl fmt::Display for AnalysisCenter {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 f.write_str(self.code())
122 }
123}
124
125impl FromStr for AnalysisCenter {
126 type Err = DataCatalogError;
127
128 fn from_str(s: &str) -> Result<Self, Self::Err> {
129 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
130 }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
135pub enum ProductType {
136 Sp3,
138 Clk,
140 Nav,
142 Ionex,
144}
145
146impl ProductType {
147 #[must_use]
149 pub const fn code(self) -> &'static str {
150 match self {
151 Self::Sp3 => "sp3",
152 Self::Clk => "clk",
153 Self::Nav => "nav",
154 Self::Ionex => "ionex",
155 }
156 }
157
158 #[must_use]
160 pub fn from_code(code: &str) -> Option<Self> {
161 match code {
162 "sp3" => Some(Self::Sp3),
163 "clk" => Some(Self::Clk),
164 "nav" => Some(Self::Nav),
165 "ionex" => Some(Self::Ionex),
166 _ => None,
167 }
168 }
169}
170
171impl fmt::Display for ProductType {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.write_str(self.code())
174 }
175}
176
177impl FromStr for ProductType {
178 type Err = DataCatalogError;
179
180 fn from_str(s: &str) -> Result<Self, Self::Err> {
181 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
182 }
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
191pub enum ProductPublisher {
192 Igs,
194 Code,
196 Esa,
198 Gfz,
200}
201
202impl ProductPublisher {
203 #[must_use]
205 pub const fn code(self) -> &'static str {
206 match self {
207 Self::Igs => "IGS",
208 Self::Code => "COD",
209 Self::Esa => "ESA",
210 Self::Gfz => "GFZ",
211 }
212 }
213}
214
215impl fmt::Display for ProductPublisher {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.write_str(self.code())
218 }
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
223pub enum SolutionClass {
224 Final,
226 Rapid,
228 UltraRapid,
230 Predicted,
232 Broadcast,
234}
235
236impl SolutionClass {
237 #[must_use]
239 pub const fn code(self) -> &'static str {
240 match self {
241 Self::Final => "final",
242 Self::Rapid => "rapid",
243 Self::UltraRapid => "ultra_rapid",
244 Self::Predicted => "predicted",
245 Self::Broadcast => "broadcast",
246 }
247 }
248
249 #[must_use]
251 pub const fn filename_token(self) -> Option<&'static str> {
252 match self {
253 Self::Final => Some("FIN"),
254 Self::Rapid => Some("RAP"),
255 Self::UltraRapid => Some("ULT"),
256 Self::Predicted => Some("PRD"),
257 Self::Broadcast => None,
258 }
259 }
260}
261
262impl fmt::Display for SolutionClass {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 f.write_str(self.code())
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
270pub enum ProductCampaign {
271 Operational,
273 MultiGnss,
275 MultiGnssExperiment,
277 Broadcast,
279}
280
281impl ProductCampaign {
282 #[must_use]
284 pub const fn code(self) -> &'static str {
285 match self {
286 Self::Operational => "OPS",
287 Self::MultiGnss => "MGN",
288 Self::MultiGnssExperiment => "MGX",
289 Self::Broadcast => "BRD",
290 }
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
296pub enum ProductFormat {
297 Sp3,
299 Ionex,
301 RinexClock,
303 RinexNavigation,
305}
306
307impl ProductFormat {
308 #[must_use]
310 pub const fn code(self) -> &'static str {
311 match self {
312 Self::Sp3 => "SP3",
313 Self::Ionex => "IONEX",
314 Self::RinexClock => "RINEX_CLK",
315 Self::RinexNavigation => "RINEX_NAV",
316 }
317 }
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
325pub enum DistributionSource {
326 Direct,
328 NasaCddis,
330 LocalFile,
332 InMemory,
334}
335
336impl DistributionSource {
337 #[must_use]
339 pub const fn code(self) -> &'static str {
340 match self {
341 Self::Direct => "direct",
342 Self::NasaCddis => "nasa_cddis",
343 Self::LocalFile => "local_file",
344 Self::InMemory => "in_memory",
345 }
346 }
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
351pub enum SpaceWeatherProduct {
352 All,
354 Last5Years,
356}
357
358impl SpaceWeatherProduct {
359 #[must_use]
361 pub const fn code(self) -> &'static str {
362 match self {
363 Self::All => "sw_all",
364 Self::Last5Years => "sw_last5",
365 }
366 }
367
368 #[must_use]
370 pub fn from_code(code: &str) -> Option<Self> {
371 match code {
372 "sw_all" => Some(Self::All),
373 "sw_last5" => Some(Self::Last5Years),
374 _ => None,
375 }
376 }
377}
378
379impl fmt::Display for SpaceWeatherProduct {
380 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381 f.write_str(self.code())
382 }
383}
384
385impl FromStr for SpaceWeatherProduct {
386 type Err = DataCatalogError;
387
388 fn from_str(s: &str) -> Result<Self, Self::Err> {
389 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
390 }
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
395pub enum ArchiveProtocol {
396 Http,
398 Https,
400}
401
402impl ArchiveProtocol {
403 #[must_use]
405 pub const fn as_str(self) -> &'static str {
406 match self {
407 Self::Http => "http",
408 Self::Https => "https",
409 }
410 }
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
415pub enum ArchiveCompression {
416 Gzip,
418 None,
420}
421
422impl ArchiveCompression {
423 #[must_use]
425 pub const fn as_str(self) -> &'static str {
426 match self {
427 Self::Gzip => "gzip",
428 Self::None => "none",
429 }
430 }
431
432 const fn suffix(self) -> &'static str {
433 match self {
434 Self::Gzip => ".gz",
435 Self::None => "",
436 }
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
442pub enum ArchiveLayout {
443 GfzRapidWeek,
445 GfzUltraWeek,
447 GpsWeek,
449 BkgProductsWeek,
451 BkgBrdcYearDoy,
453 BkgObsYearDoy,
455 AiubCodeMgexYear,
457 AiubCodeYear,
459 AiubCodeRoot,
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum ProductFilenameKind {
466 Sampled,
468 Nav,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
474pub struct ProductTypeConvention {
475 pub product_type: ProductType,
477 pub content_code: &'static str,
479 pub extension: &'static str,
481 pub kind: ProductFilenameKind,
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
487pub struct CenterProductConvention {
488 pub product_type: ProductType,
490 pub token: &'static str,
492 pub layout: ArchiveLayout,
494 pub span: &'static str,
496 pub default_sample: &'static str,
498 pub compression: ArchiveCompression,
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub struct CenterCatalogEntry {
505 pub center: AnalysisCenter,
507 pub code: &'static str,
509 pub protocol: ArchiveProtocol,
511 pub host: &'static str,
513 pub root_url: &'static str,
515 pub products: &'static [CenterProductConvention],
517 pub issues: &'static [&'static str],
519}
520
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub struct TerrainSourceEntry {
524 pub protocol: ArchiveProtocol,
526 pub host: &'static str,
528 pub compression: ArchiveCompression,
530 pub root_url: &'static str,
532}
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536pub struct SpaceWeatherSourceEntry {
537 pub protocol: ArchiveProtocol,
539 pub host: &'static str,
541 pub compression: ArchiveCompression,
543 pub root_url: &'static str,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
549pub struct NoOpenMirrorProduct {
550 pub center: &'static str,
552 pub product_type: &'static str,
554}
555
556const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
557 ProductTypeConvention {
558 product_type: ProductType::Sp3,
559 content_code: "ORB",
560 extension: "SP3",
561 kind: ProductFilenameKind::Sampled,
562 },
563 ProductTypeConvention {
564 product_type: ProductType::Clk,
565 content_code: "CLK",
566 extension: "CLK",
567 kind: ProductFilenameKind::Sampled,
568 },
569 ProductTypeConvention {
570 product_type: ProductType::Nav,
571 content_code: "MN",
572 extension: "rnx",
573 kind: ProductFilenameKind::Nav,
574 },
575 ProductTypeConvention {
576 product_type: ProductType::Ionex,
577 content_code: "GIM",
578 extension: "INX",
579 kind: ProductFilenameKind::Sampled,
580 },
581];
582
583const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
584 product_type: ProductType::Ionex,
585 token: "COD0OPSRAP",
586 layout: ArchiveLayout::AiubCodeRoot,
587 span: "01D",
588 default_sample: "01H",
589 compression: ArchiveCompression::Gzip,
590}];
591
592const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
593 product_type: ProductType::Ionex,
594 token: "COD0OPSPRD",
595 layout: ArchiveLayout::AiubCodeRoot,
596 span: "01D",
597 default_sample: "01H",
598 compression: ArchiveCompression::Gzip,
599}];
600
601const ESA_PRODUCTS: [CenterProductConvention; 3] = [
602 CenterProductConvention {
603 product_type: ProductType::Sp3,
604 token: "ESA0MGNFIN",
605 layout: ArchiveLayout::GpsWeek,
606 span: "01D",
607 default_sample: "05M",
608 compression: ArchiveCompression::Gzip,
609 },
610 CenterProductConvention {
611 product_type: ProductType::Clk,
612 token: "ESA0MGNFIN",
613 layout: ArchiveLayout::GpsWeek,
614 span: "01D",
615 default_sample: "30S",
616 compression: ArchiveCompression::Gzip,
617 },
618 CenterProductConvention {
619 product_type: ProductType::Ionex,
620 token: "ESA0OPSFIN",
621 layout: ArchiveLayout::GpsWeek,
622 span: "01D",
623 default_sample: "02H",
624 compression: ArchiveCompression::Gzip,
625 },
626];
627
628const COD_PRODUCTS: [CenterProductConvention; 3] = [
629 CenterProductConvention {
630 product_type: ProductType::Sp3,
631 token: "COD0MGXFIN",
632 layout: ArchiveLayout::AiubCodeMgexYear,
633 span: "01D",
634 default_sample: "05M",
635 compression: ArchiveCompression::Gzip,
636 },
637 CenterProductConvention {
638 product_type: ProductType::Clk,
639 token: "COD0MGXFIN",
640 layout: ArchiveLayout::AiubCodeMgexYear,
641 span: "01D",
642 default_sample: "30S",
643 compression: ArchiveCompression::Gzip,
644 },
645 CenterProductConvention {
646 product_type: ProductType::Ionex,
647 token: "COD0OPSFIN",
648 layout: ArchiveLayout::AiubCodeYear,
649 span: "01D",
650 default_sample: "01H",
651 compression: ArchiveCompression::Gzip,
652 },
653];
654
655const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
656 CenterProductConvention {
657 product_type: ProductType::Sp3,
658 token: "GFZ0OPSRAP",
659 layout: ArchiveLayout::GfzRapidWeek,
660 span: "01D",
661 default_sample: "15M",
662 compression: ArchiveCompression::Gzip,
663 },
664 CenterProductConvention {
665 product_type: ProductType::Clk,
666 token: "GFZ0OPSRAP",
667 layout: ArchiveLayout::GfzRapidWeek,
668 span: "01D",
669 default_sample: "30S",
670 compression: ArchiveCompression::Gzip,
671 },
672];
673
674const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
675 product_type: ProductType::Nav,
676 token: "BRDC00WRD",
677 layout: ArchiveLayout::BkgBrdcYearDoy,
678 span: "01D",
679 default_sample: "01D",
680 compression: ArchiveCompression::Gzip,
681}];
682
683const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
684 product_type: ProductType::Sp3,
685 token: "IGS0OPSULT",
686 layout: ArchiveLayout::BkgProductsWeek,
687 span: "02D",
688 default_sample: "15M",
689 compression: ArchiveCompression::Gzip,
690}];
691
692const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
693 product_type: ProductType::Sp3,
694 token: "COD0OPSULT",
695 layout: ArchiveLayout::AiubCodeRoot,
696 span: "01D",
697 default_sample: "05M",
698 compression: ArchiveCompression::None,
699}];
700
701const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
702 product_type: ProductType::Sp3,
703 token: "ESA0OPSULT",
704 layout: ArchiveLayout::GpsWeek,
705 span: "02D",
706 default_sample: "05M",
707 compression: ArchiveCompression::Gzip,
708}];
709
710const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
711 product_type: ProductType::Sp3,
712 token: "GFZ0OPSULT",
713 layout: ArchiveLayout::GfzUltraWeek,
714 span: "02D",
715 default_sample: "05M",
716 compression: ArchiveCompression::Gzip,
717}];
718
719const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
720const COD_ULT_ISSUES: [&str; 1] = ["0000"];
721const GFZ_ULT_ISSUES: [&str; 8] = [
722 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
723];
724
725const CENTER_ORDER: [AnalysisCenter; 11] = [
726 AnalysisCenter::CodRap,
727 AnalysisCenter::CodPrd1,
728 AnalysisCenter::CodPrd2,
729 AnalysisCenter::Igs,
730 AnalysisCenter::Esa,
731 AnalysisCenter::Cod,
732 AnalysisCenter::Gfz,
733 AnalysisCenter::IgsUlt,
734 AnalysisCenter::CodUlt,
735 AnalysisCenter::EsaUlt,
736 AnalysisCenter::GfzUlt,
737];
738
739const CATALOG: [CenterCatalogEntry; 11] = [
740 CenterCatalogEntry {
741 center: AnalysisCenter::CodRap,
742 code: "cod_rap",
743 protocol: ArchiveProtocol::Http,
744 host: "ftp.aiub.unibe.ch",
745 root_url: "http://ftp.aiub.unibe.ch",
746 products: &COD_RAP_PRODUCTS,
747 issues: &[],
748 },
749 CenterCatalogEntry {
750 center: AnalysisCenter::CodPrd1,
751 code: "cod_prd1",
752 protocol: ArchiveProtocol::Https,
753 host: "www.aiub.unibe.ch",
754 root_url: "https://www.aiub.unibe.ch/download",
755 products: &COD_PRD_PRODUCTS,
756 issues: &[],
757 },
758 CenterCatalogEntry {
759 center: AnalysisCenter::CodPrd2,
760 code: "cod_prd2",
761 protocol: ArchiveProtocol::Https,
762 host: "www.aiub.unibe.ch",
763 root_url: "https://www.aiub.unibe.ch/download",
764 products: &COD_PRD_PRODUCTS,
765 issues: &[],
766 },
767 CenterCatalogEntry {
768 center: AnalysisCenter::Igs,
769 code: "igs",
770 protocol: ArchiveProtocol::Https,
771 host: "igs.bkg.bund.de",
772 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
773 products: &IGS_PRODUCTS,
774 issues: &[],
775 },
776 CenterCatalogEntry {
777 center: AnalysisCenter::Esa,
778 code: "esa",
779 protocol: ArchiveProtocol::Https,
780 host: "navigation-office.esa.int",
781 root_url: "https://navigation-office.esa.int/products/gnss-products",
782 products: &ESA_PRODUCTS,
783 issues: &[],
784 },
785 CenterCatalogEntry {
786 center: AnalysisCenter::Cod,
787 code: "cod",
788 protocol: ArchiveProtocol::Http,
789 host: "ftp.aiub.unibe.ch",
790 root_url: "http://ftp.aiub.unibe.ch",
791 products: &COD_PRODUCTS,
792 issues: &[],
793 },
794 CenterCatalogEntry {
795 center: AnalysisCenter::Gfz,
796 code: "gfz",
797 protocol: ArchiveProtocol::Https,
798 host: "isdc-data.gfz.de",
799 root_url: "https://isdc-data.gfz.de/gnss/products",
800 products: &GFZ_PRODUCTS,
801 issues: &[],
802 },
803 CenterCatalogEntry {
804 center: AnalysisCenter::IgsUlt,
805 code: "igs_ult",
806 protocol: ArchiveProtocol::Https,
807 host: "igs.bkg.bund.de",
808 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
809 products: &IGS_ULT_PRODUCTS,
810 issues: &OPSULT_ISSUES,
811 },
812 CenterCatalogEntry {
813 center: AnalysisCenter::CodUlt,
814 code: "cod_ult",
815 protocol: ArchiveProtocol::Https,
816 host: "www.aiub.unibe.ch",
817 root_url: "https://www.aiub.unibe.ch/download",
821 products: &COD_ULT_PRODUCTS,
822 issues: &COD_ULT_ISSUES,
823 },
824 CenterCatalogEntry {
825 center: AnalysisCenter::EsaUlt,
826 code: "esa_ult",
827 protocol: ArchiveProtocol::Https,
828 host: "navigation-office.esa.int",
829 root_url: "https://navigation-office.esa.int/products/gnss-products",
830 products: &ESA_ULT_PRODUCTS,
831 issues: &OPSULT_ISSUES,
832 },
833 CenterCatalogEntry {
834 center: AnalysisCenter::GfzUlt,
835 code: "gfz_ult",
836 protocol: ArchiveProtocol::Https,
837 host: "isdc-data.gfz.de",
838 root_url: "https://isdc-data.gfz.de/gnss/products",
839 products: &GFZ_ULT_PRODUCTS,
840 issues: &GFZ_ULT_ISSUES,
841 },
842];
843
844const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
845 protocol: ArchiveProtocol::Https,
846 host: "s3.amazonaws.com",
847 compression: ArchiveCompression::Gzip,
848 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
849};
850
851const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
852 protocol: ArchiveProtocol::Https,
853 host: "celestrak.org",
854 compression: ArchiveCompression::None,
855 root_url: "https://celestrak.org/SpaceData",
856};
857
858const ALLOWED_HOSTS: [&str; 11] = [
859 "ftp.aiub.unibe.ch",
860 "www.aiub.unibe.ch",
861 "download.aiub.unibe.ch",
862 "zhw-b.s3.cloud.switch.ch",
863 "navigation-office.esa.int",
864 "isdc-data.gfz.de",
865 "igs.bkg.bund.de",
866 "s3.amazonaws.com",
867 "celestrak.org",
868 "cddis.nasa.gov",
869 "urs.earthdata.nasa.gov",
870];
871
872const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
873 NoOpenMirrorProduct {
874 center: "grg",
875 product_type: "sp3",
876 },
877 NoOpenMirrorProduct {
878 center: "grg",
879 product_type: "clk",
880 },
881 NoOpenMirrorProduct {
882 center: "wum",
883 product_type: "sp3",
884 },
885 NoOpenMirrorProduct {
886 center: "wum",
887 product_type: "clk",
888 },
889 NoOpenMirrorProduct {
890 center: "grg_ult",
891 product_type: "sp3",
892 },
893 NoOpenMirrorProduct {
894 center: "grg_ult",
895 product_type: "clk",
896 },
897 NoOpenMirrorProduct {
898 center: "igs",
899 product_type: "ionex",
900 },
901];
902
903#[derive(Debug, Clone, PartialEq, Eq)]
905pub enum DataCatalogError {
906 UnknownCenter(String),
908 UnknownProductType(String),
910 UnsupportedProduct {
912 center: AnalysisCenter,
914 product_type: ProductType,
916 },
917 UnsupportedDistribution {
919 source: DistributionSource,
921 product_type: ProductType,
923 },
924 NoDistributionSources,
926 InvalidOfficialFilename(String),
928 InconsistentProductIdentity {
930 field: &'static str,
932 },
933 NoOpenMirror {
935 center: String,
937 product_type: String,
939 },
940 InvalidDate {
942 year: i32,
944 month: u8,
946 day: u8,
948 },
949 DateOutOfRange,
951 DateBeforeGpsEpoch(ProductDate),
953 InvalidGpsDayOfWeek(u8),
955 InvalidSample(String),
957 InvalidIssue(String),
959 MissingIssue {
961 center: AnalysisCenter,
963 },
964 UnexpectedIssue {
966 center: AnalysisCenter,
968 },
969 UnsupportedIssue {
971 center: AnalysisCenter,
973 issue: String,
975 },
976 InvalidDateTime {
978 hour: u8,
980 minute: u8,
982 second: u8,
984 },
985 NoUltraIssue,
987 NoAvailableUltraIssue,
989 InvalidStation(String),
991 InvalidCoordinate {
993 lat_deg_bits: u64,
995 lon_deg_bits: u64,
997 },
998 InvalidTileIndex {
1000 lat_index: i32,
1002 lon_index: i32,
1004 },
1005 InvalidTileId(String),
1007}
1008
1009impl fmt::Display for DataCatalogError {
1010 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1011 match self {
1012 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1013 Self::UnknownProductType(product_type) => {
1014 write!(f, "unknown product type {product_type:?}")
1015 }
1016 Self::UnsupportedProduct {
1017 center,
1018 product_type,
1019 } => write!(f, "{center} does not serve {product_type}"),
1020 Self::UnsupportedDistribution {
1021 source,
1022 product_type,
1023 } => write!(
1024 f,
1025 "distributor {} does not serve {product_type}",
1026 source.code()
1027 ),
1028 Self::NoDistributionSources => {
1029 write!(f, "exact product request has no distributors")
1030 }
1031 Self::InvalidOfficialFilename(filename) => {
1032 write!(f, "invalid official product filename {filename:?}")
1033 }
1034 Self::InconsistentProductIdentity { field } => {
1035 write!(
1036 f,
1037 "product identity field {field:?} disagrees with its official filename"
1038 )
1039 }
1040 Self::NoOpenMirror {
1041 center,
1042 product_type,
1043 } => write!(f, "{center}/{product_type} has no open mirror"),
1044 Self::InvalidDate { year, month, day } => {
1045 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1046 }
1047 Self::DateOutOfRange => write!(f, "product date is out of range"),
1048 Self::DateBeforeGpsEpoch(date) => {
1049 write!(f, "product date {date} is before the GPS week epoch")
1050 }
1051 Self::InvalidGpsDayOfWeek(day) => {
1052 write!(f, "invalid GPS day-of-week {day}")
1053 }
1054 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1055 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1056 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1057 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1058 Self::UnsupportedIssue { center, issue } => {
1059 write!(f, "{center} does not publish issue {issue:?}")
1060 }
1061 Self::InvalidDateTime {
1062 hour,
1063 minute,
1064 second,
1065 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1066 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1067 Self::NoAvailableUltraIssue => {
1068 write!(f, "no available ultra-rapid issue at or before target")
1069 }
1070 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1071 Self::InvalidCoordinate {
1072 lat_deg_bits,
1073 lon_deg_bits,
1074 } => write!(
1075 f,
1076 "invalid terrain coordinate lat={} lon={}",
1077 f64::from_bits(*lat_deg_bits),
1078 f64::from_bits(*lon_deg_bits)
1079 ),
1080 Self::InvalidTileIndex {
1081 lat_index,
1082 lon_index,
1083 } => write!(
1084 f,
1085 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1086 ),
1087 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1088 }
1089 }
1090}
1091
1092impl std::error::Error for DataCatalogError {}
1093
1094#[derive(Debug, Clone, PartialEq, Eq)]
1096pub enum HgtConversionError {
1097 BadLength {
1099 expected: usize,
1101 got: usize,
1103 },
1104 InvalidTileIndex {
1106 lat_index: i32,
1108 lon_index: i32,
1110 },
1111}
1112
1113impl fmt::Display for HgtConversionError {
1114 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1115 match self {
1116 Self::BadLength { expected, got } => {
1117 write!(
1118 f,
1119 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1120 )
1121 }
1122 Self::InvalidTileIndex {
1123 lat_index,
1124 lon_index,
1125 } => write!(
1126 f,
1127 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1128 ),
1129 }
1130 }
1131}
1132
1133impl std::error::Error for HgtConversionError {}
1134
1135const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1136const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1137const MIN_TERRAIN_LON_INDEX: i32 = -180;
1138const MAX_TERRAIN_LON_INDEX: i32 = 179;
1139const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1140const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1141const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1142const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1143const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1144const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1145const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1146const DTED_SRTM1_LEN: usize =
1147 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1148
1149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1151pub struct ProductDate {
1152 pub year: i32,
1154 pub month: u8,
1156 pub day: u8,
1158}
1159
1160impl ProductDate {
1161 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1163 let days = days_in_month(i64::from(year), i64::from(month));
1164 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1165 return Err(DataCatalogError::InvalidDate { year, month, day });
1166 }
1167 Ok(Self { year, month, day })
1168 }
1169
1170 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1172 if day_of_week > 6 {
1173 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1174 }
1175 let epoch_jdn =
1176 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1177 let offset_days = i64::from(week)
1178 .checked_mul(7)
1179 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1180 .ok_or(DataCatalogError::DateOutOfRange)?;
1181 product_date_from_jdn(
1182 epoch_jdn
1183 .checked_add(offset_days)
1184 .ok_or(DataCatalogError::DateOutOfRange)?,
1185 )
1186 }
1187
1188 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1190 week_from_calendar(
1191 TimeScale::Gpst,
1192 i64::from(self.year),
1193 i64::from(self.month),
1194 i64::from(self.day),
1195 )
1196 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1197 }
1198
1199 #[must_use]
1201 pub fn day_of_year(self) -> u16 {
1202 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1203 }
1204
1205 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1206 product_date_from_jdn(
1207 self.julian_day_number()
1208 .checked_add(days)
1209 .ok_or(DataCatalogError::DateOutOfRange)?,
1210 )
1211 }
1212
1213 fn julian_day_number(self) -> i64 {
1214 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1215 }
1216}
1217
1218impl fmt::Display for ProductDate {
1219 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1220 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1221 }
1222}
1223
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1226pub struct ProductDateTime {
1227 pub date: ProductDate,
1229 pub hour: u8,
1231 pub minute: u8,
1233 pub second: u8,
1235}
1236
1237impl ProductDateTime {
1238 pub fn new(
1240 date: ProductDate,
1241 hour: u8,
1242 minute: u8,
1243 second: u8,
1244 ) -> Result<Self, DataCatalogError> {
1245 if hour > 23 || minute > 59 || second > 59 {
1246 return Err(DataCatalogError::InvalidDateTime {
1247 hour,
1248 minute,
1249 second,
1250 });
1251 }
1252 Ok(Self {
1253 date,
1254 hour,
1255 minute,
1256 second,
1257 })
1258 }
1259
1260 fn ordering_minutes(self) -> i64 {
1261 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1262 }
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1267pub struct UltraIssue {
1268 pub date: ProductDate,
1270 pub issue: String,
1272}
1273
1274impl UltraIssue {
1275 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1277 validate_issue(issue)?;
1278 Ok(Self {
1279 date,
1280 issue: issue.to_string(),
1281 })
1282 }
1283}
1284
1285#[derive(Debug, Clone, PartialEq, Eq)]
1287pub struct UltraSp3Location {
1288 pub pattern: String,
1290 pub span: String,
1292 pub sample: String,
1294 pub filename: String,
1296 pub url: String,
1298 pub compression: ArchiveCompression,
1300}
1301
1302#[derive(Debug, Clone, Copy)]
1303struct UltraSp3Pattern {
1304 label: &'static str,
1305 span: &'static str,
1306 sample: &'static str,
1307 alias_filename: Option<&'static str>,
1308}
1309
1310const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1311 UltraSp3Pattern {
1312 label: "primary_02D_15M",
1313 span: "02D",
1314 sample: "15M",
1315 alias_filename: None,
1316 },
1317 UltraSp3Pattern {
1318 label: "alternate_02D_05M",
1319 span: "02D",
1320 sample: "05M",
1321 alias_filename: None,
1322 },
1323 UltraSp3Pattern {
1324 label: "alternate_01D_15M",
1325 span: "01D",
1326 sample: "15M",
1327 alias_filename: None,
1328 },
1329];
1330
1331const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1332 UltraSp3Pattern {
1333 label: "primary_01D_05M",
1334 span: "01D",
1335 sample: "05M",
1336 alias_filename: None,
1337 },
1338 UltraSp3Pattern {
1339 label: "alternate_02D_05M",
1340 span: "02D",
1341 sample: "05M",
1342 alias_filename: None,
1343 },
1344 UltraSp3Pattern {
1345 label: "alias_latest",
1346 span: "01D",
1347 sample: "05M",
1348 alias_filename: Some("COD0OPSULT.SP3"),
1349 },
1350];
1351
1352const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1353 UltraSp3Pattern {
1354 label: "primary_02D_05M",
1355 span: "02D",
1356 sample: "05M",
1357 alias_filename: None,
1358 },
1359 UltraSp3Pattern {
1360 label: "alternate_02D_15M",
1361 span: "02D",
1362 sample: "15M",
1363 alias_filename: None,
1364 },
1365 UltraSp3Pattern {
1366 label: "alternate_01D_05M",
1367 span: "01D",
1368 sample: "05M",
1369 alias_filename: None,
1370 },
1371];
1372
1373const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1374 UltraSp3Pattern {
1375 label: "primary_02D_05M",
1376 span: "02D",
1377 sample: "05M",
1378 alias_filename: None,
1379 },
1380 UltraSp3Pattern {
1381 label: "alternate_02D_15M",
1382 span: "02D",
1383 sample: "15M",
1384 alias_filename: None,
1385 },
1386 UltraSp3Pattern {
1387 label: "alternate_01D_05M",
1388 span: "01D",
1389 sample: "05M",
1390 alias_filename: None,
1391 },
1392];
1393
1394#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1400pub struct ProductIdentity {
1401 pub family: ProductType,
1403 pub analysis_center: AnalysisCenter,
1405 pub publisher: ProductPublisher,
1407 pub solution: SolutionClass,
1409 pub campaign: ProductCampaign,
1411 pub version: u8,
1413 pub date: ProductDate,
1415 pub issue: Option<String>,
1417 pub span: String,
1419 pub sample: String,
1421 pub official_filename: String,
1423 pub format: ProductFormat,
1425 pub format_version: Option<String>,
1431 pub prediction_horizon_days: Option<u8>,
1433}
1434
1435impl ProductIdentity {
1436 pub fn validate(&self) -> Result<(), DataCatalogError> {
1442 validate_official_filename(&self.official_filename)?;
1443 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1444 validate_sample(&self.sample)?;
1445 if let Some(issue) = self.issue.as_deref() {
1446 validate_issue(issue)?;
1447 }
1448
1449 if self.format != product_format(self.family) {
1450 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1451 }
1452
1453 if self
1454 .format_version
1455 .as_deref()
1456 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1457 {
1458 return Err(DataCatalogError::InconsistentProductIdentity {
1459 field: "format_version",
1460 });
1461 }
1462
1463 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1464 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1465 (_, SolutionClass::Predicted, _) => false,
1466 (_, _, None) => true,
1467 (_, _, Some(_)) => false,
1468 };
1469 if !horizon_valid {
1470 return Err(DataCatalogError::InconsistentProductIdentity {
1471 field: "prediction_horizon_days",
1472 });
1473 }
1474 let descriptor = product_type_convention(self.family);
1475 let expected = match descriptor.kind {
1476 ProductFilenameKind::Sampled => {
1477 let solution_token = self
1478 .solution
1479 .filename_token()
1480 .ok_or(DataCatalogError::InconsistentProductIdentity { field: "solution" })?;
1481 format!(
1482 "{}{}{}{}_{}_{}_{}_{}.{}",
1483 self.publisher.code(),
1484 self.version,
1485 self.campaign.code(),
1486 solution_token,
1487 date_block(self.date, self.issue.as_deref()),
1488 self.span,
1489 self.sample,
1490 descriptor.content_code,
1491 descriptor.extension
1492 )
1493 }
1494 ProductFilenameKind::Nav => {
1495 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1496 && self.solution == SolutionClass::Broadcast
1497 && self.campaign == ProductCampaign::Broadcast
1498 && self.version == 0
1499 && self.issue.is_none()
1500 && self.span == "01D"
1501 && self.sample == "01D";
1502 if !nav_fields_valid {
1503 return Err(DataCatalogError::InconsistentProductIdentity {
1504 field: "broadcast_navigation",
1505 });
1506 }
1507 format!(
1508 "BRDC00WRD_R_{}_{}_{}.{}",
1509 date_block(self.date, None),
1510 self.span,
1511 descriptor.content_code,
1512 descriptor.extension
1513 )
1514 }
1515 };
1516 if expected != self.official_filename {
1517 return Err(DataCatalogError::InconsistentProductIdentity {
1518 field: "official_filename",
1519 });
1520 }
1521 if self.publisher != self.analysis_center.publisher()
1522 || self.solution != self.analysis_center.solution_class()
1523 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1524 {
1525 return Err(DataCatalogError::InconsistentProductIdentity {
1526 field: "analysis_center",
1527 });
1528 }
1529 Ok(())
1530 }
1531
1532 pub fn key(&self) -> Result<String, DataCatalogError> {
1534 use sha2::{Digest, Sha256};
1535
1536 let canonical = self.canonical_bytes()?;
1537 let digest = Sha256::digest(canonical);
1538 Ok(format!(
1539 "{}-{}-{}",
1540 self.publisher.code().to_ascii_lowercase(),
1541 self.solution.code(),
1542 digest[..10]
1543 .iter()
1544 .map(|byte| format!("{byte:02x}"))
1545 .collect::<String>()
1546 ))
1547 }
1548
1549 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1555 self.validate()?;
1556 let date = format!(
1557 "{:04}-{:02}-{:02}",
1558 self.date.year, self.date.month, self.date.day
1559 );
1560 let version = self.version.to_string();
1561 let prediction = self
1562 .prediction_horizon_days
1563 .map(|days| days.to_string())
1564 .unwrap_or_default();
1565 let fields = [
1566 self.family.code(),
1567 self.analysis_center.code(),
1568 self.publisher.code(),
1569 self.solution.code(),
1570 self.campaign.code(),
1571 version.as_str(),
1572 date.as_str(),
1573 self.issue.as_deref().unwrap_or_default(),
1574 self.span.as_str(),
1575 self.sample.as_str(),
1576 self.official_filename.as_str(),
1577 self.format.code(),
1578 self.format_version.as_deref().unwrap_or_default(),
1579 prediction.as_str(),
1580 ];
1581 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1582 return Err(DataCatalogError::InconsistentProductIdentity {
1583 field: "canonical_encoding",
1584 });
1585 }
1586 Ok(fields.join("\0").into_bytes())
1587 }
1588
1589 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1591 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1592 }
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Eq)]
1597pub struct DistributionLocation {
1598 pub source: DistributionSource,
1600 pub original_url: Option<String>,
1602 pub archive_filename: String,
1604 pub compression: ArchiveCompression,
1606}
1607
1608#[derive(Debug, Clone, PartialEq, Eq)]
1610pub struct ProductRequest {
1611 pub identity: ProductIdentity,
1613 pub distributors: Vec<DistributionSource>,
1615}
1616
1617#[derive(Debug, Clone, PartialEq, Eq)]
1619pub enum ExactProductSetError {
1620 EmptyExpected,
1622 InvalidExpected {
1624 index: usize,
1626 source: DataCatalogError,
1628 },
1629 InvalidAvailable {
1631 index: usize,
1633 source: DataCatalogError,
1635 },
1636 Mismatch {
1638 missing: Vec<ProductIdentity>,
1640 unexpected: Vec<ProductIdentity>,
1642 duplicate_expected: Vec<ProductIdentity>,
1644 duplicate_available: Vec<ProductIdentity>,
1646 },
1647}
1648
1649impl fmt::Display for ExactProductSetError {
1650 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1651 match self {
1652 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
1653 Self::InvalidExpected { index, source } => {
1654 write!(f, "expected product {index} is invalid: {source}")
1655 }
1656 Self::InvalidAvailable { index, source } => {
1657 write!(f, "available product {index} is invalid: {source}")
1658 }
1659 Self::Mismatch {
1660 missing,
1661 unexpected,
1662 duplicate_expected,
1663 duplicate_available,
1664 } => write!(
1665 f,
1666 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
1667 identity_list(missing),
1668 identity_list(unexpected),
1669 identity_list(duplicate_expected),
1670 identity_list(duplicate_available),
1671 ),
1672 }
1673 }
1674}
1675
1676impl std::error::Error for ExactProductSetError {}
1677
1678pub fn validate_exact_product_set(
1692 expected: &[ProductIdentity],
1693 available: &[ProductIdentity],
1694) -> Result<(), ExactProductSetError> {
1695 if expected.is_empty() {
1696 return Err(ExactProductSetError::EmptyExpected);
1697 }
1698 for (index, identity) in expected.iter().enumerate() {
1699 identity
1700 .validate()
1701 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
1702 }
1703 for (index, identity) in available.iter().enumerate() {
1704 identity
1705 .validate()
1706 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
1707 }
1708
1709 let expected_counts = identity_counts(expected);
1710 let available_counts = identity_counts(available);
1711 let missing = unique_matching(expected, |identity| {
1712 !available_counts.contains_key(identity)
1713 });
1714 let unexpected = unique_matching(available, |identity| {
1715 !expected_counts.contains_key(identity)
1716 });
1717 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
1718 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
1719
1720 if missing.is_empty()
1721 && unexpected.is_empty()
1722 && duplicate_expected.is_empty()
1723 && duplicate_available.is_empty()
1724 {
1725 Ok(())
1726 } else {
1727 Err(ExactProductSetError::Mismatch {
1728 missing,
1729 unexpected,
1730 duplicate_expected,
1731 duplicate_available,
1732 })
1733 }
1734}
1735
1736fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
1737 let mut counts = HashMap::with_capacity(identities.len());
1738 for identity in identities {
1739 *counts.entry(identity).or_insert(0) += 1;
1740 }
1741 counts
1742}
1743
1744fn unique_matching(
1745 identities: &[ProductIdentity],
1746 mut predicate: impl FnMut(&ProductIdentity) -> bool,
1747) -> Vec<ProductIdentity> {
1748 let mut seen = HashSet::with_capacity(identities.len());
1749 identities
1750 .iter()
1751 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
1752 .cloned()
1753 .collect()
1754}
1755
1756fn identity_list(identities: &[ProductIdentity]) -> String {
1757 if identities.is_empty() {
1758 return "none".to_string();
1759 }
1760 identities
1761 .iter()
1762 .map(|identity| {
1763 identity
1764 .key()
1765 .unwrap_or_else(|_| identity.official_filename.clone())
1766 })
1767 .collect::<Vec<_>>()
1768 .join(", ")
1769}
1770
1771impl ProductRequest {
1772 pub fn new(
1774 identity: ProductIdentity,
1775 distributors: Vec<DistributionSource>,
1776 ) -> Result<Self, DataCatalogError> {
1777 if distributors.is_empty() {
1778 return Err(DataCatalogError::NoDistributionSources);
1779 }
1780 identity.validate()?;
1781 Ok(Self {
1782 identity,
1783 distributors,
1784 })
1785 }
1786}
1787
1788#[derive(Debug, Clone, PartialEq, Eq)]
1790pub struct ProductSpec {
1791 pub center: AnalysisCenter,
1793 pub product_type: ProductType,
1795 pub date: ProductDate,
1797 pub sample: String,
1799 pub issue: Option<String>,
1801}
1802
1803impl ProductSpec {
1804 pub fn new(
1806 center: AnalysisCenter,
1807 product_type: ProductType,
1808 date: ProductDate,
1809 sample: &str,
1810 issue: Option<&str>,
1811 ) -> Result<Self, DataCatalogError> {
1812 validate_product(center, product_type, sample, issue)?;
1813 Ok(Self {
1814 center,
1815 product_type,
1816 date,
1817 sample: sample.to_string(),
1818 issue: issue.map(ToOwned::to_owned),
1819 })
1820 }
1821
1822 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1824 self.date.gps_week()
1825 }
1826
1827 #[must_use]
1829 pub fn day_of_year(&self) -> u16 {
1830 self.date.day_of_year()
1831 }
1832
1833 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1835 let convention = validate_product(
1836 self.center,
1837 self.product_type,
1838 &self.sample,
1839 self.issue.as_deref(),
1840 )?;
1841 let descriptor = product_type_convention(self.product_type);
1842 Ok(match descriptor.kind {
1843 ProductFilenameKind::Sampled => format!(
1844 "{}_{}_{}_{}_{}.{}",
1845 convention.token,
1846 date_block(self.date, self.issue.as_deref()),
1847 convention.span,
1848 self.sample,
1849 descriptor.content_code,
1850 descriptor.extension
1851 ),
1852 ProductFilenameKind::Nav => format!(
1853 "{}_R_{}_{}_{}.{}",
1854 convention.token,
1855 date_block(self.date, None),
1856 convention.span,
1857 descriptor.content_code,
1858 descriptor.extension
1859 ),
1860 })
1861 }
1862
1863 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1865 let convention = validate_product(
1866 self.center,
1867 self.product_type,
1868 &self.sample,
1869 self.issue.as_deref(),
1870 )?;
1871 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1872 let filename = self.canonical_filename()?;
1873 Ok(format!(
1874 "{}/{}/{}{}",
1875 entry.root_url,
1876 product_dir_path(self.center, convention.layout, self.date)?,
1877 filename,
1878 convention.compression.suffix()
1879 ))
1880 }
1881
1882 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
1884 let convention = validate_product(
1885 self.center,
1886 self.product_type,
1887 &self.sample,
1888 self.issue.as_deref(),
1889 )?;
1890 let descriptor = product_type_convention(self.product_type);
1891 let campaign = match descriptor.kind {
1892 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
1893 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
1894 Some("OPS") => ProductCampaign::Operational,
1895 Some("MGN") => ProductCampaign::MultiGnss,
1896 Some("MGX") => ProductCampaign::MultiGnssExperiment,
1897 _ => {
1898 return Err(DataCatalogError::InconsistentProductIdentity {
1899 field: "campaign",
1900 });
1901 }
1902 },
1903 };
1904 let identity = ProductIdentity {
1905 family: self.product_type,
1906 analysis_center: self.center,
1907 publisher: self.center.publisher(),
1908 solution: self.center.solution_class(),
1909 campaign,
1910 version: 0,
1911 date: self.date,
1912 issue: match descriptor.kind {
1913 ProductFilenameKind::Sampled => {
1914 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
1915 }
1916 ProductFilenameKind::Nav => None,
1917 },
1918 span: convention.span.to_string(),
1919 sample: self.sample.clone(),
1920 official_filename: self.canonical_filename()?,
1921 format: product_format(self.product_type),
1922 format_version: None,
1923 prediction_horizon_days: self.center.prediction_horizon_days(),
1924 };
1925 identity.validate()?;
1926 Ok(identity)
1927 }
1928
1929 pub fn distribution_location(
1931 &self,
1932 source: DistributionSource,
1933 ) -> Result<DistributionLocation, DataCatalogError> {
1934 let identity = self.identity()?;
1935 match source {
1936 DistributionSource::Direct => {
1937 let compression = product_convention(self.center, self.product_type)?.compression;
1938 Ok(DistributionLocation {
1939 source,
1940 original_url: Some(self.archive_url()?),
1941 archive_filename: format!(
1942 "{}{}",
1943 identity.official_filename,
1944 compression.suffix()
1945 ),
1946 compression,
1947 })
1948 }
1949 DistributionSource::NasaCddis => {
1950 let url = cddis_archive_url(&identity)?;
1951 Ok(DistributionLocation {
1952 source,
1953 original_url: Some(url),
1954 archive_filename: format!("{}.gz", identity.official_filename),
1955 compression: ArchiveCompression::Gzip,
1956 })
1957 }
1958 DistributionSource::LocalFile | DistributionSource::InMemory => {
1959 Ok(DistributionLocation {
1960 source,
1961 original_url: None,
1962 archive_filename: identity.official_filename,
1963 compression: ArchiveCompression::None,
1964 })
1965 }
1966 }
1967 }
1968}
1969
1970#[derive(Debug, Clone, PartialEq, Eq)]
1972pub struct StationObservationSpec {
1973 pub station: String,
1975 pub date: ProductDate,
1977 pub sample: String,
1979}
1980
1981impl StationObservationSpec {
1982 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1984 validate_station(station)?;
1985 validate_sample(sample)?;
1986 Ok(Self {
1987 station: station.to_string(),
1988 date,
1989 sample: sample.to_string(),
1990 })
1991 }
1992
1993 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1995 station_obs_filename(&self.station, self.date, &self.sample)
1996 }
1997
1998 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2000 station_obs_url(&self.station, self.date, &self.sample)
2001 }
2002}
2003
2004#[must_use]
2006pub const fn catalog() -> &'static [CenterCatalogEntry] {
2007 &CATALOG
2008}
2009
2010#[must_use]
2012pub const fn centers() -> &'static [AnalysisCenter] {
2013 &CENTER_ORDER
2014}
2015
2016#[must_use]
2018pub const fn product_types() -> &'static [ProductTypeConvention] {
2019 &PRODUCT_TYPE_CONVENTIONS
2020}
2021
2022#[must_use]
2024pub const fn allowed_hosts() -> &'static [&'static str] {
2025 &ALLOWED_HOSTS
2026}
2027
2028#[must_use]
2030pub const fn skadi_source_entry() -> TerrainSourceEntry {
2031 SKADI_SOURCE
2032}
2033
2034#[must_use]
2036pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2037 CELESTRAK_SPACE_WEATHER_SOURCE
2038}
2039
2040#[must_use]
2042pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2043 match product {
2044 SpaceWeatherProduct::All => "SW-All.csv",
2045 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2046 }
2047}
2048
2049#[must_use]
2051pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2052 format!(
2053 "{}/{}",
2054 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2055 space_weather_filename(product)
2056 )
2057}
2058
2059#[must_use]
2061pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2062 format!("space-weather/{}", space_weather_filename(product))
2063}
2064
2065pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2067 validate_terrain_tile_index(lat_index, lon_index)?;
2068 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2069 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2070 Ok(format!(
2071 "{lat_hemi}{:02}{lon_hemi}{:03}",
2072 lat_index.abs(),
2073 lon_index.abs()
2074 ))
2075}
2076
2077pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2079 validate_terrain_lat_index(lat_index)?;
2080 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2081 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2082}
2083
2084pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2086 let band = skadi_band(lat_index)?;
2087 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2088 Ok(format!(
2089 "{}/skadi/{}/{}.hgt{}",
2090 SKADI_SOURCE.root_url,
2091 band,
2092 tile_id,
2093 SKADI_SOURCE.compression.suffix()
2094 ))
2095}
2096
2097pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2099 validate_terrain_tile_index(lat_index, lon_index)?;
2100 Ok(format!(
2101 "{}_{}{}",
2102 terrain::format_lat(lat_index),
2103 terrain::format_lon(lon_index),
2104 terrain::DTED_SUFFIX
2105 ))
2106}
2107
2108pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2110 validate_terrain_tile_index(lat_index, lon_index)?;
2111 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2112}
2113
2114pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2116 Ok(format!(
2117 "{}/{}",
2118 dted_block_dir(lat_index, lon_index)?,
2119 dted_tile_filename(lat_index, lon_index)?
2120 ))
2121}
2122
2123pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2125 let bytes = id.as_bytes();
2126 if bytes.len() != 7
2127 || !matches!(bytes[0], b'N' | b'S')
2128 || !matches!(bytes[3], b'E' | b'W')
2129 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2130 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2131 {
2132 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2133 }
2134
2135 let lat_abs = id[1..3]
2136 .parse::<i32>()
2137 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2138 let lon_abs = id[4..7]
2139 .parse::<i32>()
2140 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2141 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2142 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2143 }
2144
2145 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2146 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2147 validate_terrain_tile_index(lat_index, lon_index)?;
2148 Ok((lat_index, lon_index))
2149}
2150
2151pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2153 if !lat_deg.is_finite()
2154 || !lon_deg.is_finite()
2155 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2156 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2157 {
2158 return Err(DataCatalogError::InvalidCoordinate {
2159 lat_deg_bits: lat_deg.to_bits(),
2160 lon_deg_bits: lon_deg.to_bits(),
2161 });
2162 }
2163
2164 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2165 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2166 lat_index = MAX_TERRAIN_LAT_INDEX;
2167 }
2168 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2169 lon_index = MAX_TERRAIN_LON_INDEX;
2170 }
2171 validate_terrain_tile_index(lat_index, lon_index)?;
2172 Ok((lat_index, lon_index))
2173}
2174
2175pub fn hgt_to_dted(
2183 lat_index: i32,
2184 lon_index: i32,
2185 hgt: &[u8],
2186) -> Result<Vec<u8>, HgtConversionError> {
2187 validate_hgt_tile_index(lat_index, lon_index)?;
2188 if hgt.len() != SRTM1_HGT_LEN {
2189 return Err(HgtConversionError::BadLength {
2190 expected: SRTM1_HGT_LEN,
2191 got: hgt.len(),
2192 });
2193 }
2194
2195 let mut out = vec![b' '; DTED_SRTM1_LEN];
2196 out[0..4].copy_from_slice(b"UHL1");
2197 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2198 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2199 out[47..51].copy_from_slice(b"3601");
2200 out[51..55].copy_from_slice(b"3601");
2201
2202 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2203 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2204 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2205 out[block_start] = terrain::DATA_SENTINEL;
2206
2207 let count = (lon_posting as u32).to_be_bytes();
2208 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2209 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2210 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2211
2212 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2213 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2214 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2215 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2216 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2217 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2218 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2219 }
2220
2221 let checksum = out[block_start..checksum_start]
2222 .iter()
2223 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2224 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2225 }
2226
2227 debug_assert_eq!(out.len(), 25_981_042);
2228 Ok(out)
2229}
2230
2231#[must_use]
2233pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2234 &NO_OPEN_MIRRORS
2235}
2236
2237pub fn open_mirror(
2239 center: AnalysisCenter,
2240 product_type: ProductType,
2241) -> Result<(), DataCatalogError> {
2242 open_mirror_code(center.code(), product_type.code())
2243}
2244
2245pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2247 if NO_OPEN_MIRRORS
2248 .iter()
2249 .any(|entry| entry.center == center && entry.product_type == product_type)
2250 {
2251 Err(DataCatalogError::NoOpenMirror {
2252 center: center.to_string(),
2253 product_type: product_type.to_string(),
2254 })
2255 } else {
2256 Ok(())
2257 }
2258}
2259
2260#[must_use]
2262pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2263 CATALOG.iter().find(|entry| entry.center == center)
2264}
2265
2266pub fn product_convention(
2268 center: AnalysisCenter,
2269 product_type: ProductType,
2270) -> Result<&'static CenterProductConvention, DataCatalogError> {
2271 open_mirror(center, product_type)?;
2272 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2273 entry
2274 .products
2275 .iter()
2276 .find(|product| product.product_type == product_type)
2277 .ok_or(DataCatalogError::UnsupportedProduct {
2278 center,
2279 product_type,
2280 })
2281}
2282
2283pub fn default_sample(
2285 center: AnalysisCenter,
2286 product_type: ProductType,
2287) -> Result<&'static str, DataCatalogError> {
2288 Ok(product_convention(center, product_type)?.default_sample)
2289}
2290
2291pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2293 date.gps_week()
2294}
2295
2296#[must_use]
2298pub fn day_of_year(date: ProductDate) -> u16 {
2299 date.day_of_year()
2300}
2301
2302pub fn product(
2304 center: AnalysisCenter,
2305 product_type: ProductType,
2306 date: ProductDate,
2307 sample: Option<&str>,
2308 issue: Option<&str>,
2309) -> Result<ProductSpec, DataCatalogError> {
2310 let sample = match sample {
2311 Some(sample) => sample,
2312 None => default_sample(center, product_type)?,
2313 };
2314 ProductSpec::new(center, product_type, date, sample, issue)
2315}
2316
2317pub fn canonical_filename(
2319 center: AnalysisCenter,
2320 product_type: ProductType,
2321 date: ProductDate,
2322 sample: Option<&str>,
2323 issue: Option<&str>,
2324) -> Result<String, DataCatalogError> {
2325 product(center, product_type, date, sample, issue)?.canonical_filename()
2326}
2327
2328pub fn archive_url(
2330 center: AnalysisCenter,
2331 product_type: ProductType,
2332 date: ProductDate,
2333 sample: Option<&str>,
2334 issue: Option<&str>,
2335) -> Result<String, DataCatalogError> {
2336 product(center, product_type, date, sample, issue)?.archive_url()
2337}
2338
2339pub fn product_identity(
2341 center: AnalysisCenter,
2342 product_type: ProductType,
2343 date: ProductDate,
2344 sample: Option<&str>,
2345 issue: Option<&str>,
2346) -> Result<ProductIdentity, DataCatalogError> {
2347 product(center, product_type, date, sample, issue)?.identity()
2348}
2349
2350pub fn distribution_location(
2352 center: AnalysisCenter,
2353 product_type: ProductType,
2354 date: ProductDate,
2355 sample: Option<&str>,
2356 issue: Option<&str>,
2357 source: DistributionSource,
2358) -> Result<DistributionLocation, DataCatalogError> {
2359 product(center, product_type, date, sample, issue)?.distribution_location(source)
2360}
2361
2362pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2367 identity.validate()?;
2368 match identity.family {
2369 ProductType::Sp3 => Ok(format!(
2370 "https://cddis.nasa.gov/archive/gnss/products/{}/{}.gz",
2371 identity.date.gps_week()?,
2372 identity.official_filename
2373 )),
2374 ProductType::Ionex => Ok(format!(
2375 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2376 identity.date.year,
2377 identity.date.day_of_year(),
2378 identity.official_filename
2379 )),
2380 product_type => Err(DataCatalogError::UnsupportedDistribution {
2381 source: DistributionSource::NasaCddis,
2382 product_type,
2383 }),
2384 }
2385}
2386
2387pub fn mgex_clk(
2389 center: AnalysisCenter,
2390 date: ProductDate,
2391 sample: Option<&str>,
2392) -> Result<ProductSpec, DataCatalogError> {
2393 product(center, ProductType::Clk, date, sample, None)
2394}
2395
2396pub fn mgex_nav(
2398 center: AnalysisCenter,
2399 date: ProductDate,
2400 sample: Option<&str>,
2401) -> Result<ProductSpec, DataCatalogError> {
2402 product(center, ProductType::Nav, date, sample, None)
2403}
2404
2405pub fn mgex_ionex(
2407 center: AnalysisCenter,
2408 date: ProductDate,
2409 sample: Option<&str>,
2410) -> Result<ProductSpec, DataCatalogError> {
2411 product(center, ProductType::Ionex, date, sample, None)
2412}
2413
2414pub fn rapid_ionex(
2416 date: ProductDate,
2417 sample: Option<&str>,
2418) -> Result<ProductSpec, DataCatalogError> {
2419 product(
2420 AnalysisCenter::CodRap,
2421 ProductType::Ionex,
2422 date,
2423 sample,
2424 None,
2425 )
2426}
2427
2428#[must_use]
2430pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2431 match center {
2432 AnalysisCenter::CodPrd2 => 1,
2433 _ => 0,
2434 }
2435}
2436
2437pub fn predicted_ionex(
2439 center: AnalysisCenter,
2440 date: ProductDate,
2441 sample: Option<&str>,
2442) -> Result<ProductSpec, DataCatalogError> {
2443 match center {
2444 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2445 let target = date.add_days(predicted_day_offset(center))?;
2446 product(center, ProductType::Ionex, target, sample, None)
2447 }
2448 other => Err(DataCatalogError::UnsupportedProduct {
2449 center: other,
2450 product_type: ProductType::Ionex,
2451 }),
2452 }
2453}
2454
2455pub fn mgex_sp3(
2457 center: AnalysisCenter,
2458 date: ProductDate,
2459 sample: Option<&str>,
2460) -> Result<ProductSpec, DataCatalogError> {
2461 product(center, ProductType::Sp3, date, sample, None)
2462}
2463
2464pub fn ops_ultra_sp3(
2466 center: AnalysisCenter,
2467 date: ProductDate,
2468 sample: Option<&str>,
2469 issue: Option<&str>,
2470) -> Result<ProductSpec, DataCatalogError> {
2471 let issue = issue.unwrap_or("0000");
2472 product(center, ProductType::Sp3, date, sample, Some(issue))
2473}
2474
2475pub fn ultra_sp3_locations(
2482 center: AnalysisCenter,
2483 date: ProductDate,
2484 issue: &str,
2485) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2486 validate_issue_for_center(center, Some(issue))?;
2487 let patterns: &[UltraSp3Pattern] = match center {
2488 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2489 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2490 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2491 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2492 other => {
2493 return Err(DataCatalogError::UnsupportedProduct {
2494 center: other,
2495 product_type: ProductType::Sp3,
2496 })
2497 }
2498 };
2499 let convention = product_convention(center, ProductType::Sp3)?;
2500 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2501 let directory = dir_path(convention.layout, date)?;
2502 let date = date_block(date, Some(issue));
2503
2504 Ok(patterns
2505 .iter()
2506 .map(|pattern| {
2507 let filename = pattern.alias_filename.map_or_else(
2508 || {
2509 format!(
2510 "{}_{}_{}_{}_ORB.SP3",
2511 convention.token, date, pattern.span, pattern.sample
2512 )
2513 },
2514 ToOwned::to_owned,
2515 );
2516 UltraSp3Location {
2517 pattern: pattern.label.to_string(),
2518 span: pattern.span.to_string(),
2519 sample: pattern.sample.to_string(),
2520 url: format!(
2521 "{}/{}/{}{}",
2522 entry.root_url,
2523 directory,
2524 filename,
2525 convention.compression.suffix()
2526 ),
2527 filename,
2528 compression: convention.compression,
2529 }
2530 })
2531 .collect())
2532}
2533
2534pub fn ops_ultra_clk(
2536 center: AnalysisCenter,
2537 date: ProductDate,
2538 sample: Option<&str>,
2539 issue: Option<&str>,
2540) -> Result<ProductSpec, DataCatalogError> {
2541 let issue = issue.unwrap_or("0000");
2542 product(center, ProductType::Clk, date, sample, Some(issue))
2543}
2544
2545pub fn latest_ops_ultra_sp3(
2547 center: AnalysisCenter,
2548 target: ProductDateTime,
2549 sample: Option<&str>,
2550 available_issues: Option<&[UltraIssue]>,
2551) -> Result<ProductSpec, DataCatalogError> {
2552 let selected = latest_ultra_issue(center, target, available_issues)?;
2553 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2554}
2555
2556pub fn ultra_issue_candidates(
2558 center: AnalysisCenter,
2559 target: ProductDateTime,
2560) -> Result<Vec<UltraIssue>, DataCatalogError> {
2561 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2562 let _ = product_convention(center, ProductType::Sp3)?;
2563 if entry.issues.is_empty() {
2564 return Err(DataCatalogError::UnsupportedProduct {
2565 center,
2566 product_type: ProductType::Sp3,
2567 });
2568 }
2569
2570 let mut candidates = Vec::new();
2571 for date in [target.date, target.date.add_days(-1)?] {
2572 for issue in entry.issues.iter().rev() {
2573 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2574 candidates.push(UltraIssue::new(date, issue)?);
2575 }
2576 }
2577 }
2578 Ok(candidates)
2579}
2580
2581pub fn latest_ultra_issue(
2583 center: AnalysisCenter,
2584 target: ProductDateTime,
2585 available_issues: Option<&[UltraIssue]>,
2586) -> Result<UltraIssue, DataCatalogError> {
2587 let candidates = ultra_issue_candidates(center, target)?;
2588 if candidates.is_empty() {
2589 return Err(DataCatalogError::NoUltraIssue);
2590 }
2591 if let Some(available) = available_issues {
2592 candidates
2593 .into_iter()
2594 .find(|candidate| {
2595 available
2596 .iter()
2597 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2598 })
2599 .ok_or(DataCatalogError::NoAvailableUltraIssue)
2600 } else {
2601 Ok(candidates[0].clone())
2602 }
2603}
2604
2605pub fn gim_date_candidates(
2607 center: AnalysisCenter,
2608 target: ProductDate,
2609 lookback: u32,
2610) -> Result<Vec<ProductDate>, DataCatalogError> {
2611 let _ = product_convention(center, ProductType::Ionex)?;
2612 let base = target.add_days(predicted_day_offset(center))?;
2613 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2614 for back in 0..=lookback {
2615 out.push(base.add_days(-i64::from(back))?);
2616 }
2617 Ok(out)
2618}
2619
2620pub fn station_obs(
2622 station: &str,
2623 date: ProductDate,
2624 sample: Option<&str>,
2625) -> Result<StationObservationSpec, DataCatalogError> {
2626 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2627}
2628
2629pub fn station_obs_filename(
2631 station: &str,
2632 date: ProductDate,
2633 sample: &str,
2634) -> Result<String, DataCatalogError> {
2635 validate_station(station)?;
2636 validate_sample(sample)?;
2637 Ok(format!(
2638 "{}_R_{}_01D_{}_MO.crx",
2639 station,
2640 date_block(date, None),
2641 sample
2642 ))
2643}
2644
2645pub fn station_obs_url(
2647 station: &str,
2648 date: ProductDate,
2649 sample: &str,
2650) -> Result<String, DataCatalogError> {
2651 let filename = station_obs_filename(station, date, sample)?;
2652 Ok(format!(
2653 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
2654 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
2655 filename
2656 ))
2657}
2658
2659#[must_use]
2661pub const fn station_obs_protocol() -> ArchiveProtocol {
2662 ArchiveProtocol::Https
2663}
2664
2665fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
2666 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
2667 Ok(())
2668 } else {
2669 Err(DataCatalogError::InvalidTileIndex {
2670 lat_index,
2671 lon_index: 0,
2672 })
2673 }
2674}
2675
2676fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
2677 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2678 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2679 {
2680 Ok(())
2681 } else {
2682 Err(DataCatalogError::InvalidTileIndex {
2683 lat_index,
2684 lon_index,
2685 })
2686 }
2687}
2688
2689fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
2690 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
2691 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
2692 {
2693 Ok(())
2694 } else {
2695 Err(HgtConversionError::InvalidTileIndex {
2696 lat_index,
2697 lon_index,
2698 })
2699 }
2700}
2701
2702fn dted_coord_field(index: i32, is_longitude: bool) -> String {
2703 let hemi = match (is_longitude, index >= 0) {
2704 (true, true) => 'E',
2705 (true, false) => 'W',
2706 (false, true) => 'N',
2707 (false, false) => 'S',
2708 };
2709 format!("{:03}0000{hemi}", index.abs())
2710}
2711
2712fn encode_dted_signed_magnitude(sample: i16) -> u16 {
2713 if sample == i16::MIN {
2714 0
2715 } else if sample >= 0 {
2716 sample as u16
2717 } else {
2718 0x8000 | (-i32::from(sample) as u16)
2719 }
2720}
2721
2722fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
2723 PRODUCT_TYPE_CONVENTIONS
2724 .iter()
2725 .find(|descriptor| descriptor.product_type == product_type)
2726 .expect("product descriptor exists for enum variant")
2727}
2728
2729const fn product_format(product_type: ProductType) -> ProductFormat {
2730 match product_type {
2731 ProductType::Sp3 => ProductFormat::Sp3,
2732 ProductType::Ionex => ProductFormat::Ionex,
2733 ProductType::Clk => ProductFormat::RinexClock,
2734 ProductType::Nav => ProductFormat::RinexNavigation,
2735 }
2736}
2737
2738fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
2739 if filename.is_empty()
2740 || filename == "."
2741 || filename == ".."
2742 || filename.contains('/')
2743 || filename.contains('\\')
2744 || filename.contains('\0')
2745 || filename.contains("..")
2746 {
2747 Err(DataCatalogError::InvalidOfficialFilename(
2748 filename.to_string(),
2749 ))
2750 } else {
2751 Ok(())
2752 }
2753}
2754
2755fn validate_product(
2756 center: AnalysisCenter,
2757 product_type: ProductType,
2758 sample: &str,
2759 issue: Option<&str>,
2760) -> Result<&'static CenterProductConvention, DataCatalogError> {
2761 let convention = product_convention(center, product_type)?;
2762 validate_sample(sample)?;
2763 validate_issue_for_center(center, issue)?;
2764 Ok(convention)
2765}
2766
2767fn validate_issue_for_center(
2768 center: AnalysisCenter,
2769 issue: Option<&str>,
2770) -> Result<(), DataCatalogError> {
2771 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2772 match (entry.issues.is_empty(), issue) {
2773 (true, None) => Ok(()),
2774 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
2775 (false, None) => Err(DataCatalogError::MissingIssue { center }),
2776 (false, Some(issue)) => {
2777 validate_issue(issue)?;
2778 if entry.issues.contains(&issue) {
2779 Ok(())
2780 } else {
2781 Err(DataCatalogError::UnsupportedIssue {
2782 center,
2783 issue: issue.to_string(),
2784 })
2785 }
2786 }
2787 }
2788}
2789
2790fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
2791 let bytes = sample.as_bytes();
2792 let valid = bytes.len() == 3
2793 && bytes[0].is_ascii_digit()
2794 && bytes[1].is_ascii_digit()
2795 && bytes[2].is_ascii_uppercase();
2796 if valid {
2797 Ok(())
2798 } else {
2799 Err(DataCatalogError::InvalidSample(sample.to_string()))
2800 }
2801}
2802
2803fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2804 let bytes = issue.as_bytes();
2805 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2806 if !valid_digits {
2807 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2808 }
2809 let hour = issue[0..2]
2810 .parse::<u8>()
2811 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2812 let minute = issue[2..4]
2813 .parse::<u8>()
2814 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2815 if hour <= 23 && minute <= 59 {
2816 Ok(())
2817 } else {
2818 Err(DataCatalogError::InvalidIssue(issue.to_string()))
2819 }
2820}
2821
2822fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2823 let bytes = station.as_bytes();
2824 let valid = bytes.len() == 9
2825 && bytes
2826 .iter()
2827 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2828 if valid {
2829 Ok(())
2830 } else {
2831 Err(DataCatalogError::InvalidStation(station.to_string()))
2832 }
2833}
2834
2835fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2836 validate_issue(issue)?;
2837 let hour = issue[0..2]
2838 .parse::<u16>()
2839 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2840 let minute = issue[2..4]
2841 .parse::<u16>()
2842 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2843 Ok(hour * 60 + minute)
2844}
2845
2846fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2847 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2848}
2849
2850fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2851 format!(
2852 "{}{:03}{}",
2853 date.year,
2854 date.day_of_year(),
2855 issue.unwrap_or("0000")
2856 )
2857}
2858
2859fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2860 Ok(match layout {
2861 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2862 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2863 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2864 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2865 ArchiveLayout::BkgBrdcYearDoy => {
2866 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2867 }
2868 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2869 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2870 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2871 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2872 })
2873}
2874
2875fn product_dir_path(
2876 center: AnalysisCenter,
2877 layout: ArchiveLayout,
2878 date: ProductDate,
2879) -> Result<String, DataCatalogError> {
2880 match center {
2881 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
2882 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
2883 _ => dir_path(layout, date),
2884 }
2885}
2886
2887fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2888 let (year, month, day) = civil_from_julian_day_number(jdn);
2889 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2890 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2891 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2892 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2893}