1use core::fmt;
9use core::str::FromStr;
10use std::collections::{HashMap, HashSet};
11
12use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
13use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
14use crate::astro::time::model::TimeScale;
15use crate::astro::time::scales::julian_day_number;
16use crate::terrain;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub enum AnalysisCenter {
21 Igs,
23 CodRap,
25 CodPrd1,
27 CodPrd2,
29 Esa,
31 Cod,
33 Gfz,
35 IgsUlt,
37 CodUlt,
39 EsaUlt,
41 GfzUlt,
43}
44
45impl AnalysisCenter {
46 #[must_use]
48 pub const fn code(self) -> &'static str {
49 match self {
50 Self::Igs => "igs",
51 Self::CodRap => "cod_rap",
52 Self::CodPrd1 => "cod_prd1",
53 Self::CodPrd2 => "cod_prd2",
54 Self::Esa => "esa",
55 Self::Cod => "cod",
56 Self::Gfz => "gfz",
57 Self::IgsUlt => "igs_ult",
58 Self::CodUlt => "cod_ult",
59 Self::EsaUlt => "esa_ult",
60 Self::GfzUlt => "gfz_ult",
61 }
62 }
63
64 #[must_use]
66 pub fn from_code(code: &str) -> Option<Self> {
67 match code {
68 "igs" => Some(Self::Igs),
69 "cod_rap" => Some(Self::CodRap),
70 "cod_prd1" => Some(Self::CodPrd1),
71 "cod_prd2" => Some(Self::CodPrd2),
72 "esa" => Some(Self::Esa),
73 "cod" => Some(Self::Cod),
74 "gfz" => Some(Self::Gfz),
75 "igs_ult" => Some(Self::IgsUlt),
76 "cod_ult" => Some(Self::CodUlt),
77 "esa_ult" => Some(Self::EsaUlt),
78 "gfz_ult" => Some(Self::GfzUlt),
79 _ => None,
80 }
81 }
82
83 #[must_use]
85 pub const fn publisher(self) -> ProductPublisher {
86 match self {
87 Self::Igs | Self::IgsUlt => ProductPublisher::Igs,
88 Self::CodRap | Self::CodPrd1 | Self::CodPrd2 | Self::Cod | Self::CodUlt => {
89 ProductPublisher::Code
90 }
91 Self::Esa | Self::EsaUlt => ProductPublisher::Esa,
92 Self::Gfz | Self::GfzUlt => ProductPublisher::Gfz,
93 }
94 }
95
96 #[must_use]
102 pub const fn solution_class(self) -> SolutionClass {
103 match self {
104 Self::Igs => SolutionClass::Broadcast,
105 Self::CodRap | Self::Gfz => SolutionClass::Rapid,
106 Self::CodPrd1 | Self::CodPrd2 => SolutionClass::Predicted,
107 Self::Esa | Self::Cod => SolutionClass::Final,
108 Self::IgsUlt | Self::CodUlt | Self::EsaUlt | Self::GfzUlt => SolutionClass::UltraRapid,
109 }
110 }
111
112 #[must_use]
114 pub const fn prediction_horizon_days(self) -> Option<u8> {
115 match self {
116 Self::CodPrd1 => Some(1),
117 Self::CodPrd2 => Some(2),
118 _ => None,
119 }
120 }
121}
122
123impl fmt::Display for AnalysisCenter {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 f.write_str(self.code())
126 }
127}
128
129impl FromStr for AnalysisCenter {
130 type Err = DataCatalogError;
131
132 fn from_str(s: &str) -> Result<Self, Self::Err> {
133 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
139pub enum ProductType {
140 Sp3,
142 Clk,
144 Nav,
146 Ionex,
148}
149
150impl ProductType {
151 #[must_use]
153 pub const fn code(self) -> &'static str {
154 match self {
155 Self::Sp3 => "sp3",
156 Self::Clk => "clk",
157 Self::Nav => "nav",
158 Self::Ionex => "ionex",
159 }
160 }
161
162 #[must_use]
164 pub fn from_code(code: &str) -> Option<Self> {
165 match code {
166 "sp3" => Some(Self::Sp3),
167 "clk" => Some(Self::Clk),
168 "nav" => Some(Self::Nav),
169 "ionex" => Some(Self::Ionex),
170 _ => None,
171 }
172 }
173}
174
175impl fmt::Display for ProductType {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 f.write_str(self.code())
178 }
179}
180
181impl FromStr for ProductType {
182 type Err = DataCatalogError;
183
184 fn from_str(s: &str) -> Result<Self, Self::Err> {
185 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
195pub enum ProductPublisher {
196 Igs,
198 Code,
200 Esa,
202 Gfz,
204}
205
206impl ProductPublisher {
207 #[must_use]
209 pub const fn code(self) -> &'static str {
210 match self {
211 Self::Igs => "IGS",
212 Self::Code => "COD",
213 Self::Esa => "ESA",
214 Self::Gfz => "GFZ",
215 }
216 }
217}
218
219impl fmt::Display for ProductPublisher {
220 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221 f.write_str(self.code())
222 }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
227pub enum SolutionClass {
228 Final,
230 Rapid,
232 UltraRapid,
234 Predicted,
236 Broadcast,
238}
239
240impl SolutionClass {
241 #[must_use]
243 pub const fn code(self) -> &'static str {
244 match self {
245 Self::Final => "final",
246 Self::Rapid => "rapid",
247 Self::UltraRapid => "ultra_rapid",
248 Self::Predicted => "predicted",
249 Self::Broadcast => "broadcast",
250 }
251 }
252
253 #[must_use]
255 pub const fn filename_token(self) -> Option<&'static str> {
256 match self {
257 Self::Final => Some("FIN"),
258 Self::Rapid => Some("RAP"),
259 Self::UltraRapid => Some("ULT"),
260 Self::Predicted => Some("PRD"),
261 Self::Broadcast => None,
262 }
263 }
264}
265
266impl fmt::Display for SolutionClass {
267 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268 f.write_str(self.code())
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
274pub enum ProductCampaign {
275 Operational,
277 MultiGnss,
279 MultiGnssExperiment,
281 Broadcast,
283}
284
285impl ProductCampaign {
286 #[must_use]
288 pub const fn code(self) -> &'static str {
289 match self {
290 Self::Operational => "OPS",
291 Self::MultiGnss => "MGN",
292 Self::MultiGnssExperiment => "MGX",
293 Self::Broadcast => "BRD",
294 }
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
300pub enum ProductFormat {
301 Sp3,
303 Ionex,
305 RinexClock,
307 RinexNavigation,
309}
310
311impl ProductFormat {
312 #[must_use]
314 pub const fn code(self) -> &'static str {
315 match self {
316 Self::Sp3 => "SP3",
317 Self::Ionex => "IONEX",
318 Self::RinexClock => "RINEX_CLK",
319 Self::RinexNavigation => "RINEX_NAV",
320 }
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
329pub enum DistributionSource {
330 Direct,
332 NasaCddis,
334 LocalFile,
336 InMemory,
338}
339
340impl DistributionSource {
341 #[must_use]
343 pub const fn code(self) -> &'static str {
344 match self {
345 Self::Direct => "direct",
346 Self::NasaCddis => "nasa_cddis",
347 Self::LocalFile => "local_file",
348 Self::InMemory => "in_memory",
349 }
350 }
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
355pub enum SpaceWeatherProduct {
356 All,
358 Last5Years,
360}
361
362impl SpaceWeatherProduct {
363 #[must_use]
365 pub const fn code(self) -> &'static str {
366 match self {
367 Self::All => "sw_all",
368 Self::Last5Years => "sw_last5",
369 }
370 }
371
372 #[must_use]
374 pub fn from_code(code: &str) -> Option<Self> {
375 match code {
376 "sw_all" => Some(Self::All),
377 "sw_last5" => Some(Self::Last5Years),
378 _ => None,
379 }
380 }
381}
382
383impl fmt::Display for SpaceWeatherProduct {
384 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
385 f.write_str(self.code())
386 }
387}
388
389impl FromStr for SpaceWeatherProduct {
390 type Err = DataCatalogError;
391
392 fn from_str(s: &str) -> Result<Self, Self::Err> {
393 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
394 }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum ArchiveProtocol {
400 Http,
402 Https,
404}
405
406impl ArchiveProtocol {
407 #[must_use]
409 pub const fn as_str(self) -> &'static str {
410 match self {
411 Self::Http => "http",
412 Self::Https => "https",
413 }
414 }
415}
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
419pub enum ArchiveCompression {
420 Gzip,
422 UnixCompress,
424 None,
426}
427
428impl ArchiveCompression {
429 #[must_use]
431 pub const fn as_str(self) -> &'static str {
432 match self {
433 Self::Gzip => "gzip",
434 Self::UnixCompress => "unix_compress",
435 Self::None => "none",
436 }
437 }
438
439 const fn suffix(self) -> &'static str {
440 match self {
441 Self::Gzip => ".gz",
442 Self::UnixCompress => ".Z",
443 Self::None => "",
444 }
445 }
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
450pub enum ArchiveLayout {
451 GfzRapidWeek,
453 GfzUltraWeek,
455 GpsWeek,
457 BkgProductsWeek,
459 BkgBrdcYearDoy,
461 BkgObsYearDoy,
463 AiubCodeMgexYear,
465 AiubCodeYear,
467 AiubCodeRoot,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473pub enum ProductFilenameKind {
474 Sampled,
476 Nav,
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub struct ProductTypeConvention {
483 pub product_type: ProductType,
485 pub content_code: &'static str,
487 pub extension: &'static str,
489 pub kind: ProductFilenameKind,
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
495pub struct CenterProductConvention {
496 pub product_type: ProductType,
498 pub token: &'static str,
500 pub layout: ArchiveLayout,
502 pub span: &'static str,
504 pub default_sample: &'static str,
506 pub compression: ArchiveCompression,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub struct CenterCatalogEntry {
513 pub center: AnalysisCenter,
515 pub code: &'static str,
517 pub protocol: ArchiveProtocol,
519 pub host: &'static str,
521 pub root_url: &'static str,
523 pub products: &'static [CenterProductConvention],
525 pub issues: &'static [&'static str],
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub struct TerrainSourceEntry {
532 pub protocol: ArchiveProtocol,
534 pub host: &'static str,
536 pub compression: ArchiveCompression,
538 pub root_url: &'static str,
540}
541
542#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub struct SpaceWeatherSourceEntry {
545 pub protocol: ArchiveProtocol,
547 pub host: &'static str,
549 pub compression: ArchiveCompression,
551 pub root_url: &'static str,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
557pub struct NoOpenMirrorProduct {
558 pub center: &'static str,
560 pub product_type: &'static str,
562}
563
564const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
565 ProductTypeConvention {
566 product_type: ProductType::Sp3,
567 content_code: "ORB",
568 extension: "SP3",
569 kind: ProductFilenameKind::Sampled,
570 },
571 ProductTypeConvention {
572 product_type: ProductType::Clk,
573 content_code: "CLK",
574 extension: "CLK",
575 kind: ProductFilenameKind::Sampled,
576 },
577 ProductTypeConvention {
578 product_type: ProductType::Nav,
579 content_code: "MN",
580 extension: "rnx",
581 kind: ProductFilenameKind::Nav,
582 },
583 ProductTypeConvention {
584 product_type: ProductType::Ionex,
585 content_code: "GIM",
586 extension: "INX",
587 kind: ProductFilenameKind::Sampled,
588 },
589];
590
591const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
592 product_type: ProductType::Ionex,
593 token: "COD0OPSRAP",
594 layout: ArchiveLayout::AiubCodeRoot,
595 span: "01D",
596 default_sample: "01H",
597 compression: ArchiveCompression::Gzip,
598}];
599
600const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
601 product_type: ProductType::Ionex,
602 token: "COD0OPSPRD",
603 layout: ArchiveLayout::AiubCodeRoot,
604 span: "01D",
605 default_sample: "01H",
606 compression: ArchiveCompression::Gzip,
607}];
608
609const ESA_PRODUCTS: [CenterProductConvention; 3] = [
610 CenterProductConvention {
611 product_type: ProductType::Sp3,
612 token: "ESA0MGNFIN",
613 layout: ArchiveLayout::GpsWeek,
614 span: "01D",
615 default_sample: "05M",
616 compression: ArchiveCompression::Gzip,
617 },
618 CenterProductConvention {
619 product_type: ProductType::Clk,
620 token: "ESA0MGNFIN",
621 layout: ArchiveLayout::GpsWeek,
622 span: "01D",
623 default_sample: "30S",
624 compression: ArchiveCompression::Gzip,
625 },
626 CenterProductConvention {
627 product_type: ProductType::Ionex,
628 token: "ESA0OPSFIN",
629 layout: ArchiveLayout::GpsWeek,
630 span: "01D",
631 default_sample: "02H",
632 compression: ArchiveCompression::Gzip,
633 },
634];
635
636const COD_PRODUCTS: [CenterProductConvention; 3] = [
637 CenterProductConvention {
638 product_type: ProductType::Sp3,
639 token: "COD0MGXFIN",
640 layout: ArchiveLayout::AiubCodeMgexYear,
641 span: "01D",
642 default_sample: "05M",
643 compression: ArchiveCompression::Gzip,
644 },
645 CenterProductConvention {
646 product_type: ProductType::Clk,
647 token: "COD0MGXFIN",
648 layout: ArchiveLayout::AiubCodeMgexYear,
649 span: "01D",
650 default_sample: "30S",
651 compression: ArchiveCompression::Gzip,
652 },
653 CenterProductConvention {
654 product_type: ProductType::Ionex,
655 token: "COD0OPSFIN",
656 layout: ArchiveLayout::AiubCodeYear,
657 span: "01D",
658 default_sample: "01H",
659 compression: ArchiveCompression::Gzip,
660 },
661];
662
663const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
664 CenterProductConvention {
665 product_type: ProductType::Sp3,
666 token: "GFZ0OPSRAP",
667 layout: ArchiveLayout::GfzRapidWeek,
668 span: "01D",
669 default_sample: "05M",
670 compression: ArchiveCompression::Gzip,
671 },
672 CenterProductConvention {
673 product_type: ProductType::Clk,
674 token: "GFZ0OPSRAP",
675 layout: ArchiveLayout::GfzRapidWeek,
676 span: "01D",
677 default_sample: "30S",
678 compression: ArchiveCompression::Gzip,
679 },
680];
681
682const IGS_PRODUCTS: [CenterProductConvention; 2] = [
683 CenterProductConvention {
684 product_type: ProductType::Sp3,
685 token: "IGS0OPSFIN",
686 layout: ArchiveLayout::BkgProductsWeek,
687 span: "01D",
688 default_sample: "15M",
689 compression: ArchiveCompression::Gzip,
690 },
691 CenterProductConvention {
692 product_type: ProductType::Nav,
693 token: "BRDC00WRD",
694 layout: ArchiveLayout::BkgBrdcYearDoy,
695 span: "01D",
696 default_sample: "01D",
697 compression: ArchiveCompression::Gzip,
698 },
699];
700
701const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
702 product_type: ProductType::Sp3,
703 token: "IGS0OPSULT",
704 layout: ArchiveLayout::BkgProductsWeek,
705 span: "02D",
706 default_sample: "15M",
707 compression: ArchiveCompression::Gzip,
708}];
709
710const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
711 product_type: ProductType::Sp3,
712 token: "COD0OPSULT",
713 layout: ArchiveLayout::AiubCodeRoot,
714 span: "01D",
715 default_sample: "05M",
716 compression: ArchiveCompression::None,
717}];
718
719const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
720 product_type: ProductType::Sp3,
721 token: "ESA0OPSULT",
722 layout: ArchiveLayout::GpsWeek,
723 span: "02D",
724 default_sample: "05M",
725 compression: ArchiveCompression::Gzip,
726}];
727
728const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
729 product_type: ProductType::Sp3,
730 token: "GFZ0OPSULT",
731 layout: ArchiveLayout::GfzUltraWeek,
732 span: "02D",
733 default_sample: "05M",
734 compression: ArchiveCompression::Gzip,
735}];
736
737const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
738const COD_ULT_ISSUES: [&str; 1] = ["0000"];
739const GFZ_ULT_ISSUES: [&str; 8] = [
740 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
741];
742
743const IGS_COMBINED_FINAL_START_GPS_WEEK: u32 = 730;
747
748const IGS_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
754
755const CODE_LONG_FILENAME_START_GPS_WEEK: u32 = 2238;
758
759const GFZ_RAPID_5M_START_DATE: ProductDate = ProductDate {
764 year: 2021,
765 month: 5,
766 day: 18,
767};
768
769const ESA_FINAL_SERIES_START_DATE: ProductDate = ProductDate {
771 year: 2014,
772 month: 1,
773 day: 5,
774};
775
776const GFZ_RAPID_SERIES_START_DATE: ProductDate = ProductDate {
778 year: 2020,
779 month: 5,
780 day: 13,
781};
782
783const ESA_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
785 year: 2022,
786 month: 10,
787 day: 4,
788};
789
790const ESA_ULTRA_15M_LAST_DATE: ProductDate = ProductDate {
792 year: 2025,
793 month: 2,
794 day: 2,
795};
796const ESA_ULTRA_15M_LAST_ISSUE_MINUTES: u16 = 6 * 60;
797
798const GFZ_ULTRA_SP3_START_DATE: ProductDate = ProductDate {
800 year: 2020,
801 month: 10,
802 day: 6,
803};
804
805const GFZ_ULTRA_5M_START_DATE: ProductDate = ProductDate {
807 year: 2021,
808 month: 5,
809 day: 16,
810};
811
812const CENTER_ORDER: [AnalysisCenter; 11] = [
813 AnalysisCenter::CodRap,
814 AnalysisCenter::CodPrd1,
815 AnalysisCenter::CodPrd2,
816 AnalysisCenter::Igs,
817 AnalysisCenter::Esa,
818 AnalysisCenter::Cod,
819 AnalysisCenter::Gfz,
820 AnalysisCenter::IgsUlt,
821 AnalysisCenter::CodUlt,
822 AnalysisCenter::EsaUlt,
823 AnalysisCenter::GfzUlt,
824];
825
826const CATALOG: [CenterCatalogEntry; 11] = [
827 CenterCatalogEntry {
828 center: AnalysisCenter::CodRap,
829 code: "cod_rap",
830 protocol: ArchiveProtocol::Https,
831 host: "www.aiub.unibe.ch",
832 root_url: "https://www.aiub.unibe.ch/download",
833 products: &COD_RAP_PRODUCTS,
834 issues: &[],
835 },
836 CenterCatalogEntry {
837 center: AnalysisCenter::CodPrd1,
838 code: "cod_prd1",
839 protocol: ArchiveProtocol::Https,
840 host: "www.aiub.unibe.ch",
841 root_url: "https://www.aiub.unibe.ch/download",
842 products: &COD_PRD_PRODUCTS,
843 issues: &[],
844 },
845 CenterCatalogEntry {
846 center: AnalysisCenter::CodPrd2,
847 code: "cod_prd2",
848 protocol: ArchiveProtocol::Https,
849 host: "www.aiub.unibe.ch",
850 root_url: "https://www.aiub.unibe.ch/download",
851 products: &COD_PRD_PRODUCTS,
852 issues: &[],
853 },
854 CenterCatalogEntry {
855 center: AnalysisCenter::Igs,
856 code: "igs",
857 protocol: ArchiveProtocol::Https,
858 host: "igs.bkg.bund.de",
859 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
860 products: &IGS_PRODUCTS,
861 issues: &[],
862 },
863 CenterCatalogEntry {
864 center: AnalysisCenter::Esa,
865 code: "esa",
866 protocol: ArchiveProtocol::Https,
867 host: "navigation-office.esa.int",
868 root_url: "https://navigation-office.esa.int/products/gnss-products",
869 products: &ESA_PRODUCTS,
870 issues: &[],
871 },
872 CenterCatalogEntry {
873 center: AnalysisCenter::Cod,
874 code: "cod",
875 protocol: ArchiveProtocol::Https,
876 host: "www.aiub.unibe.ch",
877 root_url: "https://www.aiub.unibe.ch/download",
878 products: &COD_PRODUCTS,
879 issues: &[],
880 },
881 CenterCatalogEntry {
882 center: AnalysisCenter::Gfz,
883 code: "gfz",
884 protocol: ArchiveProtocol::Https,
885 host: "isdc-data.gfz.de",
886 root_url: "https://isdc-data.gfz.de/gnss/products",
887 products: &GFZ_PRODUCTS,
888 issues: &[],
889 },
890 CenterCatalogEntry {
891 center: AnalysisCenter::IgsUlt,
892 code: "igs_ult",
893 protocol: ArchiveProtocol::Https,
894 host: "igs.bkg.bund.de",
895 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
896 products: &IGS_ULT_PRODUCTS,
897 issues: &OPSULT_ISSUES,
898 },
899 CenterCatalogEntry {
900 center: AnalysisCenter::CodUlt,
901 code: "cod_ult",
902 protocol: ArchiveProtocol::Https,
903 host: "www.aiub.unibe.ch",
904 root_url: "https://www.aiub.unibe.ch/download",
908 products: &COD_ULT_PRODUCTS,
909 issues: &COD_ULT_ISSUES,
910 },
911 CenterCatalogEntry {
912 center: AnalysisCenter::EsaUlt,
913 code: "esa_ult",
914 protocol: ArchiveProtocol::Https,
915 host: "navigation-office.esa.int",
916 root_url: "https://navigation-office.esa.int/products/gnss-products",
917 products: &ESA_ULT_PRODUCTS,
918 issues: &OPSULT_ISSUES,
919 },
920 CenterCatalogEntry {
921 center: AnalysisCenter::GfzUlt,
922 code: "gfz_ult",
923 protocol: ArchiveProtocol::Https,
924 host: "isdc-data.gfz.de",
925 root_url: "https://isdc-data.gfz.de/gnss/products",
926 products: &GFZ_ULT_PRODUCTS,
927 issues: &GFZ_ULT_ISSUES,
928 },
929];
930
931const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
932 protocol: ArchiveProtocol::Https,
933 host: "s3.amazonaws.com",
934 compression: ArchiveCompression::Gzip,
935 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
936};
937
938const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
939 protocol: ArchiveProtocol::Https,
940 host: "celestrak.org",
941 compression: ArchiveCompression::None,
942 root_url: "https://celestrak.org/SpaceData",
943};
944
945const ALLOWED_HOSTS: [&str; 10] = [
946 "www.aiub.unibe.ch",
947 "download.aiub.unibe.ch",
948 "zhw-b.s3.cloud.switch.ch",
949 "navigation-office.esa.int",
950 "isdc-data.gfz.de",
951 "igs.bkg.bund.de",
952 "s3.amazonaws.com",
953 "celestrak.org",
954 "cddis.nasa.gov",
955 "urs.earthdata.nasa.gov",
956];
957
958const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
959 NoOpenMirrorProduct {
960 center: "grg",
961 product_type: "sp3",
962 },
963 NoOpenMirrorProduct {
964 center: "grg",
965 product_type: "clk",
966 },
967 NoOpenMirrorProduct {
968 center: "wum",
969 product_type: "sp3",
970 },
971 NoOpenMirrorProduct {
972 center: "wum",
973 product_type: "clk",
974 },
975 NoOpenMirrorProduct {
976 center: "grg_ult",
977 product_type: "sp3",
978 },
979 NoOpenMirrorProduct {
980 center: "grg_ult",
981 product_type: "clk",
982 },
983 NoOpenMirrorProduct {
984 center: "igs",
985 product_type: "ionex",
986 },
987];
988
989#[derive(Debug, Clone, PartialEq, Eq)]
991pub enum DataCatalogError {
992 UnknownCenter(String),
994 UnknownProductType(String),
996 UnsupportedProduct {
998 center: AnalysisCenter,
1000 product_type: ProductType,
1002 },
1003 UnsupportedDistribution {
1005 source: DistributionSource,
1007 product_type: ProductType,
1009 },
1010 UnsupportedProductEra {
1012 center: AnalysisCenter,
1014 product_type: ProductType,
1016 date: ProductDate,
1018 },
1019 UnsupportedDistributionEra {
1021 source: DistributionSource,
1023 center: AnalysisCenter,
1025 product_type: ProductType,
1027 date: ProductDate,
1029 },
1030 NoDistributionSources,
1032 InvalidOfficialFilename(String),
1034 InconsistentProductIdentity {
1036 field: &'static str,
1038 },
1039 NoOpenMirror {
1041 center: String,
1043 product_type: String,
1045 },
1046 InvalidDate {
1048 year: i32,
1050 month: u8,
1052 day: u8,
1054 },
1055 DateOutOfRange,
1057 DateBeforeGpsEpoch(ProductDate),
1059 InvalidGpsDayOfWeek(u8),
1061 InvalidSample(String),
1063 UnsupportedSample {
1065 center: AnalysisCenter,
1067 product_type: ProductType,
1069 sample: String,
1071 },
1072 InvalidSpan(String),
1074 InvalidIssue(String),
1076 MissingIssue {
1078 center: AnalysisCenter,
1080 },
1081 UnexpectedIssue {
1083 center: AnalysisCenter,
1085 },
1086 UnsupportedIssue {
1088 center: AnalysisCenter,
1090 issue: String,
1092 },
1093 InvalidDateTime {
1095 hour: u8,
1097 minute: u8,
1099 second: u8,
1101 },
1102 NoUltraIssue,
1104 NoAvailableUltraIssue,
1106 InvalidStation(String),
1108 InvalidCoordinate {
1110 lat_deg_bits: u64,
1112 lon_deg_bits: u64,
1114 },
1115 InvalidTileIndex {
1117 lat_index: i32,
1119 lon_index: i32,
1121 },
1122 InvalidTileId(String),
1124}
1125
1126impl fmt::Display for DataCatalogError {
1127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1128 match self {
1129 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
1130 Self::UnknownProductType(product_type) => {
1131 write!(f, "unknown product type {product_type:?}")
1132 }
1133 Self::UnsupportedProduct {
1134 center,
1135 product_type,
1136 } => write!(f, "{center} does not serve {product_type}"),
1137 Self::UnsupportedDistribution {
1138 source,
1139 product_type,
1140 } => write!(
1141 f,
1142 "distributor {} does not serve {product_type}",
1143 source.code()
1144 ),
1145 Self::UnsupportedProductEra {
1146 center,
1147 product_type,
1148 date,
1149 } => write!(
1150 f,
1151 "{center}/{product_type} has no cataloged naming convention for {date}"
1152 ),
1153 Self::UnsupportedDistributionEra {
1154 source,
1155 center,
1156 product_type,
1157 date,
1158 } => write!(
1159 f,
1160 "distributor {} has no cataloged {center}/{product_type} layout for {date}",
1161 source.code()
1162 ),
1163 Self::NoDistributionSources => {
1164 write!(f, "exact product request has no distributors")
1165 }
1166 Self::InvalidOfficialFilename(filename) => {
1167 write!(f, "invalid official product filename {filename:?}")
1168 }
1169 Self::InconsistentProductIdentity { field } => {
1170 write!(
1171 f,
1172 "product identity field {field:?} disagrees with its official filename"
1173 )
1174 }
1175 Self::NoOpenMirror {
1176 center,
1177 product_type,
1178 } => write!(f, "{center}/{product_type} has no open mirror"),
1179 Self::InvalidDate { year, month, day } => {
1180 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
1181 }
1182 Self::DateOutOfRange => write!(f, "product date is out of range"),
1183 Self::DateBeforeGpsEpoch(date) => {
1184 write!(f, "product date {date} is before the GPS week epoch")
1185 }
1186 Self::InvalidGpsDayOfWeek(day) => {
1187 write!(f, "invalid GPS day-of-week {day}")
1188 }
1189 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
1190 Self::UnsupportedSample {
1191 center,
1192 product_type,
1193 sample,
1194 } => write!(
1195 f,
1196 "{center}/{product_type} does not publish sample interval {sample:?}"
1197 ),
1198 Self::InvalidSpan(span) => write!(f, "invalid coverage span {span:?}"),
1199 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
1200 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
1201 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
1202 Self::UnsupportedIssue { center, issue } => {
1203 write!(f, "{center} does not publish issue {issue:?}")
1204 }
1205 Self::InvalidDateTime {
1206 hour,
1207 minute,
1208 second,
1209 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
1210 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
1211 Self::NoAvailableUltraIssue => {
1212 write!(f, "no available ultra-rapid issue at or before target")
1213 }
1214 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
1215 Self::InvalidCoordinate {
1216 lat_deg_bits,
1217 lon_deg_bits,
1218 } => write!(
1219 f,
1220 "invalid terrain coordinate lat={} lon={}",
1221 f64::from_bits(*lat_deg_bits),
1222 f64::from_bits(*lon_deg_bits)
1223 ),
1224 Self::InvalidTileIndex {
1225 lat_index,
1226 lon_index,
1227 } => write!(
1228 f,
1229 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1230 ),
1231 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
1232 }
1233 }
1234}
1235
1236impl std::error::Error for DataCatalogError {}
1237
1238#[derive(Debug, Clone, PartialEq, Eq)]
1240pub enum HgtConversionError {
1241 BadLength {
1243 expected: usize,
1245 got: usize,
1247 },
1248 InvalidTileIndex {
1250 lat_index: i32,
1252 lon_index: i32,
1254 },
1255}
1256
1257impl fmt::Display for HgtConversionError {
1258 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1259 match self {
1260 Self::BadLength { expected, got } => {
1261 write!(
1262 f,
1263 "invalid SRTM1 HGT length: expected {expected}, got {got}"
1264 )
1265 }
1266 Self::InvalidTileIndex {
1267 lat_index,
1268 lon_index,
1269 } => write!(
1270 f,
1271 "invalid terrain tile index lat={lat_index} lon={lon_index}"
1272 ),
1273 }
1274 }
1275}
1276
1277impl std::error::Error for HgtConversionError {}
1278
1279const MIN_TERRAIN_LAT_INDEX: i32 = -90;
1280const MAX_TERRAIN_LAT_INDEX: i32 = 89;
1281const MIN_TERRAIN_LON_INDEX: i32 = -180;
1282const MAX_TERRAIN_LON_INDEX: i32 = 179;
1283const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
1284const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
1285const MIN_TERRAIN_LON_DEG: f64 = -180.0;
1286const MAX_TERRAIN_LON_DEG: f64 = 180.0;
1287const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
1288const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
1289const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
1290const DTED_SRTM1_LEN: usize =
1291 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
1292
1293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1295pub struct ProductDate {
1296 pub year: i32,
1298 pub month: u8,
1300 pub day: u8,
1302}
1303
1304impl ProductDate {
1305 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
1307 let days = days_in_month(i64::from(year), i64::from(month));
1308 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
1309 return Err(DataCatalogError::InvalidDate { year, month, day });
1310 }
1311 Ok(Self { year, month, day })
1312 }
1313
1314 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
1316 if day_of_week > 6 {
1317 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
1318 }
1319 let epoch_jdn =
1320 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1321 let offset_days = i64::from(week)
1322 .checked_mul(7)
1323 .and_then(|days| days.checked_add(i64::from(day_of_week)))
1324 .ok_or(DataCatalogError::DateOutOfRange)?;
1325 product_date_from_jdn(
1326 epoch_jdn
1327 .checked_add(offset_days)
1328 .ok_or(DataCatalogError::DateOutOfRange)?,
1329 )
1330 }
1331
1332 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
1334 week_from_calendar(
1335 TimeScale::Gpst,
1336 i64::from(self.year),
1337 i64::from(self.month),
1338 i64::from(self.day),
1339 )
1340 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
1341 }
1342
1343 pub fn gps_day_of_week(self) -> Result<u8, DataCatalogError> {
1345 let epoch_jdn =
1346 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
1347 let days = self
1348 .julian_day_number()
1349 .checked_sub(epoch_jdn)
1350 .ok_or(DataCatalogError::DateOutOfRange)?;
1351 if days < 0 {
1352 return Err(DataCatalogError::DateBeforeGpsEpoch(self));
1353 }
1354 u8::try_from(days.rem_euclid(7)).map_err(|_| DataCatalogError::DateOutOfRange)
1355 }
1356
1357 #[must_use]
1359 pub fn day_of_year(self) -> u16 {
1360 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
1361 }
1362
1363 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
1364 product_date_from_jdn(
1365 self.julian_day_number()
1366 .checked_add(days)
1367 .ok_or(DataCatalogError::DateOutOfRange)?,
1368 )
1369 }
1370
1371 fn julian_day_number(self) -> i64 {
1372 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
1373 }
1374}
1375
1376impl fmt::Display for ProductDate {
1377 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1378 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
1379 }
1380}
1381
1382#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1384pub struct ProductDateTime {
1385 pub date: ProductDate,
1387 pub hour: u8,
1389 pub minute: u8,
1391 pub second: u8,
1393}
1394
1395impl ProductDateTime {
1396 pub fn new(
1398 date: ProductDate,
1399 hour: u8,
1400 minute: u8,
1401 second: u8,
1402 ) -> Result<Self, DataCatalogError> {
1403 if hour > 23 || minute > 59 || second > 59 {
1404 return Err(DataCatalogError::InvalidDateTime {
1405 hour,
1406 minute,
1407 second,
1408 });
1409 }
1410 Ok(Self {
1411 date,
1412 hour,
1413 minute,
1414 second,
1415 })
1416 }
1417
1418 fn ordering_minutes(self) -> i64 {
1419 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1420 }
1421}
1422
1423#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1425pub struct UltraIssue {
1426 pub date: ProductDate,
1428 pub issue: String,
1430}
1431
1432impl UltraIssue {
1433 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1435 validate_issue(issue)?;
1436 Ok(Self {
1437 date,
1438 issue: issue.to_string(),
1439 })
1440 }
1441}
1442
1443#[derive(Debug, Clone, PartialEq, Eq)]
1445pub struct UltraSp3Location {
1446 pub pattern: String,
1448 pub span: String,
1450 pub sample: String,
1452 pub filename: String,
1454 pub url: String,
1456 pub compression: ArchiveCompression,
1458}
1459
1460#[derive(Debug, Clone, Copy)]
1461struct UltraSp3Pattern {
1462 label: &'static str,
1463 span: &'static str,
1464 sample: &'static str,
1465 alias_filename: Option<&'static str>,
1466}
1467
1468const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1469 UltraSp3Pattern {
1470 label: "primary_02D_15M",
1471 span: "02D",
1472 sample: "15M",
1473 alias_filename: None,
1474 },
1475 UltraSp3Pattern {
1476 label: "alternate_02D_05M",
1477 span: "02D",
1478 sample: "05M",
1479 alias_filename: None,
1480 },
1481 UltraSp3Pattern {
1482 label: "alternate_01D_15M",
1483 span: "01D",
1484 sample: "15M",
1485 alias_filename: None,
1486 },
1487];
1488
1489const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1490 UltraSp3Pattern {
1491 label: "primary_01D_05M",
1492 span: "01D",
1493 sample: "05M",
1494 alias_filename: None,
1495 },
1496 UltraSp3Pattern {
1497 label: "alternate_02D_05M",
1498 span: "02D",
1499 sample: "05M",
1500 alias_filename: None,
1501 },
1502 UltraSp3Pattern {
1503 label: "alias_latest",
1504 span: "01D",
1505 sample: "05M",
1506 alias_filename: Some("COD0OPSULT.SP3"),
1507 },
1508];
1509
1510const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1511 UltraSp3Pattern {
1512 label: "primary_02D_05M",
1513 span: "02D",
1514 sample: "05M",
1515 alias_filename: None,
1516 },
1517 UltraSp3Pattern {
1518 label: "alternate_02D_15M",
1519 span: "02D",
1520 sample: "15M",
1521 alias_filename: None,
1522 },
1523 UltraSp3Pattern {
1524 label: "alternate_01D_05M",
1525 span: "01D",
1526 sample: "05M",
1527 alias_filename: None,
1528 },
1529];
1530
1531const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1532 UltraSp3Pattern {
1533 label: "primary_02D_05M",
1534 span: "02D",
1535 sample: "05M",
1536 alias_filename: None,
1537 },
1538 UltraSp3Pattern {
1539 label: "alternate_02D_15M",
1540 span: "02D",
1541 sample: "15M",
1542 alias_filename: None,
1543 },
1544 UltraSp3Pattern {
1545 label: "alternate_01D_05M",
1546 span: "01D",
1547 sample: "05M",
1548 alias_filename: None,
1549 },
1550];
1551
1552#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1558pub struct ProductIdentity {
1559 pub family: ProductType,
1561 pub analysis_center: AnalysisCenter,
1563 pub publisher: ProductPublisher,
1565 pub solution: SolutionClass,
1567 pub campaign: ProductCampaign,
1569 pub version: u8,
1571 pub date: ProductDate,
1573 pub issue: Option<String>,
1575 pub span: String,
1577 pub sample: String,
1579 pub official_filename: String,
1581 pub format: ProductFormat,
1583 pub format_version: Option<String>,
1589 pub prediction_horizon_days: Option<u8>,
1591}
1592
1593impl ProductIdentity {
1594 pub fn validate(&self) -> Result<(), DataCatalogError> {
1600 validate_official_filename(&self.official_filename)?;
1601 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
1602 validate_sample(&self.sample)?;
1603 validate_span(&self.span)?;
1604 if let Some(issue) = self.issue.as_deref() {
1605 validate_issue(issue)?;
1606 }
1607
1608 let convention = product_convention(self.analysis_center, self.family)?;
1612 validate_product_date(self.analysis_center, self.family, self.date)?;
1613 if self.analysis_center == AnalysisCenter::Igs
1614 && self.family == ProductType::Sp3
1615 && self.span != convention.span
1616 {
1617 return Err(DataCatalogError::InconsistentProductIdentity { field: "span" });
1618 }
1619 validate_catalog_sample(self.analysis_center, self.family, &self.sample, convention)?;
1620
1621 if self.format != product_format(self.family) {
1622 return Err(DataCatalogError::InconsistentProductIdentity { field: "format" });
1623 }
1624
1625 if self
1626 .format_version
1627 .as_deref()
1628 .is_some_and(|value| value.is_empty() || value.as_bytes().contains(&0))
1629 {
1630 return Err(DataCatalogError::InconsistentProductIdentity {
1631 field: "format_version",
1632 });
1633 }
1634
1635 let horizon_valid = match (self.publisher, self.solution, self.prediction_horizon_days) {
1636 (ProductPublisher::Code, SolutionClass::Predicted, Some(1 | 2)) => true,
1637 (_, SolutionClass::Predicted, _) => false,
1638 (_, _, None) => true,
1639 (_, _, Some(_)) => false,
1640 };
1641 if !horizon_valid {
1642 return Err(DataCatalogError::InconsistentProductIdentity {
1643 field: "prediction_horizon_days",
1644 });
1645 }
1646 let descriptor = product_type_convention(self.family);
1647 let legacy_igs_final =
1648 uses_legacy_igs_final_name(self.analysis_center, self.family, self.date)?;
1649 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1650 let entry = center_catalog(self.analysis_center)
1651 .expect("validated analysis center has a catalog entry");
1652 let issue_valid = if entry.issues.is_empty() {
1653 self.issue.as_deref() == Some("0000")
1654 } else {
1655 self.issue
1656 .as_deref()
1657 .is_some_and(|issue| entry.issues.contains(&issue))
1658 };
1659 if !issue_valid {
1660 return Err(DataCatalogError::InconsistentProductIdentity { field: "issue" });
1661 }
1662 }
1663 let expected = if legacy_igs_final {
1664 let fields_valid = self.publisher == ProductPublisher::Igs
1665 && self.solution == SolutionClass::Final
1666 && self.campaign == ProductCampaign::Operational
1667 && self.version == 0
1668 && self.issue.as_deref() == Some("0000")
1669 && self.span == convention.span
1670 && self.sample == convention.default_sample;
1671 if !fields_valid {
1672 return Err(DataCatalogError::InconsistentProductIdentity {
1673 field: "legacy_igs_final",
1674 });
1675 }
1676 format!(
1677 "igs{:04}{}.sp3",
1678 self.date.gps_week()?,
1679 self.date.gps_day_of_week()?
1680 )
1681 } else {
1682 match descriptor.kind {
1683 ProductFilenameKind::Sampled => {
1684 let solution_token = self.solution.filename_token().ok_or(
1685 DataCatalogError::InconsistentProductIdentity { field: "solution" },
1686 )?;
1687 format!(
1688 "{}{}{}{}_{}_{}_{}_{}.{}",
1689 self.publisher.code(),
1690 self.version,
1691 self.campaign.code(),
1692 solution_token,
1693 date_block(self.date, self.issue.as_deref()),
1694 self.span,
1695 self.sample,
1696 descriptor.content_code,
1697 descriptor.extension
1698 )
1699 }
1700 ProductFilenameKind::Nav => {
1701 let nav_fields_valid = self.publisher == ProductPublisher::Igs
1702 && self.solution == SolutionClass::Broadcast
1703 && self.campaign == ProductCampaign::Broadcast
1704 && self.version == 0
1705 && self.issue.is_none()
1706 && self.span == "01D"
1707 && self.sample == "01D";
1708 if !nav_fields_valid {
1709 return Err(DataCatalogError::InconsistentProductIdentity {
1710 field: "broadcast_navigation",
1711 });
1712 }
1713 format!(
1714 "BRDC00WRD_R_{}_{}_{}.{}",
1715 date_block(self.date, None),
1716 self.span,
1717 descriptor.content_code,
1718 descriptor.extension
1719 )
1720 }
1721 }
1722 };
1723 if expected != self.official_filename {
1724 return Err(DataCatalogError::InconsistentProductIdentity {
1725 field: "official_filename",
1726 });
1727 }
1728 if self.publisher != self.analysis_center.publisher()
1729 || self.solution != product_solution_class(self.analysis_center, self.family)?
1730 || self.prediction_horizon_days != self.analysis_center.prediction_horizon_days()
1731 {
1732 return Err(DataCatalogError::InconsistentProductIdentity {
1733 field: "analysis_center",
1734 });
1735 }
1736
1737 if !legacy_igs_final && descriptor.kind == ProductFilenameKind::Sampled {
1738 let expected_catalog_filename = format!(
1739 "{}_{}_{}_{}_{}.{}",
1740 convention.token,
1741 date_block(self.date, self.issue.as_deref()),
1742 self.span,
1743 self.sample,
1744 descriptor.content_code,
1745 descriptor.extension
1746 );
1747 if expected_catalog_filename != self.official_filename {
1748 return Err(DataCatalogError::InconsistentProductIdentity {
1749 field: "analysis_center",
1750 });
1751 }
1752 }
1753 Ok(())
1754 }
1755
1756 pub fn key(&self) -> Result<String, DataCatalogError> {
1758 use sha2::{Digest, Sha256};
1759
1760 let canonical = self.canonical_bytes()?;
1761 let digest = Sha256::digest(canonical);
1762 Ok(format!(
1763 "{}-{}-{}",
1764 self.publisher.code().to_ascii_lowercase(),
1765 self.solution.code(),
1766 digest[..10]
1767 .iter()
1768 .map(|byte| format!("{byte:02x}"))
1769 .collect::<String>()
1770 ))
1771 }
1772
1773 pub fn canonical_bytes(&self) -> Result<Vec<u8>, DataCatalogError> {
1779 self.validate()?;
1780 let date = format!(
1781 "{:04}-{:02}-{:02}",
1782 self.date.year, self.date.month, self.date.day
1783 );
1784 let version = self.version.to_string();
1785 let prediction = self
1786 .prediction_horizon_days
1787 .map(|days| days.to_string())
1788 .unwrap_or_default();
1789 let fields = [
1790 self.family.code(),
1791 self.analysis_center.code(),
1792 self.publisher.code(),
1793 self.solution.code(),
1794 self.campaign.code(),
1795 version.as_str(),
1796 date.as_str(),
1797 self.issue.as_deref().unwrap_or_default(),
1798 self.span.as_str(),
1799 self.sample.as_str(),
1800 self.official_filename.as_str(),
1801 self.format.code(),
1802 self.format_version.as_deref().unwrap_or_default(),
1803 prediction.as_str(),
1804 ];
1805 if fields.iter().any(|field| field.as_bytes().contains(&0)) {
1806 return Err(DataCatalogError::InconsistentProductIdentity {
1807 field: "canonical_encoding",
1808 });
1809 }
1810 Ok(fields.join("\0").into_bytes())
1811 }
1812
1813 pub fn cache_relpath(&self, source: DistributionSource) -> Result<String, DataCatalogError> {
1815 Ok(format!("products/v1/{}/{}", source.code(), self.key()?))
1816 }
1817}
1818
1819#[derive(Debug, Clone, PartialEq, Eq)]
1821pub struct DistributionLocation {
1822 pub source: DistributionSource,
1824 pub original_url: Option<String>,
1826 pub archive_filename: String,
1828 pub compression: ArchiveCompression,
1830}
1831
1832#[derive(Debug, Clone, PartialEq, Eq)]
1834pub struct ProductRequest {
1835 pub identity: ProductIdentity,
1837 pub distributors: Vec<DistributionSource>,
1839}
1840
1841#[derive(Debug, Clone, PartialEq, Eq)]
1843pub enum ExactProductSetError {
1844 EmptyExpected,
1846 InvalidExpected {
1848 index: usize,
1850 source: DataCatalogError,
1852 },
1853 InvalidAvailable {
1855 index: usize,
1857 source: DataCatalogError,
1859 },
1860 Mismatch {
1862 missing: Vec<ProductIdentity>,
1864 unexpected: Vec<ProductIdentity>,
1866 duplicate_expected: Vec<ProductIdentity>,
1868 duplicate_available: Vec<ProductIdentity>,
1870 },
1871}
1872
1873impl fmt::Display for ExactProductSetError {
1874 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1875 match self {
1876 Self::EmptyExpected => write!(f, "exact product set has no expected products"),
1877 Self::InvalidExpected { index, source } => {
1878 write!(f, "expected product {index} is invalid: {source}")
1879 }
1880 Self::InvalidAvailable { index, source } => {
1881 write!(f, "available product {index} is invalid: {source}")
1882 }
1883 Self::Mismatch {
1884 missing,
1885 unexpected,
1886 duplicate_expected,
1887 duplicate_available,
1888 } => write!(
1889 f,
1890 "exact product set mismatch (missing: {}; unexpected: {}; duplicate expected: {}; duplicate available: {})",
1891 identity_list(missing),
1892 identity_list(unexpected),
1893 identity_list(duplicate_expected),
1894 identity_list(duplicate_available),
1895 ),
1896 }
1897 }
1898}
1899
1900impl std::error::Error for ExactProductSetError {}
1901
1902pub fn validate_exact_product_set(
1916 expected: &[ProductIdentity],
1917 available: &[ProductIdentity],
1918) -> Result<(), ExactProductSetError> {
1919 if expected.is_empty() {
1920 return Err(ExactProductSetError::EmptyExpected);
1921 }
1922 for (index, identity) in expected.iter().enumerate() {
1923 identity
1924 .validate()
1925 .map_err(|source| ExactProductSetError::InvalidExpected { index, source })?;
1926 }
1927 for (index, identity) in available.iter().enumerate() {
1928 identity
1929 .validate()
1930 .map_err(|source| ExactProductSetError::InvalidAvailable { index, source })?;
1931 }
1932
1933 let expected_counts = identity_counts(expected);
1934 let available_counts = identity_counts(available);
1935 let missing = unique_matching(expected, |identity| {
1936 !available_counts.contains_key(identity)
1937 });
1938 let unexpected = unique_matching(available, |identity| {
1939 !expected_counts.contains_key(identity)
1940 });
1941 let duplicate_expected = unique_matching(expected, |identity| expected_counts[identity] > 1);
1942 let duplicate_available = unique_matching(available, |identity| available_counts[identity] > 1);
1943
1944 if missing.is_empty()
1945 && unexpected.is_empty()
1946 && duplicate_expected.is_empty()
1947 && duplicate_available.is_empty()
1948 {
1949 Ok(())
1950 } else {
1951 Err(ExactProductSetError::Mismatch {
1952 missing,
1953 unexpected,
1954 duplicate_expected,
1955 duplicate_available,
1956 })
1957 }
1958}
1959
1960fn identity_counts(identities: &[ProductIdentity]) -> HashMap<&ProductIdentity, usize> {
1961 let mut counts = HashMap::with_capacity(identities.len());
1962 for identity in identities {
1963 *counts.entry(identity).or_insert(0) += 1;
1964 }
1965 counts
1966}
1967
1968fn unique_matching(
1969 identities: &[ProductIdentity],
1970 mut predicate: impl FnMut(&ProductIdentity) -> bool,
1971) -> Vec<ProductIdentity> {
1972 let mut seen = HashSet::with_capacity(identities.len());
1973 identities
1974 .iter()
1975 .filter(|identity| predicate(identity) && seen.insert((*identity).clone()))
1976 .cloned()
1977 .collect()
1978}
1979
1980fn identity_list(identities: &[ProductIdentity]) -> String {
1981 if identities.is_empty() {
1982 return "none".to_string();
1983 }
1984 identities
1985 .iter()
1986 .map(|identity| {
1987 identity
1988 .key()
1989 .unwrap_or_else(|_| identity.official_filename.clone())
1990 })
1991 .collect::<Vec<_>>()
1992 .join(", ")
1993}
1994
1995impl ProductRequest {
1996 pub fn new(
1998 identity: ProductIdentity,
1999 distributors: Vec<DistributionSource>,
2000 ) -> Result<Self, DataCatalogError> {
2001 if distributors.is_empty() {
2002 return Err(DataCatalogError::NoDistributionSources);
2003 }
2004 identity.validate()?;
2005 Ok(Self {
2006 identity,
2007 distributors,
2008 })
2009 }
2010}
2011
2012#[derive(Debug, Clone, PartialEq, Eq)]
2014pub struct ProductSpec {
2015 pub center: AnalysisCenter,
2017 pub product_type: ProductType,
2019 pub date: ProductDate,
2021 pub sample: String,
2023 pub issue: Option<String>,
2025}
2026
2027impl ProductSpec {
2028 pub fn new(
2030 center: AnalysisCenter,
2031 product_type: ProductType,
2032 date: ProductDate,
2033 sample: &str,
2034 issue: Option<&str>,
2035 ) -> Result<Self, DataCatalogError> {
2036 ProductDate::new(date.year, date.month, date.day)?;
2037 validate_product(center, product_type, sample, issue)?;
2038 validate_product_date(center, product_type, date)?;
2039 Ok(Self {
2040 center,
2041 product_type,
2042 date,
2043 sample: sample.to_string(),
2044 issue: issue.map(ToOwned::to_owned),
2045 })
2046 }
2047
2048 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
2050 self.date.gps_week()
2051 }
2052
2053 #[must_use]
2055 pub fn day_of_year(&self) -> u16 {
2056 self.date.day_of_year()
2057 }
2058
2059 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2065 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2066 let convention = validate_product(
2067 self.center,
2068 self.product_type,
2069 &self.sample,
2070 self.issue.as_deref(),
2071 )?;
2072 validate_product_date(self.center, self.product_type, self.date)?;
2073 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2074 return Ok(format!(
2075 "igs{:04}{}.sp3",
2076 self.date.gps_week()?,
2077 self.date.gps_day_of_week()?
2078 ));
2079 }
2080 let descriptor = product_type_convention(self.product_type);
2081 Ok(match descriptor.kind {
2082 ProductFilenameKind::Sampled => format!(
2083 "{}_{}_{}_{}_{}.{}",
2084 convention.token,
2085 date_block(self.date, self.issue.as_deref()),
2086 convention.span,
2087 self.sample,
2088 descriptor.content_code,
2089 descriptor.extension
2090 ),
2091 ProductFilenameKind::Nav => format!(
2092 "{}_R_{}_{}_{}.{}",
2093 convention.token,
2094 date_block(self.date, None),
2095 convention.span,
2096 descriptor.content_code,
2097 descriptor.extension
2098 ),
2099 })
2100 }
2101
2102 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2104 ProductDate::new(self.date.year, self.date.month, self.date.day)?;
2105 let convention = validate_product(
2106 self.center,
2107 self.product_type,
2108 &self.sample,
2109 self.issue.as_deref(),
2110 )?;
2111 if uses_legacy_igs_final_name(self.center, self.product_type, self.date)? {
2112 return Err(DataCatalogError::UnsupportedDistributionEra {
2113 source: DistributionSource::Direct,
2114 center: self.center,
2115 product_type: self.product_type,
2116 date: self.date,
2117 });
2118 }
2119 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
2120 let filename = self.canonical_filename()?;
2121 let compression = product_archive_compression(
2122 self.center,
2123 self.product_type,
2124 self.date,
2125 convention.compression,
2126 )?;
2127 Ok(format!(
2128 "{}/{}/{}{}",
2129 entry.root_url,
2130 product_dir_path(self.center, convention.layout, self.date)?,
2131 filename,
2132 compression.suffix()
2133 ))
2134 }
2135
2136 pub fn identity(&self) -> Result<ProductIdentity, DataCatalogError> {
2138 let convention = validate_product(
2139 self.center,
2140 self.product_type,
2141 &self.sample,
2142 self.issue.as_deref(),
2143 )?;
2144 let descriptor = product_type_convention(self.product_type);
2145 let campaign = match descriptor.kind {
2146 ProductFilenameKind::Nav => ProductCampaign::Broadcast,
2147 ProductFilenameKind::Sampled => match convention.token.get(4..7) {
2148 Some("OPS") => ProductCampaign::Operational,
2149 Some("MGN") => ProductCampaign::MultiGnss,
2150 Some("MGX") => ProductCampaign::MultiGnssExperiment,
2151 _ => {
2152 return Err(DataCatalogError::InconsistentProductIdentity {
2153 field: "campaign",
2154 });
2155 }
2156 },
2157 };
2158 let identity = ProductIdentity {
2159 family: self.product_type,
2160 analysis_center: self.center,
2161 publisher: self.center.publisher(),
2162 solution: product_solution_class(self.center, self.product_type)?,
2163 campaign,
2164 version: 0,
2165 date: self.date,
2166 issue: match descriptor.kind {
2167 ProductFilenameKind::Sampled => {
2168 Some(self.issue.clone().unwrap_or_else(|| "0000".to_string()))
2169 }
2170 ProductFilenameKind::Nav => None,
2171 },
2172 span: convention.span.to_string(),
2173 sample: self.sample.clone(),
2174 official_filename: self.canonical_filename()?,
2175 format: product_format(self.product_type),
2176 format_version: None,
2177 prediction_horizon_days: self.center.prediction_horizon_days(),
2178 };
2179 identity.validate()?;
2180 Ok(identity)
2181 }
2182
2183 pub fn distribution_location(
2185 &self,
2186 source: DistributionSource,
2187 ) -> Result<DistributionLocation, DataCatalogError> {
2188 let identity = self.identity()?;
2189 distribution_location_for_identity(&identity, source)
2190 }
2191}
2192
2193#[derive(Debug, Clone, PartialEq, Eq)]
2195pub struct StationObservationSpec {
2196 pub station: String,
2198 pub date: ProductDate,
2200 pub sample: String,
2202}
2203
2204impl StationObservationSpec {
2205 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
2207 validate_station(station)?;
2208 validate_sample(sample)?;
2209 Ok(Self {
2210 station: station.to_string(),
2211 date,
2212 sample: sample.to_string(),
2213 })
2214 }
2215
2216 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
2218 station_obs_filename(&self.station, self.date, &self.sample)
2219 }
2220
2221 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
2223 station_obs_url(&self.station, self.date, &self.sample)
2224 }
2225}
2226
2227#[must_use]
2229pub const fn catalog() -> &'static [CenterCatalogEntry] {
2230 &CATALOG
2231}
2232
2233#[must_use]
2235pub const fn centers() -> &'static [AnalysisCenter] {
2236 &CENTER_ORDER
2237}
2238
2239#[must_use]
2241pub const fn product_types() -> &'static [ProductTypeConvention] {
2242 &PRODUCT_TYPE_CONVENTIONS
2243}
2244
2245#[must_use]
2247pub const fn allowed_hosts() -> &'static [&'static str] {
2248 &ALLOWED_HOSTS
2249}
2250
2251#[must_use]
2253pub const fn skadi_source_entry() -> TerrainSourceEntry {
2254 SKADI_SOURCE
2255}
2256
2257#[must_use]
2259pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
2260 CELESTRAK_SPACE_WEATHER_SOURCE
2261}
2262
2263#[must_use]
2265pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
2266 match product {
2267 SpaceWeatherProduct::All => "SW-All.csv",
2268 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
2269 }
2270}
2271
2272#[must_use]
2274pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
2275 format!(
2276 "{}/{}",
2277 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
2278 space_weather_filename(product)
2279 )
2280}
2281
2282#[must_use]
2284pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
2285 format!("space-weather/{}", space_weather_filename(product))
2286}
2287
2288pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2290 validate_terrain_tile_index(lat_index, lon_index)?;
2291 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2292 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
2293 Ok(format!(
2294 "{lat_hemi}{:02}{lon_hemi}{:03}",
2295 lat_index.abs(),
2296 lon_index.abs()
2297 ))
2298}
2299
2300pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
2302 validate_terrain_lat_index(lat_index)?;
2303 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
2304 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
2305}
2306
2307pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2309 let band = skadi_band(lat_index)?;
2310 let tile_id = skadi_tile_id(lat_index, lon_index)?;
2311 Ok(format!(
2312 "{}/skadi/{}/{}.hgt{}",
2313 SKADI_SOURCE.root_url,
2314 band,
2315 tile_id,
2316 SKADI_SOURCE.compression.suffix()
2317 ))
2318}
2319
2320pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2322 validate_terrain_tile_index(lat_index, lon_index)?;
2323 Ok(format!(
2324 "{}_{}{}",
2325 terrain::format_lat(lat_index),
2326 terrain::format_lon(lon_index),
2327 terrain::DTED_SUFFIX
2328 ))
2329}
2330
2331pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2333 validate_terrain_tile_index(lat_index, lon_index)?;
2334 Ok(terrain::terrain_block_dir(lat_index, lon_index))
2335}
2336
2337pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
2339 Ok(format!(
2340 "{}/{}",
2341 dted_block_dir(lat_index, lon_index)?,
2342 dted_tile_filename(lat_index, lon_index)?
2343 ))
2344}
2345
2346pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
2348 let bytes = id.as_bytes();
2349 if bytes.len() != 7
2350 || !matches!(bytes[0], b'N' | b'S')
2351 || !matches!(bytes[3], b'E' | b'W')
2352 || !bytes[1..3].iter().all(u8::is_ascii_digit)
2353 || !bytes[4..7].iter().all(u8::is_ascii_digit)
2354 {
2355 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2356 }
2357
2358 let lat_abs = id[1..3]
2359 .parse::<i32>()
2360 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2361 let lon_abs = id[4..7]
2362 .parse::<i32>()
2363 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
2364 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
2365 return Err(DataCatalogError::InvalidTileId(id.to_string()));
2366 }
2367
2368 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
2369 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
2370 validate_terrain_tile_index(lat_index, lon_index)?;
2371 Ok((lat_index, lon_index))
2372}
2373
2374pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
2376 if !lat_deg.is_finite()
2377 || !lon_deg.is_finite()
2378 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
2379 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
2380 {
2381 return Err(DataCatalogError::InvalidCoordinate {
2382 lat_deg_bits: lat_deg.to_bits(),
2383 lon_deg_bits: lon_deg.to_bits(),
2384 });
2385 }
2386
2387 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
2388 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
2389 lat_index = MAX_TERRAIN_LAT_INDEX;
2390 }
2391 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
2392 lon_index = MAX_TERRAIN_LON_INDEX;
2393 }
2394 validate_terrain_tile_index(lat_index, lon_index)?;
2395 Ok((lat_index, lon_index))
2396}
2397
2398pub fn hgt_to_dted(
2406 lat_index: i32,
2407 lon_index: i32,
2408 hgt: &[u8],
2409) -> Result<Vec<u8>, HgtConversionError> {
2410 validate_hgt_tile_index(lat_index, lon_index)?;
2411 if hgt.len() != SRTM1_HGT_LEN {
2412 return Err(HgtConversionError::BadLength {
2413 expected: SRTM1_HGT_LEN,
2414 got: hgt.len(),
2415 });
2416 }
2417
2418 let mut out = vec![b' '; DTED_SRTM1_LEN];
2419 out[0..4].copy_from_slice(b"UHL1");
2420 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
2421 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
2422 out[47..51].copy_from_slice(b"3601");
2423 out[51..55].copy_from_slice(b"3601");
2424
2425 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2426 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
2427 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
2428 out[block_start] = terrain::DATA_SENTINEL;
2429
2430 let count = (lon_posting as u32).to_be_bytes();
2431 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
2432 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
2433 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
2434
2435 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
2436 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
2437 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
2438 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
2439 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
2440 let dted_sample_start = block_start + 8 + 2 * lat_posting;
2441 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
2442 }
2443
2444 let checksum = out[block_start..checksum_start]
2445 .iter()
2446 .fold(0i32, |acc, byte| acc + i32::from(*byte));
2447 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
2448 }
2449
2450 debug_assert_eq!(out.len(), 25_981_042);
2451 Ok(out)
2452}
2453
2454#[must_use]
2456pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
2457 &NO_OPEN_MIRRORS
2458}
2459
2460pub fn open_mirror(
2462 center: AnalysisCenter,
2463 product_type: ProductType,
2464) -> Result<(), DataCatalogError> {
2465 open_mirror_code(center.code(), product_type.code())
2466}
2467
2468pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
2470 if NO_OPEN_MIRRORS
2471 .iter()
2472 .any(|entry| entry.center == center && entry.product_type == product_type)
2473 {
2474 Err(DataCatalogError::NoOpenMirror {
2475 center: center.to_string(),
2476 product_type: product_type.to_string(),
2477 })
2478 } else {
2479 Ok(())
2480 }
2481}
2482
2483#[must_use]
2485pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
2486 CATALOG.iter().find(|entry| entry.center == center)
2487}
2488
2489pub fn product_convention(
2491 center: AnalysisCenter,
2492 product_type: ProductType,
2493) -> Result<&'static CenterProductConvention, DataCatalogError> {
2494 open_mirror(center, product_type)?;
2495 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2496 entry
2497 .products
2498 .iter()
2499 .find(|product| product.product_type == product_type)
2500 .ok_or(DataCatalogError::UnsupportedProduct {
2501 center,
2502 product_type,
2503 })
2504}
2505
2506pub fn product_solution_class(
2514 center: AnalysisCenter,
2515 product_type: ProductType,
2516) -> Result<SolutionClass, DataCatalogError> {
2517 product_convention(center, product_type)?;
2518 Ok(match (center, product_type) {
2519 (AnalysisCenter::Igs, ProductType::Sp3) => SolutionClass::Final,
2520 _ => center.solution_class(),
2521 })
2522}
2523
2524pub fn default_sample(
2530 center: AnalysisCenter,
2531 product_type: ProductType,
2532) -> Result<&'static str, DataCatalogError> {
2533 Ok(product_convention(center, product_type)?.default_sample)
2534}
2535
2536pub fn default_sample_for_date(
2544 center: AnalysisCenter,
2545 product_type: ProductType,
2546 date: ProductDate,
2547) -> Result<&'static str, DataCatalogError> {
2548 default_sample_for_product_issue(center, product_type, date, None)
2549}
2550
2551pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
2553 date.gps_week()
2554}
2555
2556#[must_use]
2558pub fn day_of_year(date: ProductDate) -> u16 {
2559 date.day_of_year()
2560}
2561
2562pub fn product(
2564 center: AnalysisCenter,
2565 product_type: ProductType,
2566 date: ProductDate,
2567 sample: Option<&str>,
2568 issue: Option<&str>,
2569) -> Result<ProductSpec, DataCatalogError> {
2570 let sample = match sample {
2571 Some(sample) => sample,
2572 None => default_sample_for_product_issue(center, product_type, date, issue)?,
2573 };
2574 ProductSpec::new(center, product_type, date, sample, issue)
2575}
2576
2577pub fn canonical_filename(
2579 center: AnalysisCenter,
2580 product_type: ProductType,
2581 date: ProductDate,
2582 sample: Option<&str>,
2583 issue: Option<&str>,
2584) -> Result<String, DataCatalogError> {
2585 product(center, product_type, date, sample, issue)?.canonical_filename()
2586}
2587
2588pub fn archive_url(
2590 center: AnalysisCenter,
2591 product_type: ProductType,
2592 date: ProductDate,
2593 sample: Option<&str>,
2594 issue: Option<&str>,
2595) -> Result<String, DataCatalogError> {
2596 product(center, product_type, date, sample, issue)?.archive_url()
2597}
2598
2599pub fn product_identity(
2601 center: AnalysisCenter,
2602 product_type: ProductType,
2603 date: ProductDate,
2604 sample: Option<&str>,
2605 issue: Option<&str>,
2606) -> Result<ProductIdentity, DataCatalogError> {
2607 product(center, product_type, date, sample, issue)?.identity()
2608}
2609
2610pub fn distribution_location(
2612 center: AnalysisCenter,
2613 product_type: ProductType,
2614 date: ProductDate,
2615 sample: Option<&str>,
2616 issue: Option<&str>,
2617 source: DistributionSource,
2618) -> Result<DistributionLocation, DataCatalogError> {
2619 product(center, product_type, date, sample, issue)?.distribution_location(source)
2620}
2621
2622pub fn distribution_location_for_identity(
2628 identity: &ProductIdentity,
2629 source: DistributionSource,
2630) -> Result<DistributionLocation, DataCatalogError> {
2631 identity.validate()?;
2632 match source {
2633 DistributionSource::Direct => {
2634 let convention = product_convention(identity.analysis_center, identity.family)?;
2635 if uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?
2636 {
2637 return Err(DataCatalogError::UnsupportedDistributionEra {
2638 source,
2639 center: identity.analysis_center,
2640 product_type: identity.family,
2641 date: identity.date,
2642 });
2643 }
2644 let entry = center_catalog(identity.analysis_center)
2645 .expect("validated analysis center has a catalog entry");
2646 let compression = product_archive_compression(
2647 identity.analysis_center,
2648 identity.family,
2649 identity.date,
2650 convention.compression,
2651 )?;
2652 let url = format!(
2653 "{}/{}/{}{}",
2654 entry.root_url,
2655 product_dir_path(identity.analysis_center, convention.layout, identity.date)?,
2656 identity.official_filename,
2657 compression.suffix()
2658 );
2659 Ok(DistributionLocation {
2660 source,
2661 original_url: Some(url),
2662 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2663 compression,
2664 })
2665 }
2666 DistributionSource::NasaCddis => {
2667 validate_cddis_distribution_era(identity)?;
2668 let compression = product_archive_compression(
2669 identity.analysis_center,
2670 identity.family,
2671 identity.date,
2672 ArchiveCompression::Gzip,
2673 )?;
2674 Ok(DistributionLocation {
2675 source,
2676 original_url: Some(cddis_archive_url(identity)?),
2677 archive_filename: format!("{}{}", identity.official_filename, compression.suffix()),
2678 compression,
2679 })
2680 }
2681 DistributionSource::LocalFile | DistributionSource::InMemory => Ok(DistributionLocation {
2682 source,
2683 original_url: None,
2684 archive_filename: identity.official_filename.clone(),
2685 compression: ArchiveCompression::None,
2686 }),
2687 }
2688}
2689
2690pub fn cddis_archive_url(identity: &ProductIdentity) -> Result<String, DataCatalogError> {
2699 identity.validate()?;
2700 validate_cddis_distribution_era(identity)?;
2701 match identity.family {
2702 ProductType::Sp3 => {
2703 let compression = product_archive_compression(
2704 identity.analysis_center,
2705 identity.family,
2706 identity.date,
2707 ArchiveCompression::Gzip,
2708 )?;
2709 Ok(format!(
2710 "https://cddis.nasa.gov/archive/gnss/products/{:04}/{}{}",
2711 identity.date.gps_week()?,
2712 identity.official_filename,
2713 compression.suffix()
2714 ))
2715 }
2716 ProductType::Ionex => Ok(format!(
2717 "https://cddis.nasa.gov/archive/gnss/products/ionex/{}/{:03}/{}.gz",
2718 identity.date.year,
2719 identity.date.day_of_year(),
2720 identity.official_filename
2721 )),
2722 product_type => Err(DataCatalogError::UnsupportedDistribution {
2723 source: DistributionSource::NasaCddis,
2724 product_type,
2725 }),
2726 }
2727}
2728
2729pub fn mgex_clk(
2731 center: AnalysisCenter,
2732 date: ProductDate,
2733 sample: Option<&str>,
2734) -> Result<ProductSpec, DataCatalogError> {
2735 product(center, ProductType::Clk, date, sample, None)
2736}
2737
2738pub fn mgex_nav(
2740 center: AnalysisCenter,
2741 date: ProductDate,
2742 sample: Option<&str>,
2743) -> Result<ProductSpec, DataCatalogError> {
2744 product(center, ProductType::Nav, date, sample, None)
2745}
2746
2747pub fn mgex_ionex(
2749 center: AnalysisCenter,
2750 date: ProductDate,
2751 sample: Option<&str>,
2752) -> Result<ProductSpec, DataCatalogError> {
2753 product(center, ProductType::Ionex, date, sample, None)
2754}
2755
2756pub fn rapid_ionex(
2758 date: ProductDate,
2759 sample: Option<&str>,
2760) -> Result<ProductSpec, DataCatalogError> {
2761 product(
2762 AnalysisCenter::CodRap,
2763 ProductType::Ionex,
2764 date,
2765 sample,
2766 None,
2767 )
2768}
2769
2770#[must_use]
2772pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
2773 match center {
2774 AnalysisCenter::CodPrd2 => 1,
2775 _ => 0,
2776 }
2777}
2778
2779pub fn predicted_ionex(
2781 center: AnalysisCenter,
2782 date: ProductDate,
2783 sample: Option<&str>,
2784) -> Result<ProductSpec, DataCatalogError> {
2785 match center {
2786 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
2787 let target = date.add_days(predicted_day_offset(center))?;
2788 product(center, ProductType::Ionex, target, sample, None)
2789 }
2790 other => Err(DataCatalogError::UnsupportedProduct {
2791 center: other,
2792 product_type: ProductType::Ionex,
2793 }),
2794 }
2795}
2796
2797pub fn mgex_sp3(
2799 center: AnalysisCenter,
2800 date: ProductDate,
2801 sample: Option<&str>,
2802) -> Result<ProductSpec, DataCatalogError> {
2803 product(center, ProductType::Sp3, date, sample, None)
2804}
2805
2806pub fn ops_ultra_sp3(
2808 center: AnalysisCenter,
2809 date: ProductDate,
2810 sample: Option<&str>,
2811 issue: Option<&str>,
2812) -> Result<ProductSpec, DataCatalogError> {
2813 let issue = issue.unwrap_or("0000");
2814 product(center, ProductType::Sp3, date, sample, Some(issue))
2815}
2816
2817pub fn ultra_sp3_locations(
2824 center: AnalysisCenter,
2825 date: ProductDate,
2826 issue: &str,
2827) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
2828 validate_issue_for_center(center, Some(issue))?;
2829 validate_product_date(center, ProductType::Sp3, date)?;
2830 let patterns: &[UltraSp3Pattern] = match center {
2831 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
2832 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
2833 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
2834 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
2835 other => {
2836 return Err(DataCatalogError::UnsupportedProduct {
2837 center: other,
2838 product_type: ProductType::Sp3,
2839 })
2840 }
2841 };
2842 let convention = product_convention(center, ProductType::Sp3)?;
2843 let default_sample =
2844 default_sample_for_product_issue(center, ProductType::Sp3, date, Some(issue))?;
2845 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2846 let directory = dir_path(convention.layout, date)?;
2847 let date = date_block(date, Some(issue));
2848
2849 let mut patterns = patterns.to_vec();
2850 patterns.sort_by_key(|pattern| {
2851 !(pattern.alias_filename.is_none()
2852 && pattern.span == convention.span
2853 && pattern.sample == default_sample)
2854 });
2855
2856 Ok(patterns
2857 .iter()
2858 .map(|pattern| {
2859 let is_primary = pattern.alias_filename.is_none()
2860 && pattern.span == convention.span
2861 && pattern.sample == default_sample;
2862 let filename = pattern.alias_filename.map_or_else(
2863 || {
2864 format!(
2865 "{}_{}_{}_{}_ORB.SP3",
2866 convention.token, date, pattern.span, pattern.sample
2867 )
2868 },
2869 ToOwned::to_owned,
2870 );
2871 UltraSp3Location {
2872 pattern: if is_primary {
2873 format!("primary_{}_{}", pattern.span, pattern.sample)
2874 } else if pattern.alias_filename.is_some() {
2875 pattern.label.to_string()
2876 } else {
2877 format!("alternate_{}_{}", pattern.span, pattern.sample)
2878 },
2879 span: pattern.span.to_string(),
2880 sample: pattern.sample.to_string(),
2881 url: format!(
2882 "{}/{}/{}{}",
2883 entry.root_url,
2884 directory,
2885 filename,
2886 convention.compression.suffix()
2887 ),
2888 filename,
2889 compression: convention.compression,
2890 }
2891 })
2892 .collect())
2893}
2894
2895pub fn ops_ultra_clk(
2897 center: AnalysisCenter,
2898 date: ProductDate,
2899 sample: Option<&str>,
2900 issue: Option<&str>,
2901) -> Result<ProductSpec, DataCatalogError> {
2902 let issue = issue.unwrap_or("0000");
2903 product(center, ProductType::Clk, date, sample, Some(issue))
2904}
2905
2906pub fn latest_ops_ultra_sp3(
2908 center: AnalysisCenter,
2909 target: ProductDateTime,
2910 sample: Option<&str>,
2911 available_issues: Option<&[UltraIssue]>,
2912) -> Result<ProductSpec, DataCatalogError> {
2913 let selected = latest_ultra_issue(center, target, available_issues)?;
2914 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
2915}
2916
2917pub fn ultra_issue_candidates(
2919 center: AnalysisCenter,
2920 target: ProductDateTime,
2921) -> Result<Vec<UltraIssue>, DataCatalogError> {
2922 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
2923 let _ = product_convention(center, ProductType::Sp3)?;
2924 if entry.issues.is_empty() {
2925 return Err(DataCatalogError::UnsupportedProduct {
2926 center,
2927 product_type: ProductType::Sp3,
2928 });
2929 }
2930 validate_product_date(center, ProductType::Sp3, target.date)?;
2931
2932 let mut candidates = Vec::new();
2933 for date in [target.date, target.date.add_days(-1)?] {
2934 match validate_product_date(center, ProductType::Sp3, date) {
2935 Ok(()) => {}
2936 Err(DataCatalogError::UnsupportedProductEra { .. }) => continue,
2937 Err(error) => return Err(error),
2938 }
2939 for issue in entry.issues.iter().rev() {
2940 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
2941 candidates.push(UltraIssue::new(date, issue)?);
2942 }
2943 }
2944 }
2945 Ok(candidates)
2946}
2947
2948pub fn latest_ultra_issue(
2950 center: AnalysisCenter,
2951 target: ProductDateTime,
2952 available_issues: Option<&[UltraIssue]>,
2953) -> Result<UltraIssue, DataCatalogError> {
2954 let candidates = ultra_issue_candidates(center, target)?;
2955 if candidates.is_empty() {
2956 return Err(DataCatalogError::NoUltraIssue);
2957 }
2958 if let Some(available) = available_issues {
2959 candidates
2960 .into_iter()
2961 .find(|candidate| {
2962 available
2963 .iter()
2964 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
2965 })
2966 .ok_or(DataCatalogError::NoAvailableUltraIssue)
2967 } else {
2968 Ok(candidates[0].clone())
2969 }
2970}
2971
2972pub fn gim_date_candidates(
2974 center: AnalysisCenter,
2975 target: ProductDate,
2976 lookback: u32,
2977) -> Result<Vec<ProductDate>, DataCatalogError> {
2978 let _ = product_convention(center, ProductType::Ionex)?;
2979 let base = target.add_days(predicted_day_offset(center))?;
2980 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
2981 for back in 0..=lookback {
2982 out.push(base.add_days(-i64::from(back))?);
2983 }
2984 Ok(out)
2985}
2986
2987pub fn station_obs(
2989 station: &str,
2990 date: ProductDate,
2991 sample: Option<&str>,
2992) -> Result<StationObservationSpec, DataCatalogError> {
2993 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
2994}
2995
2996pub fn station_obs_filename(
2998 station: &str,
2999 date: ProductDate,
3000 sample: &str,
3001) -> Result<String, DataCatalogError> {
3002 validate_station(station)?;
3003 validate_sample(sample)?;
3004 Ok(format!(
3005 "{}_R_{}_01D_{}_MO.crx",
3006 station,
3007 date_block(date, None),
3008 sample
3009 ))
3010}
3011
3012pub fn station_obs_url(
3014 station: &str,
3015 date: ProductDate,
3016 sample: &str,
3017) -> Result<String, DataCatalogError> {
3018 let filename = station_obs_filename(station, date, sample)?;
3019 Ok(format!(
3020 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
3021 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
3022 filename
3023 ))
3024}
3025
3026#[must_use]
3028pub const fn station_obs_protocol() -> ArchiveProtocol {
3029 ArchiveProtocol::Https
3030}
3031
3032fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
3033 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
3034 Ok(())
3035 } else {
3036 Err(DataCatalogError::InvalidTileIndex {
3037 lat_index,
3038 lon_index: 0,
3039 })
3040 }
3041}
3042
3043fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
3044 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3045 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3046 {
3047 Ok(())
3048 } else {
3049 Err(DataCatalogError::InvalidTileIndex {
3050 lat_index,
3051 lon_index,
3052 })
3053 }
3054}
3055
3056fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
3057 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
3058 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
3059 {
3060 Ok(())
3061 } else {
3062 Err(HgtConversionError::InvalidTileIndex {
3063 lat_index,
3064 lon_index,
3065 })
3066 }
3067}
3068
3069fn dted_coord_field(index: i32, is_longitude: bool) -> String {
3070 let hemi = match (is_longitude, index >= 0) {
3071 (true, true) => 'E',
3072 (true, false) => 'W',
3073 (false, true) => 'N',
3074 (false, false) => 'S',
3075 };
3076 format!("{:03}0000{hemi}", index.abs())
3077}
3078
3079fn encode_dted_signed_magnitude(sample: i16) -> u16 {
3080 if sample == i16::MIN {
3081 0
3082 } else if sample >= 0 {
3083 sample as u16
3084 } else {
3085 0x8000 | (-i32::from(sample) as u16)
3086 }
3087}
3088
3089fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
3090 PRODUCT_TYPE_CONVENTIONS
3091 .iter()
3092 .find(|descriptor| descriptor.product_type == product_type)
3093 .expect("product descriptor exists for enum variant")
3094}
3095
3096const fn product_format(product_type: ProductType) -> ProductFormat {
3097 match product_type {
3098 ProductType::Sp3 => ProductFormat::Sp3,
3099 ProductType::Ionex => ProductFormat::Ionex,
3100 ProductType::Clk => ProductFormat::RinexClock,
3101 ProductType::Nav => ProductFormat::RinexNavigation,
3102 }
3103}
3104
3105fn validate_official_filename(filename: &str) -> Result<(), DataCatalogError> {
3106 if filename.is_empty()
3107 || filename == "."
3108 || filename == ".."
3109 || filename.contains('/')
3110 || filename.contains('\\')
3111 || filename.contains('\0')
3112 || filename.contains("..")
3113 {
3114 Err(DataCatalogError::InvalidOfficialFilename(
3115 filename.to_string(),
3116 ))
3117 } else {
3118 Ok(())
3119 }
3120}
3121
3122fn validate_product(
3123 center: AnalysisCenter,
3124 product_type: ProductType,
3125 sample: &str,
3126 issue: Option<&str>,
3127) -> Result<&'static CenterProductConvention, DataCatalogError> {
3128 let convention = product_convention(center, product_type)?;
3129 validate_sample(sample)?;
3130 validate_catalog_sample(center, product_type, sample, convention)?;
3131 validate_issue_for_center(center, issue)?;
3132 Ok(convention)
3133}
3134
3135fn validate_catalog_sample(
3136 center: AnalysisCenter,
3137 product_type: ProductType,
3138 sample: &str,
3139 convention: &CenterProductConvention,
3140) -> Result<(), DataCatalogError> {
3141 if center == AnalysisCenter::Igs
3146 && product_type == ProductType::Sp3
3147 && sample != convention.default_sample
3148 {
3149 return Err(DataCatalogError::UnsupportedSample {
3150 center,
3151 product_type,
3152 sample: sample.to_string(),
3153 });
3154 }
3155 Ok(())
3156}
3157
3158fn validate_product_date(
3159 center: AnalysisCenter,
3160 product_type: ProductType,
3161 date: ProductDate,
3162) -> Result<(), DataCatalogError> {
3163 if center == AnalysisCenter::Igs
3167 && product_type == ProductType::Sp3
3168 && date.gps_week()? < IGS_COMBINED_FINAL_START_GPS_WEEK
3169 {
3170 return Err(DataCatalogError::UnsupportedProductEra {
3171 center,
3172 product_type,
3173 date,
3174 });
3175 }
3176
3177 if center == AnalysisCenter::Cod
3182 && matches!(
3183 product_type,
3184 ProductType::Sp3 | ProductType::Clk | ProductType::Ionex
3185 )
3186 && date.gps_week()? < CODE_LONG_FILENAME_START_GPS_WEEK
3187 {
3188 return Err(DataCatalogError::UnsupportedProductEra {
3189 center,
3190 product_type,
3191 date,
3192 });
3193 }
3194
3195 let start_date = match (center, product_type) {
3196 (AnalysisCenter::Esa, ProductType::Sp3 | ProductType::Clk) => {
3197 Some(ESA_FINAL_SERIES_START_DATE)
3198 }
3199 (AnalysisCenter::Gfz, ProductType::Sp3 | ProductType::Clk) => {
3200 Some(GFZ_RAPID_SERIES_START_DATE)
3201 }
3202 (AnalysisCenter::EsaUlt, ProductType::Sp3) => Some(ESA_ULTRA_SP3_START_DATE),
3203 (AnalysisCenter::GfzUlt, ProductType::Sp3) => Some(GFZ_ULTRA_SP3_START_DATE),
3204 _ => None,
3205 };
3206 let before_long_name_start = matches!(center, AnalysisCenter::IgsUlt | AnalysisCenter::CodUlt)
3207 && product_type == ProductType::Sp3
3208 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK;
3209 if before_long_name_start || start_date.is_some_and(|start| date < start) {
3210 return Err(DataCatalogError::UnsupportedProductEra {
3211 center,
3212 product_type,
3213 date,
3214 });
3215 }
3216 Ok(())
3217}
3218
3219fn default_sample_for_product_issue(
3220 center: AnalysisCenter,
3221 product_type: ProductType,
3222 date: ProductDate,
3223 issue: Option<&str>,
3224) -> Result<&'static str, DataCatalogError> {
3225 ProductDate::new(date.year, date.month, date.day)?;
3226 let current = default_sample(center, product_type)?;
3227 validate_product_date(center, product_type, date)?;
3228
3229 if product_type != ProductType::Sp3 {
3230 return Ok(current);
3231 }
3232 match center {
3233 AnalysisCenter::Gfz if date < GFZ_RAPID_5M_START_DATE => Ok("15M"),
3234 AnalysisCenter::EsaUlt => {
3235 let issue = issue.unwrap_or("0000");
3239 validate_issue_for_center(center, Some(issue))?;
3240 let at_or_before_last_15m = date < ESA_ULTRA_15M_LAST_DATE
3241 || (date == ESA_ULTRA_15M_LAST_DATE
3242 && issue_minutes(issue)? <= ESA_ULTRA_15M_LAST_ISSUE_MINUTES);
3243 if at_or_before_last_15m {
3244 Ok("15M")
3245 } else {
3246 Ok(current)
3247 }
3248 }
3249 AnalysisCenter::GfzUlt if date < GFZ_ULTRA_5M_START_DATE => Ok("15M"),
3250 _ => Ok(current),
3251 }
3252}
3253
3254fn validate_cddis_distribution_era(identity: &ProductIdentity) -> Result<(), DataCatalogError> {
3255 let gps_week = identity.date.gps_week()?;
3256 let esa_mgex_final_sp3 =
3257 identity.analysis_center == AnalysisCenter::Esa && identity.family == ProductType::Sp3;
3258 let unmodeled_pretransition_sp3 = identity.family == ProductType::Sp3
3259 && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK
3260 && !uses_legacy_igs_final_name(identity.analysis_center, identity.family, identity.date)?;
3261 let unmodeled_pretransition_ionex =
3262 identity.family == ProductType::Ionex && gps_week < IGS_LONG_FILENAME_START_GPS_WEEK;
3263 if esa_mgex_final_sp3 || unmodeled_pretransition_sp3 || unmodeled_pretransition_ionex {
3264 Err(DataCatalogError::UnsupportedDistributionEra {
3265 source: DistributionSource::NasaCddis,
3266 center: identity.analysis_center,
3267 product_type: identity.family,
3268 date: identity.date,
3269 })
3270 } else {
3271 Ok(())
3272 }
3273}
3274
3275fn validate_issue_for_center(
3276 center: AnalysisCenter,
3277 issue: Option<&str>,
3278) -> Result<(), DataCatalogError> {
3279 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
3280 match (entry.issues.is_empty(), issue) {
3281 (true, None) => Ok(()),
3282 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
3283 (false, None) => Err(DataCatalogError::MissingIssue { center }),
3284 (false, Some(issue)) => {
3285 validate_issue(issue)?;
3286 if entry.issues.contains(&issue) {
3287 Ok(())
3288 } else {
3289 Err(DataCatalogError::UnsupportedIssue {
3290 center,
3291 issue: issue.to_string(),
3292 })
3293 }
3294 }
3295 }
3296}
3297
3298fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
3299 if validate_period_token(sample) {
3300 Ok(())
3301 } else {
3302 Err(DataCatalogError::InvalidSample(sample.to_string()))
3303 }
3304}
3305
3306fn validate_span(span: &str) -> Result<(), DataCatalogError> {
3307 if validate_period_token(span) {
3308 Ok(())
3309 } else {
3310 Err(DataCatalogError::InvalidSpan(span.to_string()))
3311 }
3312}
3313
3314fn validate_period_token(token: &str) -> bool {
3315 let bytes = token.as_bytes();
3316 if bytes.len() != 3 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
3317 return false;
3318 }
3319 let amount = u16::from(bytes[0] - b'0') * 10 + u16::from(bytes[1] - b'0');
3320 match bytes[2] {
3321 b'S' | b'M' => amount > 0 && amount % 60 != 0,
3326 b'H' => amount > 0 && amount % 24 != 0,
3327 b'D' | b'W' | b'L' | b'Y' => amount > 0,
3328 b'U' => amount == 0,
3331 _ => false,
3332 }
3333}
3334
3335fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
3336 let bytes = issue.as_bytes();
3337 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
3338 if !valid_digits {
3339 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
3340 }
3341 let hour = issue[0..2]
3342 .parse::<u8>()
3343 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3344 let minute = issue[2..4]
3345 .parse::<u8>()
3346 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3347 if hour <= 23 && minute <= 59 {
3348 Ok(())
3349 } else {
3350 Err(DataCatalogError::InvalidIssue(issue.to_string()))
3351 }
3352}
3353
3354fn validate_station(station: &str) -> Result<(), DataCatalogError> {
3355 let bytes = station.as_bytes();
3356 let valid = bytes.len() == 9
3357 && bytes
3358 .iter()
3359 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
3360 if valid {
3361 Ok(())
3362 } else {
3363 Err(DataCatalogError::InvalidStation(station.to_string()))
3364 }
3365}
3366
3367fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
3368 validate_issue(issue)?;
3369 let hour = issue[0..2]
3370 .parse::<u16>()
3371 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3372 let minute = issue[2..4]
3373 .parse::<u16>()
3374 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
3375 Ok(hour * 60 + minute)
3376}
3377
3378fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
3379 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
3380}
3381
3382fn date_block(date: ProductDate, issue: Option<&str>) -> String {
3383 format!(
3384 "{}{:03}{}",
3385 date.year,
3386 date.day_of_year(),
3387 issue.unwrap_or("0000")
3388 )
3389}
3390
3391fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
3392 Ok(match layout {
3393 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
3394 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
3395 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
3396 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
3397 ArchiveLayout::BkgBrdcYearDoy => {
3398 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
3399 }
3400 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
3401 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
3402 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
3403 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
3404 })
3405}
3406
3407fn product_dir_path(
3408 center: AnalysisCenter,
3409 layout: ArchiveLayout,
3410 date: ProductDate,
3411) -> Result<String, DataCatalogError> {
3412 match center {
3413 AnalysisCenter::CodPrd1 => Ok(format!("CODE/IONO/P1/{}", date.year)),
3414 AnalysisCenter::CodPrd2 => Ok(format!("CODE/IONO/P2/{}", date.year)),
3415 _ => dir_path(layout, date),
3416 }
3417}
3418
3419fn uses_legacy_igs_final_name(
3420 center: AnalysisCenter,
3421 product_type: ProductType,
3422 date: ProductDate,
3423) -> Result<bool, DataCatalogError> {
3424 Ok(center == AnalysisCenter::Igs
3425 && product_type == ProductType::Sp3
3426 && date.gps_week()? < IGS_LONG_FILENAME_START_GPS_WEEK)
3427}
3428
3429fn product_archive_compression(
3430 center: AnalysisCenter,
3431 product_type: ProductType,
3432 date: ProductDate,
3433 default: ArchiveCompression,
3434) -> Result<ArchiveCompression, DataCatalogError> {
3435 if uses_legacy_igs_final_name(center, product_type, date)? {
3436 Ok(ArchiveCompression::UnixCompress)
3437 } else {
3438 Ok(default)
3439 }
3440}
3441
3442fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
3443 let (year, month, day) = civil_from_julian_day_number(jdn);
3444 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
3445 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
3446 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
3447 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
3448}