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::Http,
616 host: "ftp.aiub.unibe.ch",
617 root_url: "http://ftp.aiub.unibe.ch",
618 products: &COD_ULT_PRODUCTS,
619 issues: &COD_ULT_ISSUES,
620 },
621 CenterCatalogEntry {
622 center: AnalysisCenter::EsaUlt,
623 code: "esa_ult",
624 protocol: ArchiveProtocol::Https,
625 host: "navigation-office.esa.int",
626 root_url: "https://navigation-office.esa.int/products/gnss-products",
627 products: &ESA_ULT_PRODUCTS,
628 issues: &OPSULT_ISSUES,
629 },
630 CenterCatalogEntry {
631 center: AnalysisCenter::GfzUlt,
632 code: "gfz_ult",
633 protocol: ArchiveProtocol::Https,
634 host: "isdc-data.gfz.de",
635 root_url: "https://isdc-data.gfz.de/gnss/products",
636 products: &GFZ_ULT_PRODUCTS,
637 issues: &GFZ_ULT_ISSUES,
638 },
639];
640
641const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
642 protocol: ArchiveProtocol::Https,
643 host: "s3.amazonaws.com",
644 compression: ArchiveCompression::Gzip,
645 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
646};
647
648const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
649 protocol: ArchiveProtocol::Https,
650 host: "celestrak.org",
651 compression: ArchiveCompression::None,
652 root_url: "https://celestrak.org/SpaceData",
653};
654
655const ALLOWED_HOSTS: [&str; 6] = [
656 "ftp.aiub.unibe.ch",
657 "navigation-office.esa.int",
658 "isdc-data.gfz.de",
659 "igs.bkg.bund.de",
660 "s3.amazonaws.com",
661 "celestrak.org",
662];
663
664const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
665 NoOpenMirrorProduct {
666 center: "grg",
667 product_type: "sp3",
668 },
669 NoOpenMirrorProduct {
670 center: "grg",
671 product_type: "clk",
672 },
673 NoOpenMirrorProduct {
674 center: "wum",
675 product_type: "sp3",
676 },
677 NoOpenMirrorProduct {
678 center: "wum",
679 product_type: "clk",
680 },
681 NoOpenMirrorProduct {
682 center: "grg_ult",
683 product_type: "sp3",
684 },
685 NoOpenMirrorProduct {
686 center: "grg_ult",
687 product_type: "clk",
688 },
689 NoOpenMirrorProduct {
690 center: "igs",
691 product_type: "ionex",
692 },
693];
694
695#[derive(Debug, Clone, PartialEq, Eq)]
697pub enum DataCatalogError {
698 UnknownCenter(String),
700 UnknownProductType(String),
702 UnsupportedProduct {
704 center: AnalysisCenter,
706 product_type: ProductType,
708 },
709 NoOpenMirror {
711 center: String,
713 product_type: String,
715 },
716 InvalidDate {
718 year: i32,
720 month: u8,
722 day: u8,
724 },
725 DateOutOfRange,
727 DateBeforeGpsEpoch(ProductDate),
729 InvalidGpsDayOfWeek(u8),
731 InvalidSample(String),
733 InvalidIssue(String),
735 MissingIssue {
737 center: AnalysisCenter,
739 },
740 UnexpectedIssue {
742 center: AnalysisCenter,
744 },
745 UnsupportedIssue {
747 center: AnalysisCenter,
749 issue: String,
751 },
752 InvalidDateTime {
754 hour: u8,
756 minute: u8,
758 second: u8,
760 },
761 NoUltraIssue,
763 NoAvailableUltraIssue,
765 InvalidStation(String),
767 InvalidCoordinate {
769 lat_deg_bits: u64,
771 lon_deg_bits: u64,
773 },
774 InvalidTileIndex {
776 lat_index: i32,
778 lon_index: i32,
780 },
781 InvalidTileId(String),
783}
784
785impl fmt::Display for DataCatalogError {
786 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
787 match self {
788 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
789 Self::UnknownProductType(product_type) => {
790 write!(f, "unknown product type {product_type:?}")
791 }
792 Self::UnsupportedProduct {
793 center,
794 product_type,
795 } => write!(f, "{center} does not serve {product_type}"),
796 Self::NoOpenMirror {
797 center,
798 product_type,
799 } => write!(f, "{center}/{product_type} has no open mirror"),
800 Self::InvalidDate { year, month, day } => {
801 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
802 }
803 Self::DateOutOfRange => write!(f, "product date is out of range"),
804 Self::DateBeforeGpsEpoch(date) => {
805 write!(f, "product date {date} is before the GPS week epoch")
806 }
807 Self::InvalidGpsDayOfWeek(day) => {
808 write!(f, "invalid GPS day-of-week {day}")
809 }
810 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
811 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
812 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
813 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
814 Self::UnsupportedIssue { center, issue } => {
815 write!(f, "{center} does not publish issue {issue:?}")
816 }
817 Self::InvalidDateTime {
818 hour,
819 minute,
820 second,
821 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
822 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
823 Self::NoAvailableUltraIssue => {
824 write!(f, "no available ultra-rapid issue at or before target")
825 }
826 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
827 Self::InvalidCoordinate {
828 lat_deg_bits,
829 lon_deg_bits,
830 } => write!(
831 f,
832 "invalid terrain coordinate lat={} lon={}",
833 f64::from_bits(*lat_deg_bits),
834 f64::from_bits(*lon_deg_bits)
835 ),
836 Self::InvalidTileIndex {
837 lat_index,
838 lon_index,
839 } => write!(
840 f,
841 "invalid terrain tile index lat={lat_index} lon={lon_index}"
842 ),
843 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
844 }
845 }
846}
847
848impl std::error::Error for DataCatalogError {}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
852pub enum HgtConversionError {
853 BadLength {
855 expected: usize,
857 got: usize,
859 },
860 InvalidTileIndex {
862 lat_index: i32,
864 lon_index: i32,
866 },
867}
868
869impl fmt::Display for HgtConversionError {
870 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
871 match self {
872 Self::BadLength { expected, got } => {
873 write!(
874 f,
875 "invalid SRTM1 HGT length: expected {expected}, got {got}"
876 )
877 }
878 Self::InvalidTileIndex {
879 lat_index,
880 lon_index,
881 } => write!(
882 f,
883 "invalid terrain tile index lat={lat_index} lon={lon_index}"
884 ),
885 }
886 }
887}
888
889impl std::error::Error for HgtConversionError {}
890
891const MIN_TERRAIN_LAT_INDEX: i32 = -90;
892const MAX_TERRAIN_LAT_INDEX: i32 = 89;
893const MIN_TERRAIN_LON_INDEX: i32 = -180;
894const MAX_TERRAIN_LON_INDEX: i32 = 179;
895const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
896const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
897const MIN_TERRAIN_LON_DEG: f64 = -180.0;
898const MAX_TERRAIN_LON_DEG: f64 = 180.0;
899const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
900const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
901const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
902const DTED_SRTM1_LEN: usize =
903 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
904
905#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
907pub struct ProductDate {
908 pub year: i32,
910 pub month: u8,
912 pub day: u8,
914}
915
916impl ProductDate {
917 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
919 let days = days_in_month(i64::from(year), i64::from(month));
920 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
921 return Err(DataCatalogError::InvalidDate { year, month, day });
922 }
923 Ok(Self { year, month, day })
924 }
925
926 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
928 if day_of_week > 6 {
929 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
930 }
931 let epoch_jdn =
932 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
933 let offset_days = i64::from(week)
934 .checked_mul(7)
935 .and_then(|days| days.checked_add(i64::from(day_of_week)))
936 .ok_or(DataCatalogError::DateOutOfRange)?;
937 product_date_from_jdn(
938 epoch_jdn
939 .checked_add(offset_days)
940 .ok_or(DataCatalogError::DateOutOfRange)?,
941 )
942 }
943
944 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
946 week_from_calendar(
947 TimeScale::Gpst,
948 i64::from(self.year),
949 i64::from(self.month),
950 i64::from(self.day),
951 )
952 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
953 }
954
955 #[must_use]
957 pub fn day_of_year(self) -> u16 {
958 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
959 }
960
961 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
962 product_date_from_jdn(
963 self.julian_day_number()
964 .checked_add(days)
965 .ok_or(DataCatalogError::DateOutOfRange)?,
966 )
967 }
968
969 fn julian_day_number(self) -> i64 {
970 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
971 }
972}
973
974impl fmt::Display for ProductDate {
975 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
976 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
977 }
978}
979
980#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
982pub struct ProductDateTime {
983 pub date: ProductDate,
985 pub hour: u8,
987 pub minute: u8,
989 pub second: u8,
991}
992
993impl ProductDateTime {
994 pub fn new(
996 date: ProductDate,
997 hour: u8,
998 minute: u8,
999 second: u8,
1000 ) -> Result<Self, DataCatalogError> {
1001 if hour > 23 || minute > 59 || second > 59 {
1002 return Err(DataCatalogError::InvalidDateTime {
1003 hour,
1004 minute,
1005 second,
1006 });
1007 }
1008 Ok(Self {
1009 date,
1010 hour,
1011 minute,
1012 second,
1013 })
1014 }
1015
1016 fn ordering_minutes(self) -> i64 {
1017 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1018 }
1019}
1020
1021#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1023pub struct UltraIssue {
1024 pub date: ProductDate,
1026 pub issue: String,
1028}
1029
1030impl UltraIssue {
1031 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1033 validate_issue(issue)?;
1034 Ok(Self {
1035 date,
1036 issue: issue.to_string(),
1037 })
1038 }
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct UltraSp3Location {
1044 pub pattern: String,
1046 pub span: String,
1048 pub sample: String,
1050 pub filename: String,
1052 pub url: String,
1054 pub compression: ArchiveCompression,
1056}
1057
1058#[derive(Debug, Clone, Copy)]
1059struct UltraSp3Pattern {
1060 label: &'static str,
1061 span: &'static str,
1062 sample: &'static str,
1063 alias_filename: Option<&'static str>,
1064}
1065
1066const IGS_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1067 UltraSp3Pattern {
1068 label: "primary_02D_15M",
1069 span: "02D",
1070 sample: "15M",
1071 alias_filename: None,
1072 },
1073 UltraSp3Pattern {
1074 label: "alternate_02D_05M",
1075 span: "02D",
1076 sample: "05M",
1077 alias_filename: None,
1078 },
1079 UltraSp3Pattern {
1080 label: "alternate_01D_15M",
1081 span: "01D",
1082 sample: "15M",
1083 alias_filename: None,
1084 },
1085];
1086
1087const COD_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1088 UltraSp3Pattern {
1089 label: "primary_01D_05M",
1090 span: "01D",
1091 sample: "05M",
1092 alias_filename: None,
1093 },
1094 UltraSp3Pattern {
1095 label: "alternate_02D_05M",
1096 span: "02D",
1097 sample: "05M",
1098 alias_filename: None,
1099 },
1100 UltraSp3Pattern {
1101 label: "alias_latest",
1102 span: "01D",
1103 sample: "05M",
1104 alias_filename: Some("COD0OPSULT.SP3"),
1105 },
1106];
1107
1108const ESA_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1109 UltraSp3Pattern {
1110 label: "primary_02D_05M",
1111 span: "02D",
1112 sample: "05M",
1113 alias_filename: None,
1114 },
1115 UltraSp3Pattern {
1116 label: "alternate_02D_15M",
1117 span: "02D",
1118 sample: "15M",
1119 alias_filename: None,
1120 },
1121 UltraSp3Pattern {
1122 label: "alternate_01D_05M",
1123 span: "01D",
1124 sample: "05M",
1125 alias_filename: None,
1126 },
1127];
1128
1129const GFZ_ULT_SP3_PATTERNS: [UltraSp3Pattern; 3] = [
1130 UltraSp3Pattern {
1131 label: "primary_02D_05M",
1132 span: "02D",
1133 sample: "05M",
1134 alias_filename: None,
1135 },
1136 UltraSp3Pattern {
1137 label: "alternate_02D_15M",
1138 span: "02D",
1139 sample: "15M",
1140 alias_filename: None,
1141 },
1142 UltraSp3Pattern {
1143 label: "alternate_01D_05M",
1144 span: "01D",
1145 sample: "05M",
1146 alias_filename: None,
1147 },
1148];
1149
1150#[derive(Debug, Clone, PartialEq, Eq)]
1152pub struct ProductSpec {
1153 pub center: AnalysisCenter,
1155 pub product_type: ProductType,
1157 pub date: ProductDate,
1159 pub sample: String,
1161 pub issue: Option<String>,
1163}
1164
1165impl ProductSpec {
1166 pub fn new(
1168 center: AnalysisCenter,
1169 product_type: ProductType,
1170 date: ProductDate,
1171 sample: &str,
1172 issue: Option<&str>,
1173 ) -> Result<Self, DataCatalogError> {
1174 validate_product(center, product_type, sample, issue)?;
1175 Ok(Self {
1176 center,
1177 product_type,
1178 date,
1179 sample: sample.to_string(),
1180 issue: issue.map(ToOwned::to_owned),
1181 })
1182 }
1183
1184 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1186 self.date.gps_week()
1187 }
1188
1189 #[must_use]
1191 pub fn day_of_year(&self) -> u16 {
1192 self.date.day_of_year()
1193 }
1194
1195 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1197 let convention = validate_product(
1198 self.center,
1199 self.product_type,
1200 &self.sample,
1201 self.issue.as_deref(),
1202 )?;
1203 let descriptor = product_type_convention(self.product_type);
1204 Ok(match descriptor.kind {
1205 ProductFilenameKind::Sampled => format!(
1206 "{}_{}_{}_{}_{}.{}",
1207 convention.token,
1208 date_block(self.date, self.issue.as_deref()),
1209 convention.span,
1210 self.sample,
1211 descriptor.content_code,
1212 descriptor.extension
1213 ),
1214 ProductFilenameKind::Nav => format!(
1215 "{}_R_{}_{}_{}.{}",
1216 convention.token,
1217 date_block(self.date, None),
1218 convention.span,
1219 descriptor.content_code,
1220 descriptor.extension
1221 ),
1222 })
1223 }
1224
1225 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1227 let convention = validate_product(
1228 self.center,
1229 self.product_type,
1230 &self.sample,
1231 self.issue.as_deref(),
1232 )?;
1233 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1234 let filename = self.canonical_filename()?;
1235 Ok(format!(
1236 "{}/{}/{}{}",
1237 entry.root_url,
1238 dir_path(convention.layout, self.date)?,
1239 filename,
1240 convention.compression.suffix()
1241 ))
1242 }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1247pub struct StationObservationSpec {
1248 pub station: String,
1250 pub date: ProductDate,
1252 pub sample: String,
1254}
1255
1256impl StationObservationSpec {
1257 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1259 validate_station(station)?;
1260 validate_sample(sample)?;
1261 Ok(Self {
1262 station: station.to_string(),
1263 date,
1264 sample: sample.to_string(),
1265 })
1266 }
1267
1268 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1270 station_obs_filename(&self.station, self.date, &self.sample)
1271 }
1272
1273 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1275 station_obs_url(&self.station, self.date, &self.sample)
1276 }
1277}
1278
1279#[must_use]
1281pub const fn catalog() -> &'static [CenterCatalogEntry] {
1282 &CATALOG
1283}
1284
1285#[must_use]
1287pub const fn centers() -> &'static [AnalysisCenter] {
1288 &CENTER_ORDER
1289}
1290
1291#[must_use]
1293pub const fn product_types() -> &'static [ProductTypeConvention] {
1294 &PRODUCT_TYPE_CONVENTIONS
1295}
1296
1297#[must_use]
1299pub const fn allowed_hosts() -> &'static [&'static str] {
1300 &ALLOWED_HOSTS
1301}
1302
1303#[must_use]
1305pub const fn skadi_source_entry() -> TerrainSourceEntry {
1306 SKADI_SOURCE
1307}
1308
1309#[must_use]
1311pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1312 CELESTRAK_SPACE_WEATHER_SOURCE
1313}
1314
1315#[must_use]
1317pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1318 match product {
1319 SpaceWeatherProduct::All => "SW-All.csv",
1320 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1321 }
1322}
1323
1324#[must_use]
1326pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1327 format!(
1328 "{}/{}",
1329 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1330 space_weather_filename(product)
1331 )
1332}
1333
1334#[must_use]
1336pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1337 format!("space-weather/{}", space_weather_filename(product))
1338}
1339
1340pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1342 validate_terrain_tile_index(lat_index, lon_index)?;
1343 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1344 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1345 Ok(format!(
1346 "{lat_hemi}{:02}{lon_hemi}{:03}",
1347 lat_index.abs(),
1348 lon_index.abs()
1349 ))
1350}
1351
1352pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1354 validate_terrain_lat_index(lat_index)?;
1355 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1356 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1357}
1358
1359pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1361 let band = skadi_band(lat_index)?;
1362 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1363 Ok(format!(
1364 "{}/skadi/{}/{}.hgt{}",
1365 SKADI_SOURCE.root_url,
1366 band,
1367 tile_id,
1368 SKADI_SOURCE.compression.suffix()
1369 ))
1370}
1371
1372pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1374 validate_terrain_tile_index(lat_index, lon_index)?;
1375 Ok(format!(
1376 "{}_{}{}",
1377 terrain::format_lat(lat_index),
1378 terrain::format_lon(lon_index),
1379 terrain::DTED_SUFFIX
1380 ))
1381}
1382
1383pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1385 validate_terrain_tile_index(lat_index, lon_index)?;
1386 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1387}
1388
1389pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1391 Ok(format!(
1392 "{}/{}",
1393 dted_block_dir(lat_index, lon_index)?,
1394 dted_tile_filename(lat_index, lon_index)?
1395 ))
1396}
1397
1398pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1400 let bytes = id.as_bytes();
1401 if bytes.len() != 7
1402 || !matches!(bytes[0], b'N' | b'S')
1403 || !matches!(bytes[3], b'E' | b'W')
1404 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1405 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1406 {
1407 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1408 }
1409
1410 let lat_abs = id[1..3]
1411 .parse::<i32>()
1412 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1413 let lon_abs = id[4..7]
1414 .parse::<i32>()
1415 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1416 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1417 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1418 }
1419
1420 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1421 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1422 validate_terrain_tile_index(lat_index, lon_index)?;
1423 Ok((lat_index, lon_index))
1424}
1425
1426pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1428 if !lat_deg.is_finite()
1429 || !lon_deg.is_finite()
1430 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1431 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1432 {
1433 return Err(DataCatalogError::InvalidCoordinate {
1434 lat_deg_bits: lat_deg.to_bits(),
1435 lon_deg_bits: lon_deg.to_bits(),
1436 });
1437 }
1438
1439 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1440 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1441 lat_index = MAX_TERRAIN_LAT_INDEX;
1442 }
1443 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1444 lon_index = MAX_TERRAIN_LON_INDEX;
1445 }
1446 validate_terrain_tile_index(lat_index, lon_index)?;
1447 Ok((lat_index, lon_index))
1448}
1449
1450pub fn hgt_to_dted(
1458 lat_index: i32,
1459 lon_index: i32,
1460 hgt: &[u8],
1461) -> Result<Vec<u8>, HgtConversionError> {
1462 validate_hgt_tile_index(lat_index, lon_index)?;
1463 if hgt.len() != SRTM1_HGT_LEN {
1464 return Err(HgtConversionError::BadLength {
1465 expected: SRTM1_HGT_LEN,
1466 got: hgt.len(),
1467 });
1468 }
1469
1470 let mut out = vec![b' '; DTED_SRTM1_LEN];
1471 out[0..4].copy_from_slice(b"UHL1");
1472 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1473 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1474 out[47..51].copy_from_slice(b"3601");
1475 out[51..55].copy_from_slice(b"3601");
1476
1477 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1478 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1479 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1480 out[block_start] = terrain::DATA_SENTINEL;
1481
1482 let count = (lon_posting as u32).to_be_bytes();
1483 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1484 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1485 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1486
1487 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1488 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1489 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1490 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1491 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1492 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1493 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1494 }
1495
1496 let checksum = out[block_start..checksum_start]
1497 .iter()
1498 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1499 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1500 }
1501
1502 debug_assert_eq!(out.len(), 25_981_042);
1503 Ok(out)
1504}
1505
1506#[must_use]
1508pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1509 &NO_OPEN_MIRRORS
1510}
1511
1512pub fn open_mirror(
1514 center: AnalysisCenter,
1515 product_type: ProductType,
1516) -> Result<(), DataCatalogError> {
1517 open_mirror_code(center.code(), product_type.code())
1518}
1519
1520pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
1522 if NO_OPEN_MIRRORS
1523 .iter()
1524 .any(|entry| entry.center == center && entry.product_type == product_type)
1525 {
1526 Err(DataCatalogError::NoOpenMirror {
1527 center: center.to_string(),
1528 product_type: product_type.to_string(),
1529 })
1530 } else {
1531 Ok(())
1532 }
1533}
1534
1535#[must_use]
1537pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1538 CATALOG.iter().find(|entry| entry.center == center)
1539}
1540
1541pub fn product_convention(
1543 center: AnalysisCenter,
1544 product_type: ProductType,
1545) -> Result<&'static CenterProductConvention, DataCatalogError> {
1546 open_mirror(center, product_type)?;
1547 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1548 entry
1549 .products
1550 .iter()
1551 .find(|product| product.product_type == product_type)
1552 .ok_or(DataCatalogError::UnsupportedProduct {
1553 center,
1554 product_type,
1555 })
1556}
1557
1558pub fn default_sample(
1560 center: AnalysisCenter,
1561 product_type: ProductType,
1562) -> Result<&'static str, DataCatalogError> {
1563 Ok(product_convention(center, product_type)?.default_sample)
1564}
1565
1566pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1568 date.gps_week()
1569}
1570
1571#[must_use]
1573pub fn day_of_year(date: ProductDate) -> u16 {
1574 date.day_of_year()
1575}
1576
1577pub fn product(
1579 center: AnalysisCenter,
1580 product_type: ProductType,
1581 date: ProductDate,
1582 sample: Option<&str>,
1583 issue: Option<&str>,
1584) -> Result<ProductSpec, DataCatalogError> {
1585 let sample = match sample {
1586 Some(sample) => sample,
1587 None => default_sample(center, product_type)?,
1588 };
1589 ProductSpec::new(center, product_type, date, sample, issue)
1590}
1591
1592pub fn canonical_filename(
1594 center: AnalysisCenter,
1595 product_type: ProductType,
1596 date: ProductDate,
1597 sample: Option<&str>,
1598 issue: Option<&str>,
1599) -> Result<String, DataCatalogError> {
1600 product(center, product_type, date, sample, issue)?.canonical_filename()
1601}
1602
1603pub fn archive_url(
1605 center: AnalysisCenter,
1606 product_type: ProductType,
1607 date: ProductDate,
1608 sample: Option<&str>,
1609 issue: Option<&str>,
1610) -> Result<String, DataCatalogError> {
1611 product(center, product_type, date, sample, issue)?.archive_url()
1612}
1613
1614pub fn mgex_clk(
1616 center: AnalysisCenter,
1617 date: ProductDate,
1618 sample: Option<&str>,
1619) -> Result<ProductSpec, DataCatalogError> {
1620 product(center, ProductType::Clk, date, sample, None)
1621}
1622
1623pub fn mgex_nav(
1625 center: AnalysisCenter,
1626 date: ProductDate,
1627 sample: Option<&str>,
1628) -> Result<ProductSpec, DataCatalogError> {
1629 product(center, ProductType::Nav, date, sample, None)
1630}
1631
1632pub fn mgex_ionex(
1634 center: AnalysisCenter,
1635 date: ProductDate,
1636 sample: Option<&str>,
1637) -> Result<ProductSpec, DataCatalogError> {
1638 product(center, ProductType::Ionex, date, sample, None)
1639}
1640
1641pub fn rapid_ionex(
1643 date: ProductDate,
1644 sample: Option<&str>,
1645) -> Result<ProductSpec, DataCatalogError> {
1646 product(
1647 AnalysisCenter::CodRap,
1648 ProductType::Ionex,
1649 date,
1650 sample,
1651 None,
1652 )
1653}
1654
1655#[must_use]
1657pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1658 match center {
1659 AnalysisCenter::CodPrd2 => 1,
1660 _ => 0,
1661 }
1662}
1663
1664pub fn predicted_ionex(
1666 center: AnalysisCenter,
1667 date: ProductDate,
1668 sample: Option<&str>,
1669) -> Result<ProductSpec, DataCatalogError> {
1670 match center {
1671 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
1672 let target = date.add_days(predicted_day_offset(center))?;
1673 product(center, ProductType::Ionex, target, sample, None)
1674 }
1675 other => Err(DataCatalogError::UnsupportedProduct {
1676 center: other,
1677 product_type: ProductType::Ionex,
1678 }),
1679 }
1680}
1681
1682pub fn mgex_sp3(
1684 center: AnalysisCenter,
1685 date: ProductDate,
1686 sample: Option<&str>,
1687) -> Result<ProductSpec, DataCatalogError> {
1688 product(center, ProductType::Sp3, date, sample, None)
1689}
1690
1691pub fn ops_ultra_sp3(
1693 center: AnalysisCenter,
1694 date: ProductDate,
1695 sample: Option<&str>,
1696 issue: Option<&str>,
1697) -> Result<ProductSpec, DataCatalogError> {
1698 let issue = issue.unwrap_or("0000");
1699 product(center, ProductType::Sp3, date, sample, Some(issue))
1700}
1701
1702pub fn ultra_sp3_locations(
1709 center: AnalysisCenter,
1710 date: ProductDate,
1711 issue: &str,
1712) -> Result<Vec<UltraSp3Location>, DataCatalogError> {
1713 validate_issue_for_center(center, Some(issue))?;
1714 let patterns: &[UltraSp3Pattern] = match center {
1715 AnalysisCenter::IgsUlt => &IGS_ULT_SP3_PATTERNS,
1716 AnalysisCenter::CodUlt => &COD_ULT_SP3_PATTERNS,
1717 AnalysisCenter::EsaUlt => &ESA_ULT_SP3_PATTERNS,
1718 AnalysisCenter::GfzUlt => &GFZ_ULT_SP3_PATTERNS,
1719 other => {
1720 return Err(DataCatalogError::UnsupportedProduct {
1721 center: other,
1722 product_type: ProductType::Sp3,
1723 })
1724 }
1725 };
1726 let convention = product_convention(center, ProductType::Sp3)?;
1727 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1728 let directory = dir_path(convention.layout, date)?;
1729 let date = date_block(date, Some(issue));
1730
1731 Ok(patterns
1732 .iter()
1733 .map(|pattern| {
1734 let filename = pattern.alias_filename.map_or_else(
1735 || {
1736 format!(
1737 "{}_{}_{}_{}_ORB.SP3",
1738 convention.token, date, pattern.span, pattern.sample
1739 )
1740 },
1741 ToOwned::to_owned,
1742 );
1743 UltraSp3Location {
1744 pattern: pattern.label.to_string(),
1745 span: pattern.span.to_string(),
1746 sample: pattern.sample.to_string(),
1747 url: format!(
1748 "{}/{}/{}{}",
1749 entry.root_url,
1750 directory,
1751 filename,
1752 convention.compression.suffix()
1753 ),
1754 filename,
1755 compression: convention.compression,
1756 }
1757 })
1758 .collect())
1759}
1760
1761pub fn ops_ultra_clk(
1763 center: AnalysisCenter,
1764 date: ProductDate,
1765 sample: Option<&str>,
1766 issue: Option<&str>,
1767) -> Result<ProductSpec, DataCatalogError> {
1768 let issue = issue.unwrap_or("0000");
1769 product(center, ProductType::Clk, date, sample, Some(issue))
1770}
1771
1772pub fn latest_ops_ultra_sp3(
1774 center: AnalysisCenter,
1775 target: ProductDateTime,
1776 sample: Option<&str>,
1777 available_issues: Option<&[UltraIssue]>,
1778) -> Result<ProductSpec, DataCatalogError> {
1779 let selected = latest_ultra_issue(center, target, available_issues)?;
1780 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
1781}
1782
1783pub fn ultra_issue_candidates(
1785 center: AnalysisCenter,
1786 target: ProductDateTime,
1787) -> Result<Vec<UltraIssue>, DataCatalogError> {
1788 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1789 let _ = product_convention(center, ProductType::Sp3)?;
1790 if entry.issues.is_empty() {
1791 return Err(DataCatalogError::UnsupportedProduct {
1792 center,
1793 product_type: ProductType::Sp3,
1794 });
1795 }
1796
1797 let mut candidates = Vec::new();
1798 for date in [target.date, target.date.add_days(-1)?] {
1799 for issue in entry.issues.iter().rev() {
1800 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
1801 candidates.push(UltraIssue::new(date, issue)?);
1802 }
1803 }
1804 }
1805 Ok(candidates)
1806}
1807
1808pub fn latest_ultra_issue(
1810 center: AnalysisCenter,
1811 target: ProductDateTime,
1812 available_issues: Option<&[UltraIssue]>,
1813) -> Result<UltraIssue, DataCatalogError> {
1814 let candidates = ultra_issue_candidates(center, target)?;
1815 if candidates.is_empty() {
1816 return Err(DataCatalogError::NoUltraIssue);
1817 }
1818 if let Some(available) = available_issues {
1819 candidates
1820 .into_iter()
1821 .find(|candidate| {
1822 available
1823 .iter()
1824 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
1825 })
1826 .ok_or(DataCatalogError::NoAvailableUltraIssue)
1827 } else {
1828 Ok(candidates[0].clone())
1829 }
1830}
1831
1832pub fn gim_date_candidates(
1834 center: AnalysisCenter,
1835 target: ProductDate,
1836 lookback: u32,
1837) -> Result<Vec<ProductDate>, DataCatalogError> {
1838 let _ = product_convention(center, ProductType::Ionex)?;
1839 let base = target.add_days(predicted_day_offset(center))?;
1840 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
1841 for back in 0..=lookback {
1842 out.push(base.add_days(-i64::from(back))?);
1843 }
1844 Ok(out)
1845}
1846
1847pub fn station_obs(
1849 station: &str,
1850 date: ProductDate,
1851 sample: Option<&str>,
1852) -> Result<StationObservationSpec, DataCatalogError> {
1853 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
1854}
1855
1856pub fn station_obs_filename(
1858 station: &str,
1859 date: ProductDate,
1860 sample: &str,
1861) -> Result<String, DataCatalogError> {
1862 validate_station(station)?;
1863 validate_sample(sample)?;
1864 Ok(format!(
1865 "{}_R_{}_01D_{}_MO.crx",
1866 station,
1867 date_block(date, None),
1868 sample
1869 ))
1870}
1871
1872pub fn station_obs_url(
1874 station: &str,
1875 date: ProductDate,
1876 sample: &str,
1877) -> Result<String, DataCatalogError> {
1878 let filename = station_obs_filename(station, date, sample)?;
1879 Ok(format!(
1880 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
1881 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
1882 filename
1883 ))
1884}
1885
1886#[must_use]
1888pub const fn station_obs_protocol() -> ArchiveProtocol {
1889 ArchiveProtocol::Https
1890}
1891
1892fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
1893 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
1894 Ok(())
1895 } else {
1896 Err(DataCatalogError::InvalidTileIndex {
1897 lat_index,
1898 lon_index: 0,
1899 })
1900 }
1901}
1902
1903fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
1904 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1905 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1906 {
1907 Ok(())
1908 } else {
1909 Err(DataCatalogError::InvalidTileIndex {
1910 lat_index,
1911 lon_index,
1912 })
1913 }
1914}
1915
1916fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
1917 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1918 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1919 {
1920 Ok(())
1921 } else {
1922 Err(HgtConversionError::InvalidTileIndex {
1923 lat_index,
1924 lon_index,
1925 })
1926 }
1927}
1928
1929fn dted_coord_field(index: i32, is_longitude: bool) -> String {
1930 let hemi = match (is_longitude, index >= 0) {
1931 (true, true) => 'E',
1932 (true, false) => 'W',
1933 (false, true) => 'N',
1934 (false, false) => 'S',
1935 };
1936 format!("{:03}0000{hemi}", index.abs())
1937}
1938
1939fn encode_dted_signed_magnitude(sample: i16) -> u16 {
1940 if sample == i16::MIN {
1941 0
1942 } else if sample >= 0 {
1943 sample as u16
1944 } else {
1945 0x8000 | (-i32::from(sample) as u16)
1946 }
1947}
1948
1949fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
1950 PRODUCT_TYPE_CONVENTIONS
1951 .iter()
1952 .find(|descriptor| descriptor.product_type == product_type)
1953 .expect("product descriptor exists for enum variant")
1954}
1955
1956fn validate_product(
1957 center: AnalysisCenter,
1958 product_type: ProductType,
1959 sample: &str,
1960 issue: Option<&str>,
1961) -> Result<&'static CenterProductConvention, DataCatalogError> {
1962 let convention = product_convention(center, product_type)?;
1963 validate_sample(sample)?;
1964 validate_issue_for_center(center, issue)?;
1965 Ok(convention)
1966}
1967
1968fn validate_issue_for_center(
1969 center: AnalysisCenter,
1970 issue: Option<&str>,
1971) -> Result<(), DataCatalogError> {
1972 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1973 match (entry.issues.is_empty(), issue) {
1974 (true, None) => Ok(()),
1975 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
1976 (false, None) => Err(DataCatalogError::MissingIssue { center }),
1977 (false, Some(issue)) => {
1978 validate_issue(issue)?;
1979 if entry.issues.contains(&issue) {
1980 Ok(())
1981 } else {
1982 Err(DataCatalogError::UnsupportedIssue {
1983 center,
1984 issue: issue.to_string(),
1985 })
1986 }
1987 }
1988 }
1989}
1990
1991fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
1992 let bytes = sample.as_bytes();
1993 let valid = bytes.len() == 3
1994 && bytes[0].is_ascii_digit()
1995 && bytes[1].is_ascii_digit()
1996 && bytes[2].is_ascii_uppercase();
1997 if valid {
1998 Ok(())
1999 } else {
2000 Err(DataCatalogError::InvalidSample(sample.to_string()))
2001 }
2002}
2003
2004fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
2005 let bytes = issue.as_bytes();
2006 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
2007 if !valid_digits {
2008 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
2009 }
2010 let hour = issue[0..2]
2011 .parse::<u8>()
2012 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2013 let minute = issue[2..4]
2014 .parse::<u8>()
2015 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2016 if hour <= 23 && minute <= 59 {
2017 Ok(())
2018 } else {
2019 Err(DataCatalogError::InvalidIssue(issue.to_string()))
2020 }
2021}
2022
2023fn validate_station(station: &str) -> Result<(), DataCatalogError> {
2024 let bytes = station.as_bytes();
2025 let valid = bytes.len() == 9
2026 && bytes
2027 .iter()
2028 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
2029 if valid {
2030 Ok(())
2031 } else {
2032 Err(DataCatalogError::InvalidStation(station.to_string()))
2033 }
2034}
2035
2036fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
2037 validate_issue(issue)?;
2038 let hour = issue[0..2]
2039 .parse::<u16>()
2040 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2041 let minute = issue[2..4]
2042 .parse::<u16>()
2043 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
2044 Ok(hour * 60 + minute)
2045}
2046
2047fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
2048 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
2049}
2050
2051fn date_block(date: ProductDate, issue: Option<&str>) -> String {
2052 format!(
2053 "{}{:03}{}",
2054 date.year,
2055 date.day_of_year(),
2056 issue.unwrap_or("0000")
2057 )
2058}
2059
2060fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
2061 Ok(match layout {
2062 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
2063 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
2064 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
2065 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
2066 ArchiveLayout::BkgBrdcYearDoy => {
2067 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
2068 }
2069 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
2070 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
2071 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
2072 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
2073 })
2074}
2075
2076fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
2077 let (year, month, day) = civil_from_julian_day_number(jdn);
2078 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
2079 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
2080 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
2081 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
2082}