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: "15M",
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"];
521
522const CENTER_ORDER: [AnalysisCenter; 11] = [
523 AnalysisCenter::CodRap,
524 AnalysisCenter::CodPrd1,
525 AnalysisCenter::CodPrd2,
526 AnalysisCenter::Igs,
527 AnalysisCenter::Esa,
528 AnalysisCenter::Cod,
529 AnalysisCenter::Gfz,
530 AnalysisCenter::IgsUlt,
531 AnalysisCenter::CodUlt,
532 AnalysisCenter::EsaUlt,
533 AnalysisCenter::GfzUlt,
534];
535
536const CATALOG: [CenterCatalogEntry; 11] = [
537 CenterCatalogEntry {
538 center: AnalysisCenter::CodRap,
539 code: "cod_rap",
540 protocol: ArchiveProtocol::Http,
541 host: "ftp.aiub.unibe.ch",
542 root_url: "http://ftp.aiub.unibe.ch",
543 products: &COD_RAP_PRODUCTS,
544 issues: &[],
545 },
546 CenterCatalogEntry {
547 center: AnalysisCenter::CodPrd1,
548 code: "cod_prd1",
549 protocol: ArchiveProtocol::Http,
550 host: "ftp.aiub.unibe.ch",
551 root_url: "http://ftp.aiub.unibe.ch",
552 products: &COD_PRD_PRODUCTS,
553 issues: &[],
554 },
555 CenterCatalogEntry {
556 center: AnalysisCenter::CodPrd2,
557 code: "cod_prd2",
558 protocol: ArchiveProtocol::Http,
559 host: "ftp.aiub.unibe.ch",
560 root_url: "http://ftp.aiub.unibe.ch",
561 products: &COD_PRD_PRODUCTS,
562 issues: &[],
563 },
564 CenterCatalogEntry {
565 center: AnalysisCenter::Igs,
566 code: "igs",
567 protocol: ArchiveProtocol::Https,
568 host: "igs.bkg.bund.de",
569 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
570 products: &IGS_PRODUCTS,
571 issues: &[],
572 },
573 CenterCatalogEntry {
574 center: AnalysisCenter::Esa,
575 code: "esa",
576 protocol: ArchiveProtocol::Https,
577 host: "navigation-office.esa.int",
578 root_url: "https://navigation-office.esa.int/products/gnss-products",
579 products: &ESA_PRODUCTS,
580 issues: &[],
581 },
582 CenterCatalogEntry {
583 center: AnalysisCenter::Cod,
584 code: "cod",
585 protocol: ArchiveProtocol::Http,
586 host: "ftp.aiub.unibe.ch",
587 root_url: "http://ftp.aiub.unibe.ch",
588 products: &COD_PRODUCTS,
589 issues: &[],
590 },
591 CenterCatalogEntry {
592 center: AnalysisCenter::Gfz,
593 code: "gfz",
594 protocol: ArchiveProtocol::Https,
595 host: "isdc-data.gfz.de",
596 root_url: "https://isdc-data.gfz.de/gnss/products",
597 products: &GFZ_PRODUCTS,
598 issues: &[],
599 },
600 CenterCatalogEntry {
601 center: AnalysisCenter::IgsUlt,
602 code: "igs_ult",
603 protocol: ArchiveProtocol::Https,
604 host: "igs.bkg.bund.de",
605 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
606 products: &IGS_ULT_PRODUCTS,
607 issues: &OPSULT_ISSUES,
608 },
609 CenterCatalogEntry {
610 center: AnalysisCenter::CodUlt,
611 code: "cod_ult",
612 protocol: ArchiveProtocol::Http,
613 host: "ftp.aiub.unibe.ch",
614 root_url: "http://ftp.aiub.unibe.ch",
615 products: &COD_ULT_PRODUCTS,
616 issues: &COD_ULT_ISSUES,
617 },
618 CenterCatalogEntry {
619 center: AnalysisCenter::EsaUlt,
620 code: "esa_ult",
621 protocol: ArchiveProtocol::Https,
622 host: "navigation-office.esa.int",
623 root_url: "https://navigation-office.esa.int/products/gnss-products",
624 products: &ESA_ULT_PRODUCTS,
625 issues: &OPSULT_ISSUES,
626 },
627 CenterCatalogEntry {
628 center: AnalysisCenter::GfzUlt,
629 code: "gfz_ult",
630 protocol: ArchiveProtocol::Https,
631 host: "isdc-data.gfz.de",
632 root_url: "https://isdc-data.gfz.de/gnss/products",
633 products: &GFZ_ULT_PRODUCTS,
634 issues: &OPSULT_ISSUES,
635 },
636];
637
638const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
639 protocol: ArchiveProtocol::Https,
640 host: "s3.amazonaws.com",
641 compression: ArchiveCompression::Gzip,
642 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
643};
644
645const CELESTRAK_SPACE_WEATHER_SOURCE: SpaceWeatherSourceEntry = SpaceWeatherSourceEntry {
646 protocol: ArchiveProtocol::Https,
647 host: "celestrak.org",
648 compression: ArchiveCompression::None,
649 root_url: "https://celestrak.org/SpaceData",
650};
651
652const ALLOWED_HOSTS: [&str; 6] = [
653 "ftp.aiub.unibe.ch",
654 "navigation-office.esa.int",
655 "isdc-data.gfz.de",
656 "igs.bkg.bund.de",
657 "s3.amazonaws.com",
658 "celestrak.org",
659];
660
661const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
662 NoOpenMirrorProduct {
663 center: "grg",
664 product_type: "sp3",
665 },
666 NoOpenMirrorProduct {
667 center: "grg",
668 product_type: "clk",
669 },
670 NoOpenMirrorProduct {
671 center: "wum",
672 product_type: "sp3",
673 },
674 NoOpenMirrorProduct {
675 center: "wum",
676 product_type: "clk",
677 },
678 NoOpenMirrorProduct {
679 center: "grg_ult",
680 product_type: "sp3",
681 },
682 NoOpenMirrorProduct {
683 center: "grg_ult",
684 product_type: "clk",
685 },
686 NoOpenMirrorProduct {
687 center: "igs",
688 product_type: "ionex",
689 },
690];
691
692#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum DataCatalogError {
695 UnknownCenter(String),
697 UnknownProductType(String),
699 UnsupportedProduct {
701 center: AnalysisCenter,
703 product_type: ProductType,
705 },
706 NoOpenMirror {
708 center: String,
710 product_type: String,
712 },
713 InvalidDate {
715 year: i32,
717 month: u8,
719 day: u8,
721 },
722 DateOutOfRange,
724 DateBeforeGpsEpoch(ProductDate),
726 InvalidGpsDayOfWeek(u8),
728 InvalidSample(String),
730 InvalidIssue(String),
732 MissingIssue {
734 center: AnalysisCenter,
736 },
737 UnexpectedIssue {
739 center: AnalysisCenter,
741 },
742 UnsupportedIssue {
744 center: AnalysisCenter,
746 issue: String,
748 },
749 InvalidDateTime {
751 hour: u8,
753 minute: u8,
755 second: u8,
757 },
758 NoUltraIssue,
760 NoAvailableUltraIssue,
762 InvalidStation(String),
764 InvalidCoordinate {
766 lat_deg_bits: u64,
768 lon_deg_bits: u64,
770 },
771 InvalidTileIndex {
773 lat_index: i32,
775 lon_index: i32,
777 },
778 InvalidTileId(String),
780}
781
782impl fmt::Display for DataCatalogError {
783 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
784 match self {
785 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
786 Self::UnknownProductType(product_type) => {
787 write!(f, "unknown product type {product_type:?}")
788 }
789 Self::UnsupportedProduct {
790 center,
791 product_type,
792 } => write!(f, "{center} does not serve {product_type}"),
793 Self::NoOpenMirror {
794 center,
795 product_type,
796 } => write!(f, "{center}/{product_type} has no open mirror"),
797 Self::InvalidDate { year, month, day } => {
798 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
799 }
800 Self::DateOutOfRange => write!(f, "product date is out of range"),
801 Self::DateBeforeGpsEpoch(date) => {
802 write!(f, "product date {date} is before the GPS week epoch")
803 }
804 Self::InvalidGpsDayOfWeek(day) => {
805 write!(f, "invalid GPS day-of-week {day}")
806 }
807 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
808 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
809 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
810 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
811 Self::UnsupportedIssue { center, issue } => {
812 write!(f, "{center} does not publish issue {issue:?}")
813 }
814 Self::InvalidDateTime {
815 hour,
816 minute,
817 second,
818 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
819 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
820 Self::NoAvailableUltraIssue => {
821 write!(f, "no available ultra-rapid issue at or before target")
822 }
823 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
824 Self::InvalidCoordinate {
825 lat_deg_bits,
826 lon_deg_bits,
827 } => write!(
828 f,
829 "invalid terrain coordinate lat={} lon={}",
830 f64::from_bits(*lat_deg_bits),
831 f64::from_bits(*lon_deg_bits)
832 ),
833 Self::InvalidTileIndex {
834 lat_index,
835 lon_index,
836 } => write!(
837 f,
838 "invalid terrain tile index lat={lat_index} lon={lon_index}"
839 ),
840 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
841 }
842 }
843}
844
845impl std::error::Error for DataCatalogError {}
846
847#[derive(Debug, Clone, PartialEq, Eq)]
849pub enum HgtConversionError {
850 BadLength {
852 expected: usize,
854 got: usize,
856 },
857 InvalidTileIndex {
859 lat_index: i32,
861 lon_index: i32,
863 },
864}
865
866impl fmt::Display for HgtConversionError {
867 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
868 match self {
869 Self::BadLength { expected, got } => {
870 write!(
871 f,
872 "invalid SRTM1 HGT length: expected {expected}, got {got}"
873 )
874 }
875 Self::InvalidTileIndex {
876 lat_index,
877 lon_index,
878 } => write!(
879 f,
880 "invalid terrain tile index lat={lat_index} lon={lon_index}"
881 ),
882 }
883 }
884}
885
886impl std::error::Error for HgtConversionError {}
887
888const MIN_TERRAIN_LAT_INDEX: i32 = -90;
889const MAX_TERRAIN_LAT_INDEX: i32 = 89;
890const MIN_TERRAIN_LON_INDEX: i32 = -180;
891const MAX_TERRAIN_LON_INDEX: i32 = 179;
892const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
893const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
894const MIN_TERRAIN_LON_DEG: f64 = -180.0;
895const MAX_TERRAIN_LON_DEG: f64 = 180.0;
896const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
897const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
898const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
899const DTED_SRTM1_LEN: usize =
900 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
901
902#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
904pub struct ProductDate {
905 pub year: i32,
907 pub month: u8,
909 pub day: u8,
911}
912
913impl ProductDate {
914 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
916 let days = days_in_month(i64::from(year), i64::from(month));
917 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
918 return Err(DataCatalogError::InvalidDate { year, month, day });
919 }
920 Ok(Self { year, month, day })
921 }
922
923 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
925 if day_of_week > 6 {
926 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
927 }
928 let epoch_jdn =
929 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
930 let offset_days = i64::from(week)
931 .checked_mul(7)
932 .and_then(|days| days.checked_add(i64::from(day_of_week)))
933 .ok_or(DataCatalogError::DateOutOfRange)?;
934 product_date_from_jdn(
935 epoch_jdn
936 .checked_add(offset_days)
937 .ok_or(DataCatalogError::DateOutOfRange)?,
938 )
939 }
940
941 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
943 week_from_calendar(
944 TimeScale::Gpst,
945 i64::from(self.year),
946 i64::from(self.month),
947 i64::from(self.day),
948 )
949 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
950 }
951
952 #[must_use]
954 pub fn day_of_year(self) -> u16 {
955 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
956 }
957
958 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
959 product_date_from_jdn(
960 self.julian_day_number()
961 .checked_add(days)
962 .ok_or(DataCatalogError::DateOutOfRange)?,
963 )
964 }
965
966 fn julian_day_number(self) -> i64 {
967 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
968 }
969}
970
971impl fmt::Display for ProductDate {
972 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
973 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
974 }
975}
976
977#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
979pub struct ProductDateTime {
980 pub date: ProductDate,
982 pub hour: u8,
984 pub minute: u8,
986 pub second: u8,
988}
989
990impl ProductDateTime {
991 pub fn new(
993 date: ProductDate,
994 hour: u8,
995 minute: u8,
996 second: u8,
997 ) -> Result<Self, DataCatalogError> {
998 if hour > 23 || minute > 59 || second > 59 {
999 return Err(DataCatalogError::InvalidDateTime {
1000 hour,
1001 minute,
1002 second,
1003 });
1004 }
1005 Ok(Self {
1006 date,
1007 hour,
1008 minute,
1009 second,
1010 })
1011 }
1012
1013 fn ordering_minutes(self) -> i64 {
1014 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
1015 }
1016}
1017
1018#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1020pub struct UltraIssue {
1021 pub date: ProductDate,
1023 pub issue: String,
1025}
1026
1027impl UltraIssue {
1028 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
1030 validate_issue(issue)?;
1031 Ok(Self {
1032 date,
1033 issue: issue.to_string(),
1034 })
1035 }
1036}
1037
1038#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct ProductSpec {
1041 pub center: AnalysisCenter,
1043 pub product_type: ProductType,
1045 pub date: ProductDate,
1047 pub sample: String,
1049 pub issue: Option<String>,
1051}
1052
1053impl ProductSpec {
1054 pub fn new(
1056 center: AnalysisCenter,
1057 product_type: ProductType,
1058 date: ProductDate,
1059 sample: &str,
1060 issue: Option<&str>,
1061 ) -> Result<Self, DataCatalogError> {
1062 validate_product(center, product_type, sample, issue)?;
1063 Ok(Self {
1064 center,
1065 product_type,
1066 date,
1067 sample: sample.to_string(),
1068 issue: issue.map(ToOwned::to_owned),
1069 })
1070 }
1071
1072 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1074 self.date.gps_week()
1075 }
1076
1077 #[must_use]
1079 pub fn day_of_year(&self) -> u16 {
1080 self.date.day_of_year()
1081 }
1082
1083 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1085 let convention = validate_product(
1086 self.center,
1087 self.product_type,
1088 &self.sample,
1089 self.issue.as_deref(),
1090 )?;
1091 let descriptor = product_type_convention(self.product_type);
1092 Ok(match descriptor.kind {
1093 ProductFilenameKind::Sampled => format!(
1094 "{}_{}_{}_{}_{}.{}",
1095 convention.token,
1096 date_block(self.date, self.issue.as_deref()),
1097 convention.span,
1098 self.sample,
1099 descriptor.content_code,
1100 descriptor.extension
1101 ),
1102 ProductFilenameKind::Nav => format!(
1103 "{}_R_{}_{}_{}.{}",
1104 convention.token,
1105 date_block(self.date, None),
1106 convention.span,
1107 descriptor.content_code,
1108 descriptor.extension
1109 ),
1110 })
1111 }
1112
1113 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1115 let convention = validate_product(
1116 self.center,
1117 self.product_type,
1118 &self.sample,
1119 self.issue.as_deref(),
1120 )?;
1121 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1122 let filename = self.canonical_filename()?;
1123 Ok(format!(
1124 "{}/{}/{}{}",
1125 entry.root_url,
1126 dir_path(convention.layout, self.date)?,
1127 filename,
1128 convention.compression.suffix()
1129 ))
1130 }
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Eq)]
1135pub struct StationObservationSpec {
1136 pub station: String,
1138 pub date: ProductDate,
1140 pub sample: String,
1142}
1143
1144impl StationObservationSpec {
1145 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1147 validate_station(station)?;
1148 validate_sample(sample)?;
1149 Ok(Self {
1150 station: station.to_string(),
1151 date,
1152 sample: sample.to_string(),
1153 })
1154 }
1155
1156 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1158 station_obs_filename(&self.station, self.date, &self.sample)
1159 }
1160
1161 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1163 station_obs_url(&self.station, self.date, &self.sample)
1164 }
1165}
1166
1167#[must_use]
1169pub const fn catalog() -> &'static [CenterCatalogEntry] {
1170 &CATALOG
1171}
1172
1173#[must_use]
1175pub const fn centers() -> &'static [AnalysisCenter] {
1176 &CENTER_ORDER
1177}
1178
1179#[must_use]
1181pub const fn product_types() -> &'static [ProductTypeConvention] {
1182 &PRODUCT_TYPE_CONVENTIONS
1183}
1184
1185#[must_use]
1187pub const fn allowed_hosts() -> &'static [&'static str] {
1188 &ALLOWED_HOSTS
1189}
1190
1191#[must_use]
1193pub const fn skadi_source_entry() -> TerrainSourceEntry {
1194 SKADI_SOURCE
1195}
1196
1197#[must_use]
1199pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1200 CELESTRAK_SPACE_WEATHER_SOURCE
1201}
1202
1203#[must_use]
1205pub const fn space_weather_filename(product: SpaceWeatherProduct) -> &'static str {
1206 match product {
1207 SpaceWeatherProduct::All => "SW-All.csv",
1208 SpaceWeatherProduct::Last5Years => "SW-Last5Years.csv",
1209 }
1210}
1211
1212#[must_use]
1214pub fn space_weather_archive_url(product: SpaceWeatherProduct) -> String {
1215 format!(
1216 "{}/{}",
1217 CELESTRAK_SPACE_WEATHER_SOURCE.root_url,
1218 space_weather_filename(product)
1219 )
1220}
1221
1222#[must_use]
1224pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1225 format!("space-weather/{}", space_weather_filename(product))
1226}
1227
1228pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1230 validate_terrain_tile_index(lat_index, lon_index)?;
1231 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1232 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1233 Ok(format!(
1234 "{lat_hemi}{:02}{lon_hemi}{:03}",
1235 lat_index.abs(),
1236 lon_index.abs()
1237 ))
1238}
1239
1240pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1242 validate_terrain_lat_index(lat_index)?;
1243 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1244 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1245}
1246
1247pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1249 let band = skadi_band(lat_index)?;
1250 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1251 Ok(format!(
1252 "{}/skadi/{}/{}.hgt{}",
1253 SKADI_SOURCE.root_url,
1254 band,
1255 tile_id,
1256 SKADI_SOURCE.compression.suffix()
1257 ))
1258}
1259
1260pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1262 validate_terrain_tile_index(lat_index, lon_index)?;
1263 Ok(format!(
1264 "{}_{}{}",
1265 terrain::format_lat(lat_index),
1266 terrain::format_lon(lon_index),
1267 terrain::DTED_SUFFIX
1268 ))
1269}
1270
1271pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1273 validate_terrain_tile_index(lat_index, lon_index)?;
1274 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1275}
1276
1277pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1279 Ok(format!(
1280 "{}/{}",
1281 dted_block_dir(lat_index, lon_index)?,
1282 dted_tile_filename(lat_index, lon_index)?
1283 ))
1284}
1285
1286pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1288 let bytes = id.as_bytes();
1289 if bytes.len() != 7
1290 || !matches!(bytes[0], b'N' | b'S')
1291 || !matches!(bytes[3], b'E' | b'W')
1292 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1293 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1294 {
1295 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1296 }
1297
1298 let lat_abs = id[1..3]
1299 .parse::<i32>()
1300 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1301 let lon_abs = id[4..7]
1302 .parse::<i32>()
1303 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1304 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1305 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1306 }
1307
1308 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1309 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1310 validate_terrain_tile_index(lat_index, lon_index)?;
1311 Ok((lat_index, lon_index))
1312}
1313
1314pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1316 if !lat_deg.is_finite()
1317 || !lon_deg.is_finite()
1318 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1319 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1320 {
1321 return Err(DataCatalogError::InvalidCoordinate {
1322 lat_deg_bits: lat_deg.to_bits(),
1323 lon_deg_bits: lon_deg.to_bits(),
1324 });
1325 }
1326
1327 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1328 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1329 lat_index = MAX_TERRAIN_LAT_INDEX;
1330 }
1331 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1332 lon_index = MAX_TERRAIN_LON_INDEX;
1333 }
1334 validate_terrain_tile_index(lat_index, lon_index)?;
1335 Ok((lat_index, lon_index))
1336}
1337
1338pub fn hgt_to_dted(
1346 lat_index: i32,
1347 lon_index: i32,
1348 hgt: &[u8],
1349) -> Result<Vec<u8>, HgtConversionError> {
1350 validate_hgt_tile_index(lat_index, lon_index)?;
1351 if hgt.len() != SRTM1_HGT_LEN {
1352 return Err(HgtConversionError::BadLength {
1353 expected: SRTM1_HGT_LEN,
1354 got: hgt.len(),
1355 });
1356 }
1357
1358 let mut out = vec![b' '; DTED_SRTM1_LEN];
1359 out[0..4].copy_from_slice(b"UHL1");
1360 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1361 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1362 out[47..51].copy_from_slice(b"3601");
1363 out[51..55].copy_from_slice(b"3601");
1364
1365 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1366 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1367 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1368 out[block_start] = terrain::DATA_SENTINEL;
1369
1370 let count = (lon_posting as u32).to_be_bytes();
1371 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1372 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1373 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1374
1375 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1376 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1377 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1378 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1379 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1380 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1381 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1382 }
1383
1384 let checksum = out[block_start..checksum_start]
1385 .iter()
1386 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1387 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1388 }
1389
1390 debug_assert_eq!(out.len(), 25_981_042);
1391 Ok(out)
1392}
1393
1394#[must_use]
1396pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1397 &NO_OPEN_MIRRORS
1398}
1399
1400pub fn open_mirror(
1402 center: AnalysisCenter,
1403 product_type: ProductType,
1404) -> Result<(), DataCatalogError> {
1405 open_mirror_code(center.code(), product_type.code())
1406}
1407
1408pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
1410 if NO_OPEN_MIRRORS
1411 .iter()
1412 .any(|entry| entry.center == center && entry.product_type == product_type)
1413 {
1414 Err(DataCatalogError::NoOpenMirror {
1415 center: center.to_string(),
1416 product_type: product_type.to_string(),
1417 })
1418 } else {
1419 Ok(())
1420 }
1421}
1422
1423#[must_use]
1425pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1426 CATALOG.iter().find(|entry| entry.center == center)
1427}
1428
1429pub fn product_convention(
1431 center: AnalysisCenter,
1432 product_type: ProductType,
1433) -> Result<&'static CenterProductConvention, DataCatalogError> {
1434 open_mirror(center, product_type)?;
1435 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1436 entry
1437 .products
1438 .iter()
1439 .find(|product| product.product_type == product_type)
1440 .ok_or(DataCatalogError::UnsupportedProduct {
1441 center,
1442 product_type,
1443 })
1444}
1445
1446pub fn default_sample(
1448 center: AnalysisCenter,
1449 product_type: ProductType,
1450) -> Result<&'static str, DataCatalogError> {
1451 Ok(product_convention(center, product_type)?.default_sample)
1452}
1453
1454pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1456 date.gps_week()
1457}
1458
1459#[must_use]
1461pub fn day_of_year(date: ProductDate) -> u16 {
1462 date.day_of_year()
1463}
1464
1465pub fn product(
1467 center: AnalysisCenter,
1468 product_type: ProductType,
1469 date: ProductDate,
1470 sample: Option<&str>,
1471 issue: Option<&str>,
1472) -> Result<ProductSpec, DataCatalogError> {
1473 let sample = match sample {
1474 Some(sample) => sample,
1475 None => default_sample(center, product_type)?,
1476 };
1477 ProductSpec::new(center, product_type, date, sample, issue)
1478}
1479
1480pub fn canonical_filename(
1482 center: AnalysisCenter,
1483 product_type: ProductType,
1484 date: ProductDate,
1485 sample: Option<&str>,
1486 issue: Option<&str>,
1487) -> Result<String, DataCatalogError> {
1488 product(center, product_type, date, sample, issue)?.canonical_filename()
1489}
1490
1491pub fn archive_url(
1493 center: AnalysisCenter,
1494 product_type: ProductType,
1495 date: ProductDate,
1496 sample: Option<&str>,
1497 issue: Option<&str>,
1498) -> Result<String, DataCatalogError> {
1499 product(center, product_type, date, sample, issue)?.archive_url()
1500}
1501
1502pub fn mgex_clk(
1504 center: AnalysisCenter,
1505 date: ProductDate,
1506 sample: Option<&str>,
1507) -> Result<ProductSpec, DataCatalogError> {
1508 product(center, ProductType::Clk, date, sample, None)
1509}
1510
1511pub fn mgex_nav(
1513 center: AnalysisCenter,
1514 date: ProductDate,
1515 sample: Option<&str>,
1516) -> Result<ProductSpec, DataCatalogError> {
1517 product(center, ProductType::Nav, date, sample, None)
1518}
1519
1520pub fn mgex_ionex(
1522 center: AnalysisCenter,
1523 date: ProductDate,
1524 sample: Option<&str>,
1525) -> Result<ProductSpec, DataCatalogError> {
1526 product(center, ProductType::Ionex, date, sample, None)
1527}
1528
1529pub fn rapid_ionex(
1531 date: ProductDate,
1532 sample: Option<&str>,
1533) -> Result<ProductSpec, DataCatalogError> {
1534 product(
1535 AnalysisCenter::CodRap,
1536 ProductType::Ionex,
1537 date,
1538 sample,
1539 None,
1540 )
1541}
1542
1543#[must_use]
1545pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1546 match center {
1547 AnalysisCenter::CodPrd2 => 1,
1548 _ => 0,
1549 }
1550}
1551
1552pub fn predicted_ionex(
1554 center: AnalysisCenter,
1555 date: ProductDate,
1556 sample: Option<&str>,
1557) -> Result<ProductSpec, DataCatalogError> {
1558 match center {
1559 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
1560 let target = date.add_days(predicted_day_offset(center))?;
1561 product(center, ProductType::Ionex, target, sample, None)
1562 }
1563 other => Err(DataCatalogError::UnsupportedProduct {
1564 center: other,
1565 product_type: ProductType::Ionex,
1566 }),
1567 }
1568}
1569
1570pub fn mgex_sp3(
1572 center: AnalysisCenter,
1573 date: ProductDate,
1574 sample: Option<&str>,
1575) -> Result<ProductSpec, DataCatalogError> {
1576 product(center, ProductType::Sp3, date, sample, None)
1577}
1578
1579pub fn ops_ultra_sp3(
1581 center: AnalysisCenter,
1582 date: ProductDate,
1583 sample: Option<&str>,
1584 issue: Option<&str>,
1585) -> Result<ProductSpec, DataCatalogError> {
1586 let issue = issue.unwrap_or("0000");
1587 product(center, ProductType::Sp3, date, sample, Some(issue))
1588}
1589
1590pub fn ops_ultra_clk(
1592 center: AnalysisCenter,
1593 date: ProductDate,
1594 sample: Option<&str>,
1595 issue: Option<&str>,
1596) -> Result<ProductSpec, DataCatalogError> {
1597 let issue = issue.unwrap_or("0000");
1598 product(center, ProductType::Clk, date, sample, Some(issue))
1599}
1600
1601pub fn latest_ops_ultra_sp3(
1603 center: AnalysisCenter,
1604 target: ProductDateTime,
1605 sample: Option<&str>,
1606 available_issues: Option<&[UltraIssue]>,
1607) -> Result<ProductSpec, DataCatalogError> {
1608 let selected = latest_ultra_issue(center, target, available_issues)?;
1609 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
1610}
1611
1612pub fn ultra_issue_candidates(
1614 center: AnalysisCenter,
1615 target: ProductDateTime,
1616) -> Result<Vec<UltraIssue>, DataCatalogError> {
1617 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1618 let _ = product_convention(center, ProductType::Sp3)?;
1619 if entry.issues.is_empty() {
1620 return Err(DataCatalogError::UnsupportedProduct {
1621 center,
1622 product_type: ProductType::Sp3,
1623 });
1624 }
1625
1626 let mut candidates = Vec::new();
1627 for date in [target.date, target.date.add_days(-1)?] {
1628 for issue in entry.issues.iter().rev() {
1629 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
1630 candidates.push(UltraIssue::new(date, issue)?);
1631 }
1632 }
1633 }
1634 Ok(candidates)
1635}
1636
1637pub fn latest_ultra_issue(
1639 center: AnalysisCenter,
1640 target: ProductDateTime,
1641 available_issues: Option<&[UltraIssue]>,
1642) -> Result<UltraIssue, DataCatalogError> {
1643 let candidates = ultra_issue_candidates(center, target)?;
1644 if candidates.is_empty() {
1645 return Err(DataCatalogError::NoUltraIssue);
1646 }
1647 if let Some(available) = available_issues {
1648 candidates
1649 .into_iter()
1650 .find(|candidate| {
1651 available
1652 .iter()
1653 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
1654 })
1655 .ok_or(DataCatalogError::NoAvailableUltraIssue)
1656 } else {
1657 Ok(candidates[0].clone())
1658 }
1659}
1660
1661pub fn gim_date_candidates(
1663 center: AnalysisCenter,
1664 target: ProductDate,
1665 lookback: u32,
1666) -> Result<Vec<ProductDate>, DataCatalogError> {
1667 let _ = product_convention(center, ProductType::Ionex)?;
1668 let base = target.add_days(predicted_day_offset(center))?;
1669 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
1670 for back in 0..=lookback {
1671 out.push(base.add_days(-i64::from(back))?);
1672 }
1673 Ok(out)
1674}
1675
1676pub fn station_obs(
1678 station: &str,
1679 date: ProductDate,
1680 sample: Option<&str>,
1681) -> Result<StationObservationSpec, DataCatalogError> {
1682 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
1683}
1684
1685pub fn station_obs_filename(
1687 station: &str,
1688 date: ProductDate,
1689 sample: &str,
1690) -> Result<String, DataCatalogError> {
1691 validate_station(station)?;
1692 validate_sample(sample)?;
1693 Ok(format!(
1694 "{}_R_{}_01D_{}_MO.crx",
1695 station,
1696 date_block(date, None),
1697 sample
1698 ))
1699}
1700
1701pub fn station_obs_url(
1703 station: &str,
1704 date: ProductDate,
1705 sample: &str,
1706) -> Result<String, DataCatalogError> {
1707 let filename = station_obs_filename(station, date, sample)?;
1708 Ok(format!(
1709 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
1710 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
1711 filename
1712 ))
1713}
1714
1715#[must_use]
1717pub const fn station_obs_protocol() -> ArchiveProtocol {
1718 ArchiveProtocol::Https
1719}
1720
1721fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
1722 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
1723 Ok(())
1724 } else {
1725 Err(DataCatalogError::InvalidTileIndex {
1726 lat_index,
1727 lon_index: 0,
1728 })
1729 }
1730}
1731
1732fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
1733 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1734 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1735 {
1736 Ok(())
1737 } else {
1738 Err(DataCatalogError::InvalidTileIndex {
1739 lat_index,
1740 lon_index,
1741 })
1742 }
1743}
1744
1745fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
1746 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1747 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1748 {
1749 Ok(())
1750 } else {
1751 Err(HgtConversionError::InvalidTileIndex {
1752 lat_index,
1753 lon_index,
1754 })
1755 }
1756}
1757
1758fn dted_coord_field(index: i32, is_longitude: bool) -> String {
1759 let hemi = match (is_longitude, index >= 0) {
1760 (true, true) => 'E',
1761 (true, false) => 'W',
1762 (false, true) => 'N',
1763 (false, false) => 'S',
1764 };
1765 format!("{:03}0000{hemi}", index.abs())
1766}
1767
1768fn encode_dted_signed_magnitude(sample: i16) -> u16 {
1769 if sample == i16::MIN {
1770 0
1771 } else if sample >= 0 {
1772 sample as u16
1773 } else {
1774 0x8000 | (-i32::from(sample) as u16)
1775 }
1776}
1777
1778fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
1779 PRODUCT_TYPE_CONVENTIONS
1780 .iter()
1781 .find(|descriptor| descriptor.product_type == product_type)
1782 .expect("product descriptor exists for enum variant")
1783}
1784
1785fn validate_product(
1786 center: AnalysisCenter,
1787 product_type: ProductType,
1788 sample: &str,
1789 issue: Option<&str>,
1790) -> Result<&'static CenterProductConvention, DataCatalogError> {
1791 let convention = product_convention(center, product_type)?;
1792 validate_sample(sample)?;
1793 validate_issue_for_center(center, issue)?;
1794 Ok(convention)
1795}
1796
1797fn validate_issue_for_center(
1798 center: AnalysisCenter,
1799 issue: Option<&str>,
1800) -> Result<(), DataCatalogError> {
1801 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1802 match (entry.issues.is_empty(), issue) {
1803 (true, None) => Ok(()),
1804 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
1805 (false, None) => Err(DataCatalogError::MissingIssue { center }),
1806 (false, Some(issue)) => {
1807 validate_issue(issue)?;
1808 if entry.issues.contains(&issue) {
1809 Ok(())
1810 } else {
1811 Err(DataCatalogError::UnsupportedIssue {
1812 center,
1813 issue: issue.to_string(),
1814 })
1815 }
1816 }
1817 }
1818}
1819
1820fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
1821 let bytes = sample.as_bytes();
1822 let valid = bytes.len() == 3
1823 && bytes[0].is_ascii_digit()
1824 && bytes[1].is_ascii_digit()
1825 && bytes[2].is_ascii_uppercase();
1826 if valid {
1827 Ok(())
1828 } else {
1829 Err(DataCatalogError::InvalidSample(sample.to_string()))
1830 }
1831}
1832
1833fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
1834 let bytes = issue.as_bytes();
1835 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
1836 if !valid_digits {
1837 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
1838 }
1839 let hour = issue[0..2]
1840 .parse::<u8>()
1841 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1842 let minute = issue[2..4]
1843 .parse::<u8>()
1844 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1845 if hour <= 23 && minute <= 59 {
1846 Ok(())
1847 } else {
1848 Err(DataCatalogError::InvalidIssue(issue.to_string()))
1849 }
1850}
1851
1852fn validate_station(station: &str) -> Result<(), DataCatalogError> {
1853 let bytes = station.as_bytes();
1854 let valid = bytes.len() == 9
1855 && bytes
1856 .iter()
1857 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
1858 if valid {
1859 Ok(())
1860 } else {
1861 Err(DataCatalogError::InvalidStation(station.to_string()))
1862 }
1863}
1864
1865fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
1866 validate_issue(issue)?;
1867 let hour = issue[0..2]
1868 .parse::<u16>()
1869 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1870 let minute = issue[2..4]
1871 .parse::<u16>()
1872 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1873 Ok(hour * 60 + minute)
1874}
1875
1876fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
1877 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
1878}
1879
1880fn date_block(date: ProductDate, issue: Option<&str>) -> String {
1881 format!(
1882 "{}{:03}{}",
1883 date.year,
1884 date.day_of_year(),
1885 issue.unwrap_or("0000")
1886 )
1887}
1888
1889fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
1890 Ok(match layout {
1891 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
1892 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
1893 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
1894 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
1895 ArchiveLayout::BkgBrdcYearDoy => {
1896 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
1897 }
1898 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
1899 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
1900 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
1901 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
1902 })
1903}
1904
1905fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
1906 let (year, month, day) = civil_from_julian_day_number(jdn);
1907 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
1908 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
1909 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
1910 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
1911}