1use core::fmt;
9use core::str::FromStr;
10
11use crate::astro::time::civil::{civil_from_julian_day_number, day_of_year_int, days_in_month};
12use crate::astro::time::gnss::{week_epoch_julian_day_number, week_from_calendar};
13use crate::astro::time::model::TimeScale;
14use crate::astro::time::scales::julian_day_number;
15use crate::terrain;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum AnalysisCenter {
20 Igs,
22 CodRap,
24 CodPrd1,
26 CodPrd2,
28 Esa,
30 Cod,
32 Gfz,
34 IgsUlt,
36 CodUlt,
38 EsaUlt,
40 GfzUlt,
42}
43
44impl AnalysisCenter {
45 #[must_use]
47 pub const fn code(self) -> &'static str {
48 match self {
49 Self::Igs => "igs",
50 Self::CodRap => "cod_rap",
51 Self::CodPrd1 => "cod_prd1",
52 Self::CodPrd2 => "cod_prd2",
53 Self::Esa => "esa",
54 Self::Cod => "cod",
55 Self::Gfz => "gfz",
56 Self::IgsUlt => "igs_ult",
57 Self::CodUlt => "cod_ult",
58 Self::EsaUlt => "esa_ult",
59 Self::GfzUlt => "gfz_ult",
60 }
61 }
62
63 #[must_use]
65 pub fn from_code(code: &str) -> Option<Self> {
66 match code {
67 "igs" => Some(Self::Igs),
68 "cod_rap" => Some(Self::CodRap),
69 "cod_prd1" => Some(Self::CodPrd1),
70 "cod_prd2" => Some(Self::CodPrd2),
71 "esa" => Some(Self::Esa),
72 "cod" => Some(Self::Cod),
73 "gfz" => Some(Self::Gfz),
74 "igs_ult" => Some(Self::IgsUlt),
75 "cod_ult" => Some(Self::CodUlt),
76 "esa_ult" => Some(Self::EsaUlt),
77 "gfz_ult" => Some(Self::GfzUlt),
78 _ => None,
79 }
80 }
81}
82
83impl fmt::Display for AnalysisCenter {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.write_str(self.code())
86 }
87}
88
89impl FromStr for AnalysisCenter {
90 type Err = DataCatalogError;
91
92 fn from_str(s: &str) -> Result<Self, Self::Err> {
93 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownCenter(s.to_string()))
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
99pub enum ProductType {
100 Sp3,
102 Clk,
104 Nav,
106 Ionex,
108}
109
110impl ProductType {
111 #[must_use]
113 pub const fn code(self) -> &'static str {
114 match self {
115 Self::Sp3 => "sp3",
116 Self::Clk => "clk",
117 Self::Nav => "nav",
118 Self::Ionex => "ionex",
119 }
120 }
121
122 #[must_use]
124 pub fn from_code(code: &str) -> Option<Self> {
125 match code {
126 "sp3" => Some(Self::Sp3),
127 "clk" => Some(Self::Clk),
128 "nav" => Some(Self::Nav),
129 "ionex" => Some(Self::Ionex),
130 _ => None,
131 }
132 }
133}
134
135impl fmt::Display for ProductType {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 f.write_str(self.code())
138 }
139}
140
141impl FromStr for ProductType {
142 type Err = DataCatalogError;
143
144 fn from_str(s: &str) -> Result<Self, Self::Err> {
145 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
151pub enum SpaceWeatherProduct {
152 All,
154 Last5Years,
156}
157
158impl SpaceWeatherProduct {
159 #[must_use]
161 pub const fn code(self) -> &'static str {
162 match self {
163 Self::All => "sw_all",
164 Self::Last5Years => "sw_last5",
165 }
166 }
167
168 #[must_use]
170 pub fn from_code(code: &str) -> Option<Self> {
171 match code {
172 "sw_all" => Some(Self::All),
173 "sw_last5" => Some(Self::Last5Years),
174 _ => None,
175 }
176 }
177}
178
179impl fmt::Display for SpaceWeatherProduct {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 f.write_str(self.code())
182 }
183}
184
185impl FromStr for SpaceWeatherProduct {
186 type Err = DataCatalogError;
187
188 fn from_str(s: &str) -> Result<Self, Self::Err> {
189 Self::from_code(s).ok_or_else(|| DataCatalogError::UnknownProductType(s.to_string()))
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum ArchiveProtocol {
196 Http,
198 Https,
200}
201
202impl ArchiveProtocol {
203 #[must_use]
205 pub const fn as_str(self) -> &'static str {
206 match self {
207 Self::Http => "http",
208 Self::Https => "https",
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum ArchiveCompression {
216 Gzip,
218 None,
220}
221
222impl ArchiveCompression {
223 #[must_use]
225 pub const fn as_str(self) -> &'static str {
226 match self {
227 Self::Gzip => "gzip",
228 Self::None => "none",
229 }
230 }
231
232 const fn suffix(self) -> &'static str {
233 match self {
234 Self::Gzip => ".gz",
235 Self::None => "",
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242pub enum ArchiveLayout {
243 GfzRapidWeek,
245 GfzUltraWeek,
247 GpsWeek,
249 BkgProductsWeek,
251 BkgBrdcYearDoy,
253 BkgObsYearDoy,
255 AiubCodeMgexYear,
257 AiubCodeYear,
259 AiubCodeRoot,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum ProductFilenameKind {
266 Sampled,
268 Nav,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct ProductTypeConvention {
275 pub product_type: ProductType,
277 pub content_code: &'static str,
279 pub extension: &'static str,
281 pub kind: ProductFilenameKind,
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub struct CenterProductConvention {
288 pub product_type: ProductType,
290 pub token: &'static str,
292 pub layout: ArchiveLayout,
294 pub span: &'static str,
296 pub default_sample: &'static str,
298 pub compression: ArchiveCompression,
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct CenterCatalogEntry {
305 pub center: AnalysisCenter,
307 pub code: &'static str,
309 pub protocol: ArchiveProtocol,
311 pub host: &'static str,
313 pub root_url: &'static str,
315 pub products: &'static [CenterProductConvention],
317 pub issues: &'static [&'static str],
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct TerrainSourceEntry {
324 pub protocol: ArchiveProtocol,
326 pub host: &'static str,
328 pub compression: ArchiveCompression,
330 pub root_url: &'static str,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub struct SpaceWeatherSourceEntry {
337 pub protocol: ArchiveProtocol,
339 pub host: &'static str,
341 pub compression: ArchiveCompression,
343 pub root_url: &'static str,
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub struct NoOpenMirrorProduct {
350 pub center: &'static str,
352 pub product_type: &'static str,
354}
355
356const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
357 ProductTypeConvention {
358 product_type: ProductType::Sp3,
359 content_code: "ORB",
360 extension: "SP3",
361 kind: ProductFilenameKind::Sampled,
362 },
363 ProductTypeConvention {
364 product_type: ProductType::Clk,
365 content_code: "CLK",
366 extension: "CLK",
367 kind: ProductFilenameKind::Sampled,
368 },
369 ProductTypeConvention {
370 product_type: ProductType::Nav,
371 content_code: "MN",
372 extension: "rnx",
373 kind: ProductFilenameKind::Nav,
374 },
375 ProductTypeConvention {
376 product_type: ProductType::Ionex,
377 content_code: "GIM",
378 extension: "INX",
379 kind: ProductFilenameKind::Sampled,
380 },
381];
382
383const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
384 product_type: ProductType::Ionex,
385 token: "COD0OPSRAP",
386 layout: ArchiveLayout::AiubCodeRoot,
387 span: "01D",
388 default_sample: "01H",
389 compression: ArchiveCompression::Gzip,
390}];
391
392const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
393 product_type: ProductType::Ionex,
394 token: "COD0OPSPRD",
395 layout: ArchiveLayout::AiubCodeRoot,
396 span: "01D",
397 default_sample: "01H",
398 compression: ArchiveCompression::Gzip,
399}];
400
401const ESA_PRODUCTS: [CenterProductConvention; 3] = [
402 CenterProductConvention {
403 product_type: ProductType::Sp3,
404 token: "ESA0MGNFIN",
405 layout: ArchiveLayout::GpsWeek,
406 span: "01D",
407 default_sample: "05M",
408 compression: ArchiveCompression::Gzip,
409 },
410 CenterProductConvention {
411 product_type: ProductType::Clk,
412 token: "ESA0MGNFIN",
413 layout: ArchiveLayout::GpsWeek,
414 span: "01D",
415 default_sample: "30S",
416 compression: ArchiveCompression::Gzip,
417 },
418 CenterProductConvention {
419 product_type: ProductType::Ionex,
420 token: "ESA0OPSFIN",
421 layout: ArchiveLayout::GpsWeek,
422 span: "01D",
423 default_sample: "02H",
424 compression: ArchiveCompression::Gzip,
425 },
426];
427
428const COD_PRODUCTS: [CenterProductConvention; 3] = [
429 CenterProductConvention {
430 product_type: ProductType::Sp3,
431 token: "COD0MGXFIN",
432 layout: ArchiveLayout::AiubCodeMgexYear,
433 span: "01D",
434 default_sample: "05M",
435 compression: ArchiveCompression::Gzip,
436 },
437 CenterProductConvention {
438 product_type: ProductType::Clk,
439 token: "COD0MGXFIN",
440 layout: ArchiveLayout::AiubCodeMgexYear,
441 span: "01D",
442 default_sample: "30S",
443 compression: ArchiveCompression::Gzip,
444 },
445 CenterProductConvention {
446 product_type: ProductType::Ionex,
447 token: "COD0OPSFIN",
448 layout: ArchiveLayout::AiubCodeYear,
449 span: "01D",
450 default_sample: "01H",
451 compression: ArchiveCompression::Gzip,
452 },
453];
454
455const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
456 CenterProductConvention {
457 product_type: ProductType::Sp3,
458 token: "GFZ0OPSRAP",
459 layout: ArchiveLayout::GfzRapidWeek,
460 span: "01D",
461 default_sample: "15M",
462 compression: ArchiveCompression::Gzip,
463 },
464 CenterProductConvention {
465 product_type: ProductType::Clk,
466 token: "GFZ0OPSRAP",
467 layout: ArchiveLayout::GfzRapidWeek,
468 span: "01D",
469 default_sample: "30S",
470 compression: ArchiveCompression::Gzip,
471 },
472];
473
474const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
475 product_type: ProductType::Nav,
476 token: "BRDC00WRD",
477 layout: ArchiveLayout::BkgBrdcYearDoy,
478 span: "01D",
479 default_sample: "01D",
480 compression: ArchiveCompression::Gzip,
481}];
482
483const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
484 product_type: ProductType::Sp3,
485 token: "IGS0OPSULT",
486 layout: ArchiveLayout::BkgProductsWeek,
487 span: "02D",
488 default_sample: "15M",
489 compression: ArchiveCompression::Gzip,
490}];
491
492const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
493 product_type: ProductType::Sp3,
494 token: "COD0OPSULT",
495 layout: ArchiveLayout::AiubCodeRoot,
496 span: "01D",
497 default_sample: "05M",
498 compression: ArchiveCompression::None,
499}];
500
501const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
502 product_type: ProductType::Sp3,
503 token: "ESA0OPSULT",
504 layout: ArchiveLayout::GpsWeek,
505 span: "02D",
506 default_sample: "05M",
507 compression: ArchiveCompression::Gzip,
508}];
509
510const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
511 product_type: ProductType::Sp3,
512 token: "GFZ0OPSULT",
513 layout: ArchiveLayout::GfzUltraWeek,
514 span: "02D",
515 default_sample: "05M",
516 compression: ArchiveCompression::Gzip,
517}];
518
519const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
520const COD_ULT_ISSUES: [&str; 1] = ["0000"];
521const GFZ_ULT_ISSUES: [&str; 8] = [
522 "0000", "0300", "0600", "0900", "1200", "1500", "1800", "2100",
523];
524
525const CENTER_ORDER: [AnalysisCenter; 11] = [
526 AnalysisCenter::CodRap,
527 AnalysisCenter::CodPrd1,
528 AnalysisCenter::CodPrd2,
529 AnalysisCenter::Igs,
530 AnalysisCenter::Esa,
531 AnalysisCenter::Cod,
532 AnalysisCenter::Gfz,
533 AnalysisCenter::IgsUlt,
534 AnalysisCenter::CodUlt,
535 AnalysisCenter::EsaUlt,
536 AnalysisCenter::GfzUlt,
537];
538
539const CATALOG: [CenterCatalogEntry; 11] = [
540 CenterCatalogEntry {
541 center: AnalysisCenter::CodRap,
542 code: "cod_rap",
543 protocol: ArchiveProtocol::Http,
544 host: "ftp.aiub.unibe.ch",
545 root_url: "http://ftp.aiub.unibe.ch",
546 products: &COD_RAP_PRODUCTS,
547 issues: &[],
548 },
549 CenterCatalogEntry {
550 center: AnalysisCenter::CodPrd1,
551 code: "cod_prd1",
552 protocol: ArchiveProtocol::Http,
553 host: "ftp.aiub.unibe.ch",
554 root_url: "http://ftp.aiub.unibe.ch",
555 products: &COD_PRD_PRODUCTS,
556 issues: &[],
557 },
558 CenterCatalogEntry {
559 center: AnalysisCenter::CodPrd2,
560 code: "cod_prd2",
561 protocol: ArchiveProtocol::Http,
562 host: "ftp.aiub.unibe.ch",
563 root_url: "http://ftp.aiub.unibe.ch",
564 products: &COD_PRD_PRODUCTS,
565 issues: &[],
566 },
567 CenterCatalogEntry {
568 center: AnalysisCenter::Igs,
569 code: "igs",
570 protocol: ArchiveProtocol::Https,
571 host: "igs.bkg.bund.de",
572 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
573 products: &IGS_PRODUCTS,
574 issues: &[],
575 },
576 CenterCatalogEntry {
577 center: AnalysisCenter::Esa,
578 code: "esa",
579 protocol: ArchiveProtocol::Https,
580 host: "navigation-office.esa.int",
581 root_url: "https://navigation-office.esa.int/products/gnss-products",
582 products: &ESA_PRODUCTS,
583 issues: &[],
584 },
585 CenterCatalogEntry {
586 center: AnalysisCenter::Cod,
587 code: "cod",
588 protocol: ArchiveProtocol::Http,
589 host: "ftp.aiub.unibe.ch",
590 root_url: "http://ftp.aiub.unibe.ch",
591 products: &COD_PRODUCTS,
592 issues: &[],
593 },
594 CenterCatalogEntry {
595 center: AnalysisCenter::Gfz,
596 code: "gfz",
597 protocol: ArchiveProtocol::Https,
598 host: "isdc-data.gfz.de",
599 root_url: "https://isdc-data.gfz.de/gnss/products",
600 products: &GFZ_PRODUCTS,
601 issues: &[],
602 },
603 CenterCatalogEntry {
604 center: AnalysisCenter::IgsUlt,
605 code: "igs_ult",
606 protocol: ArchiveProtocol::Https,
607 host: "igs.bkg.bund.de",
608 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
609 products: &IGS_ULT_PRODUCTS,
610 issues: &OPSULT_ISSUES,
611 },
612 CenterCatalogEntry {
613 center: AnalysisCenter::CodUlt,
614 code: "cod_ult",
615 protocol: ArchiveProtocol::Https,
616 host: "www.aiub.unibe.ch",
617 root_url: "https://www.aiub.unibe.ch/download",
621 products: &COD_ULT_PRODUCTS,
622 issues: &COD_ULT_ISSUES,
623 },
624 CenterCatalogEntry {
625 center: AnalysisCenter::EsaUlt,
626 code: "esa_ult",
627 protocol: ArchiveProtocol::Https,
628 host: "navigation-office.esa.int",
629 root_url: "https://navigation-office.esa.int/products/gnss-products",
630 products: &ESA_ULT_PRODUCTS,
631 issues: &OPSULT_ISSUES,
632 },
633 CenterCatalogEntry {
634 center: AnalysisCenter::GfzUlt,
635 code: "gfz_ult",
636 protocol: ArchiveProtocol::Https,
637 host: "isdc-data.gfz.de",
638 root_url: "https://isdc-data.gfz.de/gnss/products",
639 products: &GFZ_ULT_PRODUCTS,
640 issues: &GFZ_ULT_ISSUES,
641 },
642];
643
644const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
645 protocol: ArchiveProtocol::Https,
646 host: "s3.amazonaws.com",
647 compression: ArchiveCompression::Gzip,
648 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
649};
650
651const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
652 protocol: ArchiveProtocol::Https,
653 host: "celestrak.org",
654 compression: ArchiveCompression::None,
655 root_url: "https://celestrak.org/SpaceData",
656};
657
658const ALLOWED_HOSTS: [&str; 7] = [
659 "ftp.aiub.unibe.ch",
660 "www.aiub.unibe.ch",
661 "navigation-office.esa.int",
662 "isdc-data.gfz.de",
663 "igs.bkg.bund.de",
664 "s3.amazonaws.com",
665 "celestrak.org",
666];
667
668const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
669 NoOpenMirrorProduct {
670 center: "grg",
671 product_type: "sp3",
672 },
673 NoOpenMirrorProduct {
674 center: "grg",
675 product_type: "clk",
676 },
677 NoOpenMirrorProduct {
678 center: "wum",
679 product_type: "sp3",
680 },
681 NoOpenMirrorProduct {
682 center: "wum",
683 product_type: "clk",
684 },
685 NoOpenMirrorProduct {
686 center: "grg_ult",
687 product_type: "sp3",
688 },
689 NoOpenMirrorProduct {
690 center: "grg_ult",
691 product_type: "clk",
692 },
693 NoOpenMirrorProduct {
694 center: "igs",
695 product_type: "ionex",
696 },
697];
698
699#[derive(Debug, Clone, PartialEq, Eq)]
701pub enum DataCatalogError {
702 UnknownCenter(String),
704 UnknownProductType(String),
706 UnsupportedProduct {
708 center: AnalysisCenter,
710 product_type: ProductType,
712 },
713 NoOpenMirror {
715 center: String,
717 product_type: String,
719 },
720 InvalidDate {
722 year: i32,
724 month: u8,
726 day: u8,
728 },
729 DateOutOfRange,
731 DateBeforeGpsEpoch(ProductDate),
733 InvalidGpsDayOfWeek(u8),
735 InvalidSample(String),
737 InvalidIssue(String),
739 MissingIssue {
741 center: AnalysisCenter,
743 },
744 UnexpectedIssue {
746 center: AnalysisCenter,
748 },
749 UnsupportedIssue {
751 center: AnalysisCenter,
753 issue: String,
755 },
756 InvalidDateTime {
758 hour: u8,
760 minute: u8,
762 second: u8,
764 },
765 NoUltraIssue,
767 NoAvailableUltraIssue,
769 InvalidStation(String),
771 InvalidCoordinate {
773 lat_deg_bits: u64,
775 lon_deg_bits: u64,
777 },
778 InvalidTileIndex {
780 lat_index: i32,
782 lon_index: i32,
784 },
785 InvalidTileId(String),
787}
788
789impl fmt::Display for DataCatalogError {
790 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
791 match self {
792 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
793 Self::UnknownProductType(product_type) => {
794 write!(f, "unknown product type {product_type:?}")
795 }
796 Self::UnsupportedProduct {
797 center,
798 product_type,
799 } => write!(f, "{center} does not serve {product_type}"),
800 Self::NoOpenMirror {
801 center,
802 product_type,
803 } => write!(f, "{center}/{product_type} has no open mirror"),
804 Self::InvalidDate { year, month, day } => {
805 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
806 }
807 Self::DateOutOfRange => write!(f, "product date is out of range"),
808 Self::DateBeforeGpsEpoch(date) => {
809 write!(f, "product date {date} is before the GPS week epoch")
810 }
811 Self::InvalidGpsDayOfWeek(day) => {
812 write!(f, "invalid GPS day-of-week {day}")
813 }
814 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
815 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
816 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
817 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
818 Self::UnsupportedIssue { center, issue } => {
819 write!(f, "{center} does not publish issue {issue:?}")
820 }
821 Self::InvalidDateTime {
822 hour,
823 minute,
824 second,
825 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
826 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
827 Self::NoAvailableUltraIssue => {
828 write!(f, "no available ultra-rapid issue at or before target")
829 }
830 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
831 Self::InvalidCoordinate {
832 lat_deg_bits,
833 lon_deg_bits,
834 } => write!(
835 f,
836 "invalid terrain coordinate lat={} lon={}",
837 f64::from_bits(*lat_deg_bits),
838 f64::from_bits(*lon_deg_bits)
839 ),
840 Self::InvalidTileIndex {
841 lat_index,
842 lon_index,
843 } => write!(
844 f,
845 "invalid terrain tile index lat={lat_index} lon={lon_index}"
846 ),
847 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
848 }
849 }
850}
851
852impl std::error::Error for DataCatalogError {}
853
854#[derive(Debug, Clone, PartialEq, Eq)]
856pub enum HgtConversionError {
857 BadLength {
859 expected: usize,
861 got: usize,
863 },
864 InvalidTileIndex {
866 lat_index: i32,
868 lon_index: i32,
870 },
871}
872
873impl fmt::Display for HgtConversionError {
874 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
875 match self {
876 Self::BadLength { expected, got } => {
877 write!(
878 f,
879 "invalid SRTM1 HGT length: expected {expected}, got {got}"
880 )
881 }
882 Self::InvalidTileIndex {
883 lat_index,
884 lon_index,
885 } => write!(
886 f,
887 "invalid terrain tile index lat={lat_index} lon={lon_index}"
888 ),
889 }
890 }
891}
892
893impl std::error::Error for HgtConversionError {}
894
895const MIN_TERRAIN_LAT_INDEX: i32 = -90;
896const MAX_TERRAIN_LAT_INDEX: i32 = 89;
897const MIN_TERRAIN_LON_INDEX: i32 = -180;
898const MAX_TERRAIN_LON_INDEX: i32 = 179;
899const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
900const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
901const MIN_TERRAIN_LON_DEG: f64 = -180.0;
902const MAX_TERRAIN_LON_DEG: f64 = 180.0;
903const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
904const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
905const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
906const DTED_SRTM1_LEN: usize =
907 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
908
909#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
911pub struct ProductDate {
912 pub year: i32,
914 pub month: u8,
916 pub day: u8,
918}
919
920impl ProductDate {
921 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
923 let days = days_in_month(i64::from(year), i64::from(month));
924 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
925 return Err(DataCatalogError::InvalidDate { year, month, day });
926 }
927 Ok(Self { year, month, day })
928 }
929
930 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
932 if day_of_week > 6 {
933 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
934 }
935 let epoch_jdn =
936 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
937 let offset_days = i64::from(week)
938 .checked_mul(7)
939 .and_then(|days| days.checked_add(i64::from(day_of_week)))
940 .ok_or(DataCatalogError::DateOutOfRange)?;
941 product_date_from_jdn(
942 epoch_jdn
943 .checked_add(offset_days)
944 .ok_or(DataCatalogError::DateOutOfRange)?,
945 )
946 }
947
948 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
950 week_from_calendar(
951 TimeScale::Gpst,
952 i64::from(self.year),
953 i64::from(self.month),
954 i64::from(self.day),
955 )
956 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
957 }
958
959 #[must_use]
961 pub fn day_of_year(self) -> u16 {
962 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
963 }
964
965 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
966 product_date_from_jdn(
967 self.julian_day_number()
968 .checked_add(days)
969 .ok_or(DataCatalogError::DateOutOfRange)?,
970 )
971 }
972
973 fn julian_day_number(self) -> i64 {
974 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
975 }
976}
977
978impl fmt::Display for ProductDate {
979 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
980 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
981 }
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
986pub struct ProductDateTime {
987 pub date: ProductDate,
989 pub hour: u8,
991 pub minute: u8,
993 pub second: u8,
995}
996
997impl ProductDateTime {
998 pub fn new(
1000 date: ProductDate,
1001 hour: u8,
1002 minute: u8,
1003 second: u8,
1004 ) -> Result<Self, DataCatalogError> {
1005 if hour > 23 || minute > 59 || second > 59 {
1006 return Err(DataCatalogError::InvalidDateTime {
1007 hour,
1008 minute,
1009 second,
1010 });
1011 }
1012 Ok(Self {
1013 date,
1014 hour,
1015 minute,
1016 second,
1017 })
1018 }
1019
1020 fn ordering_minutes(self) -> i64 {
1021 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1022 }
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1027pub struct UltraIssue {
1028 pub date: ProductDate,
1030 pub issue: String,
1032}
1033
1034impl UltraIssue {
1035 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1037 validate_issue(issue)?;
1038 Ok(Self {
1039 date,
1040 issue: issue.to_string(),
1041 })
1042 }
1043}
1044
1045#[derive(Debug, Clone, PartialEq, Eq)]
1047pub struct UltraSp3Location {
1048 pub pattern: String,
1050 pub span: String,
1052 pub sample: String,
1054 pub filename: String,
1056 pub url: String,
1058 pub compression: ArchiveCompression,
1060}
1061
1062#[derive(Debug, Clone, Copy)]
1063struct UltraSp3Pattern {
1064 label: &'static str,
1065 span: &'static str,
1066 sample: &'static str,
1067 alias_filename: Option<&'static str>,
1068}
1069
1070const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1071 UltraSp3Pattern {
1072 label: "primary_02D_15M",
1073 span: "02D",
1074 sample: "15M",
1075 alias_filename: None,
1076 },
1077 UltraSp3Pattern {
1078 label: "alternate_02D_05M",
1079 span: "02D",
1080 sample: "05M",
1081 alias_filename: None,
1082 },
1083 UltraSp3Pattern {
1084 label: "alternate_01D_15M",
1085 span: "01D",
1086 sample: "15M",
1087 alias_filename: None,
1088 },
1089];
1090
1091const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1092 UltraSp3Pattern {
1093 label: "primary_01D_05M",
1094 span: "01D",
1095 sample: "05M",
1096 alias_filename: None,
1097 },
1098 UltraSp3Pattern {
1099 label: "alternate_02D_05M",
1100 span: "02D",
1101 sample: "05M",
1102 alias_filename: None,
1103 },
1104 UltraSp3Pattern {
1105 label: "alias_latest",
1106 span: "01D",
1107 sample: "05M",
1108 alias_filename: Some("COD0OPSULT.SP3"),
1109 },
1110];
1111
1112const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1113 UltraSp3Pattern {
1114 label: "primary_02D_05M",
1115 span: "02D",
1116 sample: "05M",
1117 alias_filename: None,
1118 },
1119 UltraSp3Pattern {
1120 label: "alternate_02D_15M",
1121 span: "02D",
1122 sample: "15M",
1123 alias_filename: None,
1124 },
1125 UltraSp3Pattern {
1126 label: "alternate_01D_05M",
1127 span: "01D",
1128 sample: "05M",
1129 alias_filename: None,
1130 },
1131];
1132
1133const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1134 UltraSp3Pattern {
1135 label: "primary_02D_05M",
1136 span: "02D",
1137 sample: "05M",
1138 alias_filename: None,
1139 },
1140 UltraSp3Pattern {
1141 label: "alternate_02D_15M",
1142 span: "02D",
1143 sample: "15M",
1144 alias_filename: None,
1145 },
1146 UltraSp3Pattern {
1147 label: "alternate_01D_05M",
1148 span: "01D",
1149 sample: "05M",
1150 alias_filename: None,
1151 },
1152];
1153
1154#[derive(Debug, Clone, PartialEq, Eq)]
1156pub struct ProductSpec {
1157 pub center: AnalysisCenter,
1159 pub product_type: ProductType,
1161 pub date: ProductDate,
1163 pub sample: String,
1165 pub issue: Option<String>,
1167}
1168
1169impl ProductSpec {
1170 pub fn new(
1172 center: AnalysisCenter,
1173 product_type: ProductType,
1174 date: ProductDate,
1175 sample: &str,
1176 issue: Option<&str>,
1177 ) -> Result<Self, DataCatalogError> {
1178 validate_product(center, product_type, sample, issue)?;
1179 Ok(Self {
1180 center,
1181 product_type,
1182 date,
1183 sample: sample.to_string(),
1184 issue: issue.map(ToOwned::to_owned),
1185 })
1186 }
1187
1188 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1190 self.date.gps_week()
1191 }
1192
1193 #[must_use]
1195 pub fn day_of_year(&self) -> u16 {
1196 self.date.day_of_year()
1197 }
1198
1199 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1201 let convention = validate_product(
1202 self.center,
1203 self.product_type,
1204 &self.sample,
1205 self.issue.as_deref(),
1206 )?;
1207 let descriptor = product_type_convention(self.product_type);
1208 Ok(match descriptor.kind {
1209 ProductFilenameKind::Sampled => format!(
1210 "{}_{}_{}_{}_{}.{}",
1211 convention.token,
1212 date_block(self.date, self.issue.as_deref()),
1213 convention.span,
1214 self.sample,
1215 descriptor.content_code,
1216 descriptor.extension
1217 ),
1218 ProductFilenameKind::Nav => format!(
1219 "{}_R_{}_{}_{}.{}",
1220 convention.token,
1221 date_block(self.date, None),
1222 convention.span,
1223 descriptor.content_code,
1224 descriptor.extension
1225 ),
1226 })
1227 }
1228
1229 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1231 let convention = validate_product(
1232 self.center,
1233 self.product_type,
1234 &self.sample,
1235 self.issue.as_deref(),
1236 )?;
1237 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1238 let filename = self.canonical_filename()?;
1239 Ok(format!(
1240 "{}/{}/{}{}",
1241 entry.root_url,
1242 dir_path(convention.layout, self.date)?,
1243 filename,
1244 convention.compression.suffix()
1245 ))
1246 }
1247}
1248
1249#[derive(Debug, Clone, PartialEq, Eq)]
1251pub struct StationObservationSpec {
1252 pub station: String,
1254 pub date: ProductDate,
1256 pub sample: String,
1258}
1259
1260impl StationObservationSpec {
1261 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1263 validate_station(station)?;
1264 validate_sample(sample)?;
1265 Ok(Self {
1266 station: station.to_string(),
1267 date,
1268 sample: sample.to_string(),
1269 })
1270 }
1271
1272 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1274 station_obs_filename(&self.station, self.date, &self.sample)
1275 }
1276
1277 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1279 station_obs_url(&self.station, self.date, &self.sample)
1280 }
1281}
1282
1283#[must_use]
1285pub const fn catalog() -> &'static [CenterCatalogEntry] {
1286 &CATALOG
1287}
1288
1289#[must_use]
1291pub const fn centers() -> &'static [AnalysisCenter] {
1292 &CENTER_ORDER
1293}
1294
1295#[must_use]
1297pub const fn product_types() -> &'static [ProductTypeConvention] {
1298 &PRODUCT_TYPE_CONVENTIONS
1299}
1300
1301#[must_use]
1303pub const fn allowed_hosts() -> &'static [&'static str] {
1304 &ALLOWED_HOSTS
1305}
1306
1307#[must_use]
1309pub const fn skadi_source_entry() -> TerrainSourceEntry {
1310 SKADI_SOURCE
1311}
1312
1313#[must_use]
1315pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1316 CELESTRAK_SPACE_WEATHER_SOURCE
1317}
1318
1319#[must_use]
1321pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1322 match product {
1323 SpaceWeatherProduct::All => "SW-All.csv",
1324 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1325 }
1326}
1327
1328#[must_use]
1330pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1331 format!(
1332 "{}/{}",
1333 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1334 space_weather_filename(product)
1335 )
1336}
1337
1338#[must_use]
1340pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1341 format!("space-weather/{}", space_weather_filename(product))
1342}
1343
1344pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1346 validate_terrain_tile_index(lat_index, lon_index)?;
1347 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1348 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1349 Ok(format!(
1350 "{lat_hemi}{:02}{lon_hemi}{:03}",
1351 lat_index.abs(),
1352 lon_index.abs()
1353 ))
1354}
1355
1356pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1358 validate_terrain_lat_index(lat_index)?;
1359 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1360 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1361}
1362
1363pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1365 let band = skadi_band(lat_index)?;
1366 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1367 Ok(format!(
1368 "{}/skadi/{}/{}.hgt{}",
1369 SKADI_SOURCE.root_url,
1370 band,
1371 tile_id,
1372 SKADI_SOURCE.compression.suffix()
1373 ))
1374}
1375
1376pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1378 validate_terrain_tile_index(lat_index, lon_index)?;
1379 Ok(format!(
1380 "{}_{}{}",
1381 terrain::format_lat(lat_index),
1382 terrain::format_lon(lon_index),
1383 terrain::DTED_SUFFIX
1384 ))
1385}
1386
1387pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1389 validate_terrain_tile_index(lat_index, lon_index)?;
1390 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1391}
1392
1393pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1395 Ok(format!(
1396 "{}/{}",
1397 dted_block_dir(lat_index, lon_index)?,
1398 dted_tile_filename(lat_index, lon_index)?
1399 ))
1400}
1401
1402pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1404 let bytes = id.as_bytes();
1405 if bytes.len() != 7
1406 || !matches!(bytes[0], b'N' | b'S')
1407 || !matches!(bytes[3], b'E' | b'W')
1408 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1409 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1410 {
1411 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1412 }
1413
1414 let lat_abs = id[1..3]
1415 .parse::<i32>()
1416 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1417 let lon_abs = id[4..7]
1418 .parse::<i32>()
1419 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1420 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1421 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1422 }
1423
1424 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1425 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1426 validate_terrain_tile_index(lat_index, lon_index)?;
1427 Ok((lat_index, lon_index))
1428}
1429
1430pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1432 if !lat_deg.is_finite()
1433 || !lon_deg.is_finite()
1434 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1435 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1436 {
1437 return Err(DataCatalogError::InvalidCoordinate {
1438 lat_deg_bits: lat_deg.to_bits(),
1439 lon_deg_bits: lon_deg.to_bits(),
1440 });
1441 }
1442
1443 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1444 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1445 lat_index = MAX_TERRAIN_LAT_INDEX;
1446 }
1447 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1448 lon_index = MAX_TERRAIN_LON_INDEX;
1449 }
1450 validate_terrain_tile_index(lat_index, lon_index)?;
1451 Ok((lat_index, lon_index))
1452}
1453
1454pub fn hgt_to_dted(
1462 lat_index: i32,
1463 lon_index: i32,
1464 hgt: &[u8],
1465) -> Result<Vec<u8>, HgtConversionError> {
1466 validate_hgt_tile_index(lat_index, lon_index)?;
1467 if hgt.len() != SRTM1_HGT_LEN {
1468 return Err(HgtConversionError::BadLength {
1469 expected: SRTM1_HGT_LEN,
1470 got: hgt.len(),
1471 });
1472 }
1473
1474 let mut out = vec![b' '; DTED_SRTM1_LEN];
1475 out[0..4].copy_from_slice(b"UHL1");
1476 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1477 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1478 out[47..51].copy_from_slice(b"3601");
1479 out[51..55].copy_from_slice(b"3601");
1480
1481 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1482 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1483 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1484 out[block_start] = terrain::DATA_SENTINEL;
1485
1486 let count = (lon_posting as u32).to_be_bytes();
1487 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1488 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1489 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1490
1491 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1492 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1493 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1494 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1495 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1496 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1497 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1498 }
1499
1500 let checksum = out[block_start..checksum_start]
1501 .iter()
1502 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1503 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1504 }
1505
1506 debug_assert_eq!(out.len(), 25_981_042);
1507 Ok(out)
1508}
1509
1510#[must_use]
1512pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1513 &NO_OPEN_MIRRORS
1514}
1515
1516pub fn open_mirror(
1518 center: AnalysisCenter,
1519 product_type: ProductType,
1520) -> Result<(), DataCatalogError> {
1521 open_mirror_code(center.code(), product_type.code())
1522}
1523
1524pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
1526 if NO_OPEN_MIRRORS
1527 .iter()
1528 .any(|entry| entry.center == center && entry.product_type == product_type)
1529 {
1530 Err(DataCatalogError::NoOpenMirror {
1531 center: center.to_string(),
1532 product_type: product_type.to_string(),
1533 })
1534 } else {
1535 Ok(())
1536 }
1537}
1538
1539#[must_use]
1541pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1542 CATALOG.iter().find(|entry| entry.center == center)
1543}
1544
1545pub fn product_convention(
1547 center: AnalysisCenter,
1548 product_type: ProductType,
1549) -> Result<&'static CenterProductConvention, DataCatalogError> {
1550 open_mirror(center, product_type)?;
1551 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1552 entry
1553 .products
1554 .iter()
1555 .find(|product| product.product_type == product_type)
1556 .ok_or(DataCatalogError::UnsupportedProduct {
1557 center,
1558 product_type,
1559 })
1560}
1561
1562pub fn default_sample(
1564 center: AnalysisCenter,
1565 product_type: ProductType,
1566) -> Result<&'static str, DataCatalogError> {
1567 Ok(product_convention(center, product_type)?.default_sample)
1568}
1569
1570pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1572 date.gps_week()
1573}
1574
1575#[must_use]
1577pub fn day_of_year(date: ProductDate) -> u16 {
1578 date.day_of_year()
1579}
1580
1581pub fn product(
1583 center: AnalysisCenter,
1584 product_type: ProductType,
1585 date: ProductDate,
1586 sample: Option<&str>,
1587 issue: Option<&str>,
1588) -> Result<ProductSpec, DataCatalogError> {
1589 let sample = match sample {
1590 Some(sample) => sample,
1591 None => default_sample(center, product_type)?,
1592 };
1593 ProductSpec::new(center, product_type, date, sample, issue)
1594}
1595
1596pub fn canonical_filename(
1598 center: AnalysisCenter,
1599 product_type: ProductType,
1600 date: ProductDate,
1601 sample: Option<&str>,
1602 issue: Option<&str>,
1603) -> Result<String, DataCatalogError> {
1604 product(center, product_type, date, sample, issue)?.canonical_filename()
1605}
1606
1607pub fn archive_url(
1609 center: AnalysisCenter,
1610 product_type: ProductType,
1611 date: ProductDate,
1612 sample: Option<&str>,
1613 issue: Option<&str>,
1614) -> Result<String, DataCatalogError> {
1615 product(center, product_type, date, sample, issue)?.archive_url()
1616}
1617
1618pub fn mgex_clk(
1620 center: AnalysisCenter,
1621 date: ProductDate,
1622 sample: Option<&str>,
1623) -> Result<ProductSpec, DataCatalogError> {
1624 product(center, ProductType::Clk, date, sample, None)
1625}
1626
1627pub fn mgex_nav(
1629 center: AnalysisCenter,
1630 date: ProductDate,
1631 sample: Option<&str>,
1632) -> Result<ProductSpec, DataCatalogError> {
1633 product(center, ProductType::Nav, date, sample, None)
1634}
1635
1636pub fn mgex_ionex(
1638 center: AnalysisCenter,
1639 date: ProductDate,
1640 sample: Option<&str>,
1641) -> Result<ProductSpec, DataCatalogError> {
1642 product(center, ProductType::Ionex, date, sample, None)
1643}
1644
1645pub fn rapid_ionex(
1647 date: ProductDate,
1648 sample: Option<&str>,
1649) -> Result<ProductSpec, DataCatalogError> {
1650 product(
1651 AnalysisCenter::CodRap,
1652 ProductType::Ionex,
1653 date,
1654 sample,
1655 None,
1656 )
1657}
1658
1659#[must_use]
1661pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1662 match center {
1663 AnalysisCenter::CodPrd2 => 1,
1664 _ => 0,
1665 }
1666}
1667
1668pub fn predicted_ionex(
1670 center: AnalysisCenter,
1671 date: ProductDate,
1672 sample: Option<&str>,
1673) -> Result<ProductSpec, DataCatalogError> {
1674 match center {
1675 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
1676 let target = date.add_days(predicted_day_offset(center))?;
1677 product(center, ProductType::Ionex, target, sample, None)
1678 }
1679 other => Err(DataCatalogError::UnsupportedProduct {
1680 center: other,
1681 product_type: ProductType::Ionex,
1682 }),
1683 }
1684}
1685
1686pub fn mgex_sp3(
1688 center: AnalysisCenter,
1689 date: ProductDate,
1690 sample: Option<&str>,
1691) -> Result<ProductSpec, DataCatalogError> {
1692 product(center, ProductType::Sp3, date, sample, None)
1693}
1694
1695pub fn ops_ultra_sp3(
1697 center: AnalysisCenter,
1698 date: ProductDate,
1699 sample: Option<&str>,
1700 issue: Option<&str>,
1701) -> Result<ProductSpec, DataCatalogError> {
1702 let issue = issue.unwrap_or("0000");
1703 product(center, ProductType::Sp3, date, sample, Some(issue))
1704}
1705
1706pub fn ultra_sp3_locations(
1713 center: AnalysisCenter,
1714 date: ProductDate,
1715 issue: &str,
1716) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
1717 validate_issue_for_center(center, Some(issue))?;
1718 let patterns: &[UltraSp3Pattern] = match center {
1719 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
1720 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
1721 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
1722 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
1723 other => {
1724 return Err(DataCatalogError::UnsupportedProduct {
1725 center: other,
1726 product_type: ProductType::Sp3,
1727 })
1728 }
1729 };
1730 let convention = product_convention(center, ProductType::Sp3)?;
1731 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1732 let directory = dir_path(convention.layout, date)?;
1733 let date = date_block(date, Some(issue));
1734
1735 Ok(patterns
1736 .iter()
1737 .map(|pattern| {
1738 let filename = pattern.alias_filename.map_or_else(
1739 || {
1740 format!(
1741 "{}_{}_{}_{}_ORB.SP3",
1742 convention.token, date, pattern.span, pattern.sample
1743 )
1744 },
1745 ToOwned::to_owned,
1746 );
1747 UltraSp3Location {
1748 pattern: pattern.label.to_string(),
1749 span: pattern.span.to_string(),
1750 sample: pattern.sample.to_string(),
1751 url: format!(
1752 "{}/{}/{}{}",
1753 entry.root_url,
1754 directory,
1755 filename,
1756 convention.compression.suffix()
1757 ),
1758 filename,
1759 compression: convention.compression,
1760 }
1761 })
1762 .collect())
1763}
1764
1765pub fn ops_ultra_clk(
1767 center: AnalysisCenter,
1768 date: ProductDate,
1769 sample: Option<&str>,
1770 issue: Option<&str>,
1771) -> Result<ProductSpec, DataCatalogError> {
1772 let issue = issue.unwrap_or("0000");
1773 product(center, ProductType::Clk, date, sample, Some(issue))
1774}
1775
1776pub fn latest_ops_ultra_sp3(
1778 center: AnalysisCenter,
1779 target: ProductDateTime,
1780 sample: Option<&str>,
1781 available_issues: Option<&[UltraIssue]>,
1782) -> Result<ProductSpec, DataCatalogError> {
1783 let selected = latest_ultra_issue(center, target, available_issues)?;
1784 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
1785}
1786
1787pub fn ultra_issue_candidates(
1789 center: AnalysisCenter,
1790 target: ProductDateTime,
1791) -> Result<Vec<UltraIssue>, DataCatalogError> {
1792 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1793 let _ = product_convention(center, ProductType::Sp3)?;
1794 if entry.issues.is_empty() {
1795 return Err(DataCatalogError::UnsupportedProduct {
1796 center,
1797 product_type: ProductType::Sp3,
1798 });
1799 }
1800
1801 let mut candidates = Vec::new();
1802 for date in [target.date, target.date.add_days(-1)?] {
1803 for issue in entry.issues.iter().rev() {
1804 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
1805 candidates.push(UltraIssue::new(date, issue)?);
1806 }
1807 }
1808 }
1809 Ok(candidates)
1810}
1811
1812pub fn latest_ultra_issue(
1814 center: AnalysisCenter,
1815 target: ProductDateTime,
1816 available_issues: Option<&[UltraIssue]>,
1817) -> Result<UltraIssue, DataCatalogError> {
1818 let candidates = ultra_issue_candidates(center, target)?;
1819 if candidates.is_empty() {
1820 return Err(DataCatalogError::NoUltraIssue);
1821 }
1822 if let Some(available) = available_issues {
1823 candidates
1824 .into_iter()
1825 .find(|candidate| {
1826 available
1827 .iter()
1828 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
1829 })
1830 .ok_or(DataCatalogError::NoAvailableUltraIssue)
1831 } else {
1832 Ok(candidates[0].clone())
1833 }
1834}
1835
1836pub fn gim_date_candidates(
1838 center: AnalysisCenter,
1839 target: ProductDate,
1840 lookback: u32,
1841) -> Result<Vec<ProductDate>, DataCatalogError> {
1842 let _ = product_convention(center, ProductType::Ionex)?;
1843 let base = target.add_days(predicted_day_offset(center))?;
1844 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
1845 for back in 0..=lookback {
1846 out.push(base.add_days(-i64::from(back))?);
1847 }
1848 Ok(out)
1849}
1850
1851pub fn station_obs(
1853 station: &str,
1854 date: ProductDate,
1855 sample: Option<&str>,
1856) -> Result<StationObservationSpec, DataCatalogError> {
1857 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
1858}
1859
1860pub fn station_obs_filename(
1862 station: &str,
1863 date: ProductDate,
1864 sample: &str,
1865) -> Result<String, DataCatalogError> {
1866 validate_station(station)?;
1867 validate_sample(sample)?;
1868 Ok(format!(
1869 "{}_R_{}_01D_{}_MO.crx",
1870 station,
1871 date_block(date, None),
1872 sample
1873 ))
1874}
1875
1876pub fn station_obs_url(
1878 station: &str,
1879 date: ProductDate,
1880 sample: &str,
1881) -> Result<String, DataCatalogError> {
1882 let filename = station_obs_filename(station, date, sample)?;
1883 Ok(format!(
1884 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
1885 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
1886 filename
1887 ))
1888}
1889
1890#[must_use]
1892pub const fn station_obs_protocol() -> ArchiveProtocol {
1893 ArchiveProtocol::Https
1894}
1895
1896fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
1897 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
1898 Ok(())
1899 } else {
1900 Err(DataCatalogError::InvalidTileIndex {
1901 lat_index,
1902 lon_index: 0,
1903 })
1904 }
1905}
1906
1907fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
1908 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1909 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1910 {
1911 Ok(())
1912 } else {
1913 Err(DataCatalogError::InvalidTileIndex {
1914 lat_index,
1915 lon_index,
1916 })
1917 }
1918}
1919
1920fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
1921 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1922 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1923 {
1924 Ok(())
1925 } else {
1926 Err(HgtConversionError::InvalidTileIndex {
1927 lat_index,
1928 lon_index,
1929 })
1930 }
1931}
1932
1933fn dted_coord_field(index: i32, is_longitude: bool) -> String {
1934 let hemi = match (is_longitude, index >= 0) {
1935 (true, true) => 'E',
1936 (true, false) => 'W',
1937 (false, true) => 'N',
1938 (false, false) => 'S',
1939 };
1940 format!("{:03}0000{hemi}", index.abs())
1941}
1942
1943fn encode_dted_signed_magnitude(sample: i16) -> u16 {
1944 if sample == i16::MIN {
1945 0
1946 } else if sample >= 0 {
1947 sample as u16
1948 } else {
1949 0x8000 | (-i32::from(sample) as u16)
1950 }
1951}
1952
1953fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
1954 PRODUCT_TYPE_CONVENTIONS
1955 .iter()
1956 .find(|descriptor| descriptor.product_type == product_type)
1957 .expect("product descriptor exists for enum variant")
1958}
1959
1960fn validate_product(
1961 center: AnalysisCenter,
1962 product_type: ProductType,
1963 sample: &str,
1964 issue: Option<&str>,
1965) -> Result<&'static CenterProductConvention, DataCatalogError> {
1966 let convention = product_convention(center, product_type)?;
1967 validate_sample(sample)?;
1968 validate_issue_for_center(center, issue)?;
1969 Ok(convention)
1970}
1971
1972fn validate_issue_for_center(
1973 center: AnalysisCenter,
1974 issue: Option<&str>,
1975) -> Result<(), DataCatalogError> {
1976 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1977 match (entry.issues.is_empty(), issue) {
1978 (true, None) => Ok(()),
1979 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
1980 (false, None) => Err(DataCatalogError::MissingIssue { center }),
1981 (false, Some(issue)) => {
1982 validate_issue(issue)?;
1983 if entry.issues.contains(&issue) {
1984 Ok(())
1985 } else {
1986 Err(DataCatalogError::UnsupportedIssue {
1987 center,
1988 issue: issue.to_string(),
1989 })
1990 }
1991 }
1992 }
1993}
1994
1995fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
1996 let bytes = sample.as_bytes();
1997 let valid = bytes.len() == 3
1998 && bytes[0].is_ascii_digit()
1999 && bytes[1].is_ascii_digit()
2000 && bytes[2].is_ascii_uppercase();
2001 if valid {
2002 Ok(())
2003 } else {
2004 Err(DataCatalogError::InvalidSample(sample.to_string()))
2005 }
2006}
2007
2008fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2009 let bytes = issue.as_bytes();
2010 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2011 if !valid_digits {
2012 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2013 }
2014 let hour = issue[0..2]
2015 .parse::<u8>()
2016 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2017 let minute = issue[2..4]
2018 .parse::<u8>()
2019 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2020 if hour <= 23 && minute <= 59 {
2021 Ok(())
2022 } else {
2023 Err(DataCatalogError::InvalidIssue(issue.to_string()))
2024 }
2025}
2026
2027fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2028 let bytes = station.as_bytes();
2029 let valid = bytes.len() == 9
2030 && bytes
2031 .iter()
2032 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2033 if valid {
2034 Ok(())
2035 } else {
2036 Err(DataCatalogError::InvalidStation(station.to_string()))
2037 }
2038}
2039
2040fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2041 validate_issue(issue)?;
2042 let hour = issue[0..2]
2043 .parse::<u16>()
2044 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2045 let minute = issue[2..4]
2046 .parse::<u16>()
2047 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2048 Ok(hour * 60 + minute)
2049}
2050
2051fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2052 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2053}
2054
2055fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2056 format!(
2057 "{}{:03}{}",
2058 date.year,
2059 date.day_of_year(),
2060 issue.unwrap_or("0000")
2061 )
2062}
2063
2064fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2065 Ok(match layout {
2066 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2067 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2068 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2069 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2070 ArchiveLayout::BkgBrdcYearDoy => {
2071 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2072 }
2073 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2074 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2075 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2076 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2077 })
2078}
2079
2080fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2081 let (year, month, day) = civil_from_julian_day_number(jdn);
2082 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2083 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2084 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2085 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2086}