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)]
151pub enum ArchiveProtocol {
152 Http,
154 Https,
156}
157
158impl ArchiveProtocol {
159 #[must_use]
161 pub const fn as_str(self) -> &'static str {
162 match self {
163 Self::Http => "http",
164 Self::Https => "https",
165 }
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum ArchiveCompression {
172 Gzip,
174 None,
176}
177
178impl ArchiveCompression {
179 #[must_use]
181 pub const fn as_str(self) -> &'static str {
182 match self {
183 Self::Gzip => "gzip",
184 Self::None => "none",
185 }
186 }
187
188 const fn suffix(self) -> &'static str {
189 match self {
190 Self::Gzip => ".gz",
191 Self::None => "",
192 }
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198pub enum ArchiveLayout {
199 GfzRapidWeek,
201 GfzUltraWeek,
203 GpsWeek,
205 BkgProductsWeek,
207 BkgBrdcYearDoy,
209 BkgObsYearDoy,
211 AiubCodeMgexYear,
213 AiubCodeYear,
215 AiubCodeRoot,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum ProductFilenameKind {
222 Sampled,
224 Nav,
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct ProductTypeConvention {
231 pub product_type: ProductType,
233 pub content_code: &'static str,
235 pub extension: &'static str,
237 pub kind: ProductFilenameKind,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct CenterProductConvention {
244 pub product_type: ProductType,
246 pub token: &'static str,
248 pub layout: ArchiveLayout,
250 pub span: &'static str,
252 pub default_sample: &'static str,
254 pub compression: ArchiveCompression,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260pub struct CenterCatalogEntry {
261 pub center: AnalysisCenter,
263 pub code: &'static str,
265 pub protocol: ArchiveProtocol,
267 pub host: &'static str,
269 pub root_url: &'static str,
271 pub products: &'static [CenterProductConvention],
273 pub issues: &'static [&'static str],
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub struct TerrainSourceEntry {
280 pub protocol: ArchiveProtocol,
282 pub host: &'static str,
284 pub compression: ArchiveCompression,
286 pub root_url: &'static str,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
292pub struct NoOpenMirrorProduct {
293 pub center: &'static str,
295 pub product_type: &'static str,
297}
298
299const PRODUCT_TYPE_CONVENTIONS: [ProductTypeConvention; 4] = [
300 ProductTypeConvention {
301 product_type: ProductType::Sp3,
302 content_code: "ORB",
303 extension: "SP3",
304 kind: ProductFilenameKind::Sampled,
305 },
306 ProductTypeConvention {
307 product_type: ProductType::Clk,
308 content_code: "CLK",
309 extension: "CLK",
310 kind: ProductFilenameKind::Sampled,
311 },
312 ProductTypeConvention {
313 product_type: ProductType::Nav,
314 content_code: "MN",
315 extension: "rnx",
316 kind: ProductFilenameKind::Nav,
317 },
318 ProductTypeConvention {
319 product_type: ProductType::Ionex,
320 content_code: "GIM",
321 extension: "INX",
322 kind: ProductFilenameKind::Sampled,
323 },
324];
325
326const COD_RAP_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
327 product_type: ProductType::Ionex,
328 token: "COD0OPSRAP",
329 layout: ArchiveLayout::AiubCodeRoot,
330 span: "01D",
331 default_sample: "01H",
332 compression: ArchiveCompression::Gzip,
333}];
334
335const COD_PRD_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
336 product_type: ProductType::Ionex,
337 token: "COD0OPSPRD",
338 layout: ArchiveLayout::AiubCodeRoot,
339 span: "01D",
340 default_sample: "01H",
341 compression: ArchiveCompression::Gzip,
342}];
343
344const ESA_PRODUCTS: [CenterProductConvention; 3] = [
345 CenterProductConvention {
346 product_type: ProductType::Sp3,
347 token: "ESA0MGNFIN",
348 layout: ArchiveLayout::GpsWeek,
349 span: "01D",
350 default_sample: "05M",
351 compression: ArchiveCompression::Gzip,
352 },
353 CenterProductConvention {
354 product_type: ProductType::Clk,
355 token: "ESA0MGNFIN",
356 layout: ArchiveLayout::GpsWeek,
357 span: "01D",
358 default_sample: "30S",
359 compression: ArchiveCompression::Gzip,
360 },
361 CenterProductConvention {
362 product_type: ProductType::Ionex,
363 token: "ESA0OPSFIN",
364 layout: ArchiveLayout::GpsWeek,
365 span: "01D",
366 default_sample: "02H",
367 compression: ArchiveCompression::Gzip,
368 },
369];
370
371const COD_PRODUCTS: [CenterProductConvention; 3] = [
372 CenterProductConvention {
373 product_type: ProductType::Sp3,
374 token: "COD0MGXFIN",
375 layout: ArchiveLayout::AiubCodeMgexYear,
376 span: "01D",
377 default_sample: "05M",
378 compression: ArchiveCompression::Gzip,
379 },
380 CenterProductConvention {
381 product_type: ProductType::Clk,
382 token: "COD0MGXFIN",
383 layout: ArchiveLayout::AiubCodeMgexYear,
384 span: "01D",
385 default_sample: "30S",
386 compression: ArchiveCompression::Gzip,
387 },
388 CenterProductConvention {
389 product_type: ProductType::Ionex,
390 token: "COD0OPSFIN",
391 layout: ArchiveLayout::AiubCodeYear,
392 span: "01D",
393 default_sample: "01H",
394 compression: ArchiveCompression::Gzip,
395 },
396];
397
398const GFZ_PRODUCTS: [CenterProductConvention; 2] = [
399 CenterProductConvention {
400 product_type: ProductType::Sp3,
401 token: "GFZ0OPSRAP",
402 layout: ArchiveLayout::GfzRapidWeek,
403 span: "01D",
404 default_sample: "15M",
405 compression: ArchiveCompression::Gzip,
406 },
407 CenterProductConvention {
408 product_type: ProductType::Clk,
409 token: "GFZ0OPSRAP",
410 layout: ArchiveLayout::GfzRapidWeek,
411 span: "01D",
412 default_sample: "30S",
413 compression: ArchiveCompression::Gzip,
414 },
415];
416
417const IGS_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
418 product_type: ProductType::Nav,
419 token: "BRDC00WRD",
420 layout: ArchiveLayout::BkgBrdcYearDoy,
421 span: "01D",
422 default_sample: "01D",
423 compression: ArchiveCompression::Gzip,
424}];
425
426const IGS_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
427 product_type: ProductType::Sp3,
428 token: "IGS0OPSULT",
429 layout: ArchiveLayout::BkgProductsWeek,
430 span: "02D",
431 default_sample: "15M",
432 compression: ArchiveCompression::Gzip,
433}];
434
435const COD_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
436 product_type: ProductType::Sp3,
437 token: "COD0OPSULT",
438 layout: ArchiveLayout::AiubCodeRoot,
439 span: "01D",
440 default_sample: "05M",
441 compression: ArchiveCompression::None,
442}];
443
444const ESA_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
445 product_type: ProductType::Sp3,
446 token: "ESA0OPSULT",
447 layout: ArchiveLayout::GpsWeek,
448 span: "02D",
449 default_sample: "15M",
450 compression: ArchiveCompression::Gzip,
451}];
452
453const GFZ_ULT_PRODUCTS: [CenterProductConvention; 1] = [CenterProductConvention {
454 product_type: ProductType::Sp3,
455 token: "GFZ0OPSULT",
456 layout: ArchiveLayout::GfzUltraWeek,
457 span: "02D",
458 default_sample: "05M",
459 compression: ArchiveCompression::Gzip,
460}];
461
462const OPSULT_ISSUES: [&str; 4] = ["0000", "0600", "1200", "1800"];
463const COD_ULT_ISSUES: [&str; 1] = ["0000"];
464
465const CENTER_ORDER: [AnalysisCenter; 11] = [
466 AnalysisCenter::CodRap,
467 AnalysisCenter::CodPrd1,
468 AnalysisCenter::CodPrd2,
469 AnalysisCenter::Igs,
470 AnalysisCenter::Esa,
471 AnalysisCenter::Cod,
472 AnalysisCenter::Gfz,
473 AnalysisCenter::IgsUlt,
474 AnalysisCenter::CodUlt,
475 AnalysisCenter::EsaUlt,
476 AnalysisCenter::GfzUlt,
477];
478
479const CATALOG: [CenterCatalogEntry; 11] = [
480 CenterCatalogEntry {
481 center: AnalysisCenter::CodRap,
482 code: "cod_rap",
483 protocol: ArchiveProtocol::Http,
484 host: "ftp.aiub.unibe.ch",
485 root_url: "http://ftp.aiub.unibe.ch",
486 products: &COD_RAP_PRODUCTS,
487 issues: &[],
488 },
489 CenterCatalogEntry {
490 center: AnalysisCenter::CodPrd1,
491 code: "cod_prd1",
492 protocol: ArchiveProtocol::Http,
493 host: "ftp.aiub.unibe.ch",
494 root_url: "http://ftp.aiub.unibe.ch",
495 products: &COD_PRD_PRODUCTS,
496 issues: &[],
497 },
498 CenterCatalogEntry {
499 center: AnalysisCenter::CodPrd2,
500 code: "cod_prd2",
501 protocol: ArchiveProtocol::Http,
502 host: "ftp.aiub.unibe.ch",
503 root_url: "http://ftp.aiub.unibe.ch",
504 products: &COD_PRD_PRODUCTS,
505 issues: &[],
506 },
507 CenterCatalogEntry {
508 center: AnalysisCenter::Igs,
509 code: "igs",
510 protocol: ArchiveProtocol::Https,
511 host: "igs.bkg.bund.de",
512 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
513 products: &IGS_PRODUCTS,
514 issues: &[],
515 },
516 CenterCatalogEntry {
517 center: AnalysisCenter::Esa,
518 code: "esa",
519 protocol: ArchiveProtocol::Https,
520 host: "navigation-office.esa.int",
521 root_url: "https://navigation-office.esa.int/products/gnss-products",
522 products: &ESA_PRODUCTS,
523 issues: &[],
524 },
525 CenterCatalogEntry {
526 center: AnalysisCenter::Cod,
527 code: "cod",
528 protocol: ArchiveProtocol::Http,
529 host: "ftp.aiub.unibe.ch",
530 root_url: "http://ftp.aiub.unibe.ch",
531 products: &COD_PRODUCTS,
532 issues: &[],
533 },
534 CenterCatalogEntry {
535 center: AnalysisCenter::Gfz,
536 code: "gfz",
537 protocol: ArchiveProtocol::Https,
538 host: "isdc-data.gfz.de",
539 root_url: "https://isdc-data.gfz.de/gnss/products",
540 products: &GFZ_PRODUCTS,
541 issues: &[],
542 },
543 CenterCatalogEntry {
544 center: AnalysisCenter::IgsUlt,
545 code: "igs_ult",
546 protocol: ArchiveProtocol::Https,
547 host: "igs.bkg.bund.de",
548 root_url: "https://igs.bkg.bund.de/root_ftp/IGS",
549 products: &IGS_ULT_PRODUCTS,
550 issues: &OPSULT_ISSUES,
551 },
552 CenterCatalogEntry {
553 center: AnalysisCenter::CodUlt,
554 code: "cod_ult",
555 protocol: ArchiveProtocol::Http,
556 host: "ftp.aiub.unibe.ch",
557 root_url: "http://ftp.aiub.unibe.ch",
558 products: &COD_ULT_PRODUCTS,
559 issues: &COD_ULT_ISSUES,
560 },
561 CenterCatalogEntry {
562 center: AnalysisCenter::EsaUlt,
563 code: "esa_ult",
564 protocol: ArchiveProtocol::Https,
565 host: "navigation-office.esa.int",
566 root_url: "https://navigation-office.esa.int/products/gnss-products",
567 products: &ESA_ULT_PRODUCTS,
568 issues: &OPSULT_ISSUES,
569 },
570 CenterCatalogEntry {
571 center: AnalysisCenter::GfzUlt,
572 code: "gfz_ult",
573 protocol: ArchiveProtocol::Https,
574 host: "isdc-data.gfz.de",
575 root_url: "https://isdc-data.gfz.de/gnss/products",
576 products: &GFZ_ULT_PRODUCTS,
577 issues: &OPSULT_ISSUES,
578 },
579];
580
581const SKADI_SOURCE: TerrainSourceEntry = TerrainSourceEntry {
582 protocol: ArchiveProtocol::Https,
583 host: "s3.amazonaws.com",
584 compression: ArchiveCompression::Gzip,
585 root_url: "https://s3.amazonaws.com/elevation-tiles-prod",
586};
587
588const ALLOWED_HOSTS: [&str; 5] = [
589 "ftp.aiub.unibe.ch",
590 "navigation-office.esa.int",
591 "isdc-data.gfz.de",
592 "igs.bkg.bund.de",
593 "s3.amazonaws.com",
594];
595
596const NO_OPEN_MIRRORS: [NoOpenMirrorProduct; 7] = [
597 NoOpenMirrorProduct {
598 center: "grg",
599 product_type: "sp3",
600 },
601 NoOpenMirrorProduct {
602 center: "grg",
603 product_type: "clk",
604 },
605 NoOpenMirrorProduct {
606 center: "wum",
607 product_type: "sp3",
608 },
609 NoOpenMirrorProduct {
610 center: "wum",
611 product_type: "clk",
612 },
613 NoOpenMirrorProduct {
614 center: "grg_ult",
615 product_type: "sp3",
616 },
617 NoOpenMirrorProduct {
618 center: "grg_ult",
619 product_type: "clk",
620 },
621 NoOpenMirrorProduct {
622 center: "igs",
623 product_type: "ionex",
624 },
625];
626
627#[derive(Debug, Clone, PartialEq, Eq)]
629pub enum DataCatalogError {
630 UnknownCenter(String),
632 UnknownProductType(String),
634 UnsupportedProduct {
636 center: AnalysisCenter,
638 product_type: ProductType,
640 },
641 NoOpenMirror {
643 center: String,
645 product_type: String,
647 },
648 InvalidDate {
650 year: i32,
652 month: u8,
654 day: u8,
656 },
657 DateOutOfRange,
659 DateBeforeGpsEpoch(ProductDate),
661 InvalidGpsDayOfWeek(u8),
663 InvalidSample(String),
665 InvalidIssue(String),
667 MissingIssue {
669 center: AnalysisCenter,
671 },
672 UnexpectedIssue {
674 center: AnalysisCenter,
676 },
677 UnsupportedIssue {
679 center: AnalysisCenter,
681 issue: String,
683 },
684 InvalidDateTime {
686 hour: u8,
688 minute: u8,
690 second: u8,
692 },
693 NoUltraIssue,
695 NoAvailableUltraIssue,
697 InvalidStation(String),
699 InvalidCoordinate {
701 lat_deg_bits: u64,
703 lon_deg_bits: u64,
705 },
706 InvalidTileIndex {
708 lat_index: i32,
710 lon_index: i32,
712 },
713 InvalidTileId(String),
715}
716
717impl fmt::Display for DataCatalogError {
718 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
719 match self {
720 Self::UnknownCenter(center) => write!(f, "unknown analysis center {center:?}"),
721 Self::UnknownProductType(product_type) => {
722 write!(f, "unknown product type {product_type:?}")
723 }
724 Self::UnsupportedProduct {
725 center,
726 product_type,
727 } => write!(f, "{center} does not serve {product_type}"),
728 Self::NoOpenMirror {
729 center,
730 product_type,
731 } => write!(f, "{center}/{product_type} has no open mirror"),
732 Self::InvalidDate { year, month, day } => {
733 write!(f, "invalid product date {year:04}-{month:02}-{day:02}")
734 }
735 Self::DateOutOfRange => write!(f, "product date is out of range"),
736 Self::DateBeforeGpsEpoch(date) => {
737 write!(f, "product date {date} is before the GPS week epoch")
738 }
739 Self::InvalidGpsDayOfWeek(day) => {
740 write!(f, "invalid GPS day-of-week {day}")
741 }
742 Self::InvalidSample(sample) => write!(f, "invalid sample code {sample:?}"),
743 Self::InvalidIssue(issue) => write!(f, "invalid issue time {issue:?}"),
744 Self::MissingIssue { center } => write!(f, "{center} requires an issue time"),
745 Self::UnexpectedIssue { center } => write!(f, "{center} does not take an issue time"),
746 Self::UnsupportedIssue { center, issue } => {
747 write!(f, "{center} does not publish issue {issue:?}")
748 }
749 Self::InvalidDateTime {
750 hour,
751 minute,
752 second,
753 } => write!(f, "invalid product time {hour:02}:{minute:02}:{second:02}"),
754 Self::NoUltraIssue => write!(f, "no ultra-rapid issue at or before target"),
755 Self::NoAvailableUltraIssue => {
756 write!(f, "no available ultra-rapid issue at or before target")
757 }
758 Self::InvalidStation(station) => write!(f, "invalid station code {station:?}"),
759 Self::InvalidCoordinate {
760 lat_deg_bits,
761 lon_deg_bits,
762 } => write!(
763 f,
764 "invalid terrain coordinate lat={} lon={}",
765 f64::from_bits(*lat_deg_bits),
766 f64::from_bits(*lon_deg_bits)
767 ),
768 Self::InvalidTileIndex {
769 lat_index,
770 lon_index,
771 } => write!(
772 f,
773 "invalid terrain tile index lat={lat_index} lon={lon_index}"
774 ),
775 Self::InvalidTileId(id) => write!(f, "invalid skadi tile id {id:?}"),
776 }
777 }
778}
779
780impl std::error::Error for DataCatalogError {}
781
782#[derive(Debug, Clone, PartialEq, Eq)]
784pub enum HgtConversionError {
785 BadLength {
787 expected: usize,
789 got: usize,
791 },
792 InvalidTileIndex {
794 lat_index: i32,
796 lon_index: i32,
798 },
799}
800
801impl fmt::Display for HgtConversionError {
802 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
803 match self {
804 Self::BadLength { expected, got } => {
805 write!(
806 f,
807 "invalid SRTM1 HGT length: expected {expected}, got {got}"
808 )
809 }
810 Self::InvalidTileIndex {
811 lat_index,
812 lon_index,
813 } => write!(
814 f,
815 "invalid terrain tile index lat={lat_index} lon={lon_index}"
816 ),
817 }
818 }
819}
820
821impl std::error::Error for HgtConversionError {}
822
823const MIN_TERRAIN_LAT_INDEX: i32 = -90;
824const MAX_TERRAIN_LAT_INDEX: i32 = 89;
825const MIN_TERRAIN_LON_INDEX: i32 = -180;
826const MAX_TERRAIN_LON_INDEX: i32 = 179;
827const MIN_TERRAIN_LAT_DEG: f64 = -90.0;
828const MAX_TERRAIN_LAT_DEG: f64 = 90.0;
829const MIN_TERRAIN_LON_DEG: f64 = -180.0;
830const MAX_TERRAIN_LON_DEG: f64 = 180.0;
831const SRTM1_POSTINGS_PER_AXIS: usize = 3601;
832const SRTM1_HGT_LEN: usize = SRTM1_POSTINGS_PER_AXIS * SRTM1_POSTINGS_PER_AXIS * 2;
833const DTED_SRTM1_DATA_BLOCK_LEN: usize = 12 + 2 * SRTM1_POSTINGS_PER_AXIS;
834const DTED_SRTM1_LEN: usize =
835 terrain::DATA_OFFSET + SRTM1_POSTINGS_PER_AXIS * DTED_SRTM1_DATA_BLOCK_LEN;
836
837#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
839pub struct ProductDate {
840 pub year: i32,
842 pub month: u8,
844 pub day: u8,
846}
847
848impl ProductDate {
849 pub fn new(year: i32, month: u8, day: u8) -> Result<Self, DataCatalogError> {
851 let days = days_in_month(i64::from(year), i64::from(month));
852 if !(1..=9999).contains(&year) || days == 0 || day == 0 || i64::from(day) > days {
853 return Err(DataCatalogError::InvalidDate { year, month, day });
854 }
855 Ok(Self { year, month, day })
856 }
857
858 pub fn from_gps_week_day(week: u32, day_of_week: u8) -> Result<Self, DataCatalogError> {
860 if day_of_week > 6 {
861 return Err(DataCatalogError::InvalidGpsDayOfWeek(day_of_week));
862 }
863 let epoch_jdn =
864 week_epoch_julian_day_number(TimeScale::Gpst).expect("GPST has a week-numbering epoch");
865 let offset_days = i64::from(week)
866 .checked_mul(7)
867 .and_then(|days| days.checked_add(i64::from(day_of_week)))
868 .ok_or(DataCatalogError::DateOutOfRange)?;
869 product_date_from_jdn(
870 epoch_jdn
871 .checked_add(offset_days)
872 .ok_or(DataCatalogError::DateOutOfRange)?,
873 )
874 }
875
876 pub fn gps_week(self) -> Result<u32, DataCatalogError> {
878 week_from_calendar(
879 TimeScale::Gpst,
880 i64::from(self.year),
881 i64::from(self.month),
882 i64::from(self.day),
883 )
884 .ok_or(DataCatalogError::DateBeforeGpsEpoch(self))
885 }
886
887 #[must_use]
889 pub fn day_of_year(self) -> u16 {
890 day_of_year_int(self.year, i32::from(self.month), i32::from(self.day)) as u16
891 }
892
893 fn add_days(self, days: i64) -> Result<Self, DataCatalogError> {
894 product_date_from_jdn(
895 self.julian_day_number()
896 .checked_add(days)
897 .ok_or(DataCatalogError::DateOutOfRange)?,
898 )
899 }
900
901 fn julian_day_number(self) -> i64 {
902 julian_day_number(self.year, i32::from(self.month), i32::from(self.day))
903 }
904}
905
906impl fmt::Display for ProductDate {
907 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
908 write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
909 }
910}
911
912#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
914pub struct ProductDateTime {
915 pub date: ProductDate,
917 pub hour: u8,
919 pub minute: u8,
921 pub second: u8,
923}
924
925impl ProductDateTime {
926 pub fn new(
928 date: ProductDate,
929 hour: u8,
930 minute: u8,
931 second: u8,
932 ) -> Result<Self, DataCatalogError> {
933 if hour > 23 || minute > 59 || second > 59 {
934 return Err(DataCatalogError::InvalidDateTime {
935 hour,
936 minute,
937 second,
938 });
939 }
940 Ok(Self {
941 date,
942 hour,
943 minute,
944 second,
945 })
946 }
947
948 fn ordering_minutes(self) -> i64 {
949 self.date.julian_day_number() * 1_440 + i64::from(self.hour) * 60 + i64::from(self.minute)
950 }
951}
952
953#[derive(Debug, Clone, PartialEq, Eq, Hash)]
955pub struct UltraIssue {
956 pub date: ProductDate,
958 pub issue: String,
960}
961
962impl UltraIssue {
963 pub fn new(date: ProductDate, issue: &str) -> Result<Self, DataCatalogError> {
965 validate_issue(issue)?;
966 Ok(Self {
967 date,
968 issue: issue.to_string(),
969 })
970 }
971}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
975pub struct ProductSpec {
976 pub center: AnalysisCenter,
978 pub product_type: ProductType,
980 pub date: ProductDate,
982 pub sample: String,
984 pub issue: Option<String>,
986}
987
988impl ProductSpec {
989 pub fn new(
991 center: AnalysisCenter,
992 product_type: ProductType,
993 date: ProductDate,
994 sample: &str,
995 issue: Option<&str>,
996 ) -> Result<Self, DataCatalogError> {
997 validate_product(center, product_type, sample, issue)?;
998 Ok(Self {
999 center,
1000 product_type,
1001 date,
1002 sample: sample.to_string(),
1003 issue: issue.map(ToOwned::to_owned),
1004 })
1005 }
1006
1007 pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1009 self.date.gps_week()
1010 }
1011
1012 #[must_use]
1014 pub fn day_of_year(&self) -> u16 {
1015 self.date.day_of_year()
1016 }
1017
1018 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1020 let convention = validate_product(
1021 self.center,
1022 self.product_type,
1023 &self.sample,
1024 self.issue.as_deref(),
1025 )?;
1026 let descriptor = product_type_convention(self.product_type);
1027 Ok(match descriptor.kind {
1028 ProductFilenameKind::Sampled => format!(
1029 "{}_{}_{}_{}_{}.{}",
1030 convention.token,
1031 date_block(self.date, self.issue.as_deref()),
1032 convention.span,
1033 self.sample,
1034 descriptor.content_code,
1035 descriptor.extension
1036 ),
1037 ProductFilenameKind::Nav => format!(
1038 "{}_R_{}_{}_{}.{}",
1039 convention.token,
1040 date_block(self.date, None),
1041 convention.span,
1042 descriptor.content_code,
1043 descriptor.extension
1044 ),
1045 })
1046 }
1047
1048 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1050 let convention = validate_product(
1051 self.center,
1052 self.product_type,
1053 &self.sample,
1054 self.issue.as_deref(),
1055 )?;
1056 let entry = center_catalog(self.center).expect("catalog entry exists for enum variant");
1057 let filename = self.canonical_filename()?;
1058 Ok(format!(
1059 "{}/{}/{}{}",
1060 entry.root_url,
1061 dir_path(convention.layout, self.date)?,
1062 filename,
1063 convention.compression.suffix()
1064 ))
1065 }
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq)]
1070pub struct StationObservationSpec {
1071 pub station: String,
1073 pub date: ProductDate,
1075 pub sample: String,
1077}
1078
1079impl StationObservationSpec {
1080 pub fn new(station: &str, date: ProductDate, sample: &str) -> Result<Self, DataCatalogError> {
1082 validate_station(station)?;
1083 validate_sample(sample)?;
1084 Ok(Self {
1085 station: station.to_string(),
1086 date,
1087 sample: sample.to_string(),
1088 })
1089 }
1090
1091 pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1093 station_obs_filename(&self.station, self.date, &self.sample)
1094 }
1095
1096 pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1098 station_obs_url(&self.station, self.date, &self.sample)
1099 }
1100}
1101
1102#[must_use]
1104pub const fn catalog() -> &'static [CenterCatalogEntry] {
1105 &CATALOG
1106}
1107
1108#[must_use]
1110pub const fn centers() -> &'static [AnalysisCenter] {
1111 &CENTER_ORDER
1112}
1113
1114#[must_use]
1116pub const fn product_types() -> &'static [ProductTypeConvention] {
1117 &PRODUCT_TYPE_CONVENTIONS
1118}
1119
1120#[must_use]
1122pub const fn allowed_hosts() -> &'static [&'static str] {
1123 &ALLOWED_HOSTS
1124}
1125
1126#[must_use]
1128pub const fn skadi_source_entry() -> TerrainSourceEntry {
1129 SKADI_SOURCE
1130}
1131
1132pub fn skadi_tile_id(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1134 validate_terrain_tile_index(lat_index, lon_index)?;
1135 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1136 let lon_hemi = if lon_index >= 0 { 'E' } else { 'W' };
1137 Ok(format!(
1138 "{lat_hemi}{:02}{lon_hemi}{:03}",
1139 lat_index.abs(),
1140 lon_index.abs()
1141 ))
1142}
1143
1144pub fn skadi_band(lat_index: i32) -> Result<String, DataCatalogError> {
1146 validate_terrain_lat_index(lat_index)?;
1147 let lat_hemi = if lat_index >= 0 { 'N' } else { 'S' };
1148 Ok(format!("{lat_hemi}{:02}", lat_index.abs()))
1149}
1150
1151pub fn skadi_archive_url(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1153 let band = skadi_band(lat_index)?;
1154 let tile_id = skadi_tile_id(lat_index, lon_index)?;
1155 Ok(format!(
1156 "{}/skadi/{}/{}.hgt{}",
1157 SKADI_SOURCE.root_url,
1158 band,
1159 tile_id,
1160 SKADI_SOURCE.compression.suffix()
1161 ))
1162}
1163
1164pub fn dted_tile_filename(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1166 validate_terrain_tile_index(lat_index, lon_index)?;
1167 Ok(format!(
1168 "{}_{}{}",
1169 terrain::format_lat(lat_index),
1170 terrain::format_lon(lon_index),
1171 terrain::DTED_SUFFIX
1172 ))
1173}
1174
1175pub fn dted_block_dir(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1177 validate_terrain_tile_index(lat_index, lon_index)?;
1178 Ok(terrain::terrain_block_dir(lat_index, lon_index))
1179}
1180
1181pub fn dted_cache_relpath(lat_index: i32, lon_index: i32) -> Result<String, DataCatalogError> {
1183 Ok(format!(
1184 "{}/{}",
1185 dted_block_dir(lat_index, lon_index)?,
1186 dted_tile_filename(lat_index, lon_index)?
1187 ))
1188}
1189
1190pub fn parse_skadi_tile_id(id: &str) -> Result<(i32, i32), DataCatalogError> {
1192 let bytes = id.as_bytes();
1193 if bytes.len() != 7
1194 || !matches!(bytes[0], b'N' | b'S')
1195 || !matches!(bytes[3], b'E' | b'W')
1196 || !bytes[1..3].iter().all(u8::is_ascii_digit)
1197 || !bytes[4..7].iter().all(u8::is_ascii_digit)
1198 {
1199 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1200 }
1201
1202 let lat_abs = id[1..3]
1203 .parse::<i32>()
1204 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1205 let lon_abs = id[4..7]
1206 .parse::<i32>()
1207 .map_err(|_| DataCatalogError::InvalidTileId(id.to_string()))?;
1208 if (bytes[0] == b'S' && lat_abs == 0) || (bytes[3] == b'W' && lon_abs == 0) {
1209 return Err(DataCatalogError::InvalidTileId(id.to_string()));
1210 }
1211
1212 let lat_index = if bytes[0] == b'N' { lat_abs } else { -lat_abs };
1213 let lon_index = if bytes[3] == b'E' { lon_abs } else { -lon_abs };
1214 validate_terrain_tile_index(lat_index, lon_index)?;
1215 Ok((lat_index, lon_index))
1216}
1217
1218pub fn terrain_tile_index(lat_deg: f64, lon_deg: f64) -> Result<(i32, i32), DataCatalogError> {
1220 if !lat_deg.is_finite()
1221 || !lon_deg.is_finite()
1222 || !(MIN_TERRAIN_LAT_DEG..=MAX_TERRAIN_LAT_DEG).contains(&lat_deg)
1223 || !(MIN_TERRAIN_LON_DEG..=MAX_TERRAIN_LON_DEG).contains(&lon_deg)
1224 {
1225 return Err(DataCatalogError::InvalidCoordinate {
1226 lat_deg_bits: lat_deg.to_bits(),
1227 lon_deg_bits: lon_deg.to_bits(),
1228 });
1229 }
1230
1231 let (mut lat_index, mut lon_index) = terrain::terrain_grid(lon_deg, lat_deg);
1232 if lat_index == MAX_TERRAIN_LAT_DEG as i32 {
1233 lat_index = MAX_TERRAIN_LAT_INDEX;
1234 }
1235 if lon_index == MAX_TERRAIN_LON_DEG as i32 {
1236 lon_index = MAX_TERRAIN_LON_INDEX;
1237 }
1238 validate_terrain_tile_index(lat_index, lon_index)?;
1239 Ok((lat_index, lon_index))
1240}
1241
1242pub fn hgt_to_dted(
1250 lat_index: i32,
1251 lon_index: i32,
1252 hgt: &[u8],
1253) -> Result<Vec<u8>, HgtConversionError> {
1254 validate_hgt_tile_index(lat_index, lon_index)?;
1255 if hgt.len() != SRTM1_HGT_LEN {
1256 return Err(HgtConversionError::BadLength {
1257 expected: SRTM1_HGT_LEN,
1258 got: hgt.len(),
1259 });
1260 }
1261
1262 let mut out = vec![b' '; DTED_SRTM1_LEN];
1263 out[0..4].copy_from_slice(b"UHL1");
1264 out[4..12].copy_from_slice(dted_coord_field(lon_index, true).as_bytes());
1265 out[12..20].copy_from_slice(dted_coord_field(lat_index, false).as_bytes());
1266 out[47..51].copy_from_slice(b"3601");
1267 out[51..55].copy_from_slice(b"3601");
1268
1269 for lon_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1270 let block_start = terrain::DATA_OFFSET + lon_posting * DTED_SRTM1_DATA_BLOCK_LEN;
1271 let checksum_start = block_start + DTED_SRTM1_DATA_BLOCK_LEN - 4;
1272 out[block_start] = terrain::DATA_SENTINEL;
1273
1274 let count = (lon_posting as u32).to_be_bytes();
1275 out[block_start + 1..block_start + 4].copy_from_slice(&count[1..4]);
1276 out[block_start + 4..block_start + 6].copy_from_slice(&(lon_posting as u16).to_be_bytes());
1277 out[block_start + 6..block_start + 8].copy_from_slice(&0u16.to_be_bytes());
1278
1279 for lat_posting in 0..SRTM1_POSTINGS_PER_AXIS {
1280 let hgt_row = SRTM1_POSTINGS_PER_AXIS - 1 - lat_posting;
1281 let hgt_sample_start = 2 * (hgt_row * SRTM1_POSTINGS_PER_AXIS + lon_posting);
1282 let sample = i16::from_be_bytes([hgt[hgt_sample_start], hgt[hgt_sample_start + 1]]);
1283 let encoded = encode_dted_signed_magnitude(sample).to_be_bytes();
1284 let dted_sample_start = block_start + 8 + 2 * lat_posting;
1285 out[dted_sample_start..dted_sample_start + 2].copy_from_slice(&encoded);
1286 }
1287
1288 let checksum = out[block_start..checksum_start]
1289 .iter()
1290 .fold(0i32, |acc, byte| acc + i32::from(*byte));
1291 out[checksum_start..checksum_start + 4].copy_from_slice(&checksum.to_be_bytes());
1292 }
1293
1294 debug_assert_eq!(out.len(), 25_981_042);
1295 Ok(out)
1296}
1297
1298#[must_use]
1300pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1301 &NO_OPEN_MIRRORS
1302}
1303
1304pub fn open_mirror(
1306 center: AnalysisCenter,
1307 product_type: ProductType,
1308) -> Result<(), DataCatalogError> {
1309 open_mirror_code(center.code(), product_type.code())
1310}
1311
1312pub fn open_mirror_code(center: &str, product_type: &str) -> Result<(), DataCatalogError> {
1314 if NO_OPEN_MIRRORS
1315 .iter()
1316 .any(|entry| entry.center == center && entry.product_type == product_type)
1317 {
1318 Err(DataCatalogError::NoOpenMirror {
1319 center: center.to_string(),
1320 product_type: product_type.to_string(),
1321 })
1322 } else {
1323 Ok(())
1324 }
1325}
1326
1327#[must_use]
1329pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1330 CATALOG.iter().find(|entry| entry.center == center)
1331}
1332
1333pub fn product_convention(
1335 center: AnalysisCenter,
1336 product_type: ProductType,
1337) -> Result<&'static CenterProductConvention, DataCatalogError> {
1338 open_mirror(center, product_type)?;
1339 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1340 entry
1341 .products
1342 .iter()
1343 .find(|product| product.product_type == product_type)
1344 .ok_or(DataCatalogError::UnsupportedProduct {
1345 center,
1346 product_type,
1347 })
1348}
1349
1350pub fn default_sample(
1352 center: AnalysisCenter,
1353 product_type: ProductType,
1354) -> Result<&'static str, DataCatalogError> {
1355 Ok(product_convention(center, product_type)?.default_sample)
1356}
1357
1358pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1360 date.gps_week()
1361}
1362
1363#[must_use]
1365pub fn day_of_year(date: ProductDate) -> u16 {
1366 date.day_of_year()
1367}
1368
1369pub fn product(
1371 center: AnalysisCenter,
1372 product_type: ProductType,
1373 date: ProductDate,
1374 sample: Option<&str>,
1375 issue: Option<&str>,
1376) -> Result<ProductSpec, DataCatalogError> {
1377 let sample = match sample {
1378 Some(sample) => sample,
1379 None => default_sample(center, product_type)?,
1380 };
1381 ProductSpec::new(center, product_type, date, sample, issue)
1382}
1383
1384pub fn canonical_filename(
1386 center: AnalysisCenter,
1387 product_type: ProductType,
1388 date: ProductDate,
1389 sample: Option<&str>,
1390 issue: Option<&str>,
1391) -> Result<String, DataCatalogError> {
1392 product(center, product_type, date, sample, issue)?.canonical_filename()
1393}
1394
1395pub fn archive_url(
1397 center: AnalysisCenter,
1398 product_type: ProductType,
1399 date: ProductDate,
1400 sample: Option<&str>,
1401 issue: Option<&str>,
1402) -> Result<String, DataCatalogError> {
1403 product(center, product_type, date, sample, issue)?.archive_url()
1404}
1405
1406pub fn mgex_clk(
1408 center: AnalysisCenter,
1409 date: ProductDate,
1410 sample: Option<&str>,
1411) -> Result<ProductSpec, DataCatalogError> {
1412 product(center, ProductType::Clk, date, sample, None)
1413}
1414
1415pub fn mgex_nav(
1417 center: AnalysisCenter,
1418 date: ProductDate,
1419 sample: Option<&str>,
1420) -> Result<ProductSpec, DataCatalogError> {
1421 product(center, ProductType::Nav, date, sample, None)
1422}
1423
1424pub fn mgex_ionex(
1426 center: AnalysisCenter,
1427 date: ProductDate,
1428 sample: Option<&str>,
1429) -> Result<ProductSpec, DataCatalogError> {
1430 product(center, ProductType::Ionex, date, sample, None)
1431}
1432
1433pub fn rapid_ionex(
1435 date: ProductDate,
1436 sample: Option<&str>,
1437) -> Result<ProductSpec, DataCatalogError> {
1438 product(
1439 AnalysisCenter::CodRap,
1440 ProductType::Ionex,
1441 date,
1442 sample,
1443 None,
1444 )
1445}
1446
1447#[must_use]
1449pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1450 match center {
1451 AnalysisCenter::CodPrd2 => 1,
1452 _ => 0,
1453 }
1454}
1455
1456pub fn predicted_ionex(
1458 center: AnalysisCenter,
1459 date: ProductDate,
1460 sample: Option<&str>,
1461) -> Result<ProductSpec, DataCatalogError> {
1462 match center {
1463 AnalysisCenter::CodPrd1 | AnalysisCenter::CodPrd2 => {
1464 let target = date.add_days(predicted_day_offset(center))?;
1465 product(center, ProductType::Ionex, target, sample, None)
1466 }
1467 other => Err(DataCatalogError::UnsupportedProduct {
1468 center: other,
1469 product_type: ProductType::Ionex,
1470 }),
1471 }
1472}
1473
1474pub fn mgex_sp3(
1476 center: AnalysisCenter,
1477 date: ProductDate,
1478 sample: Option<&str>,
1479) -> Result<ProductSpec, DataCatalogError> {
1480 product(center, ProductType::Sp3, date, sample, None)
1481}
1482
1483pub fn ops_ultra_sp3(
1485 center: AnalysisCenter,
1486 date: ProductDate,
1487 sample: Option<&str>,
1488 issue: Option<&str>,
1489) -> Result<ProductSpec, DataCatalogError> {
1490 let issue = issue.unwrap_or("0000");
1491 product(center, ProductType::Sp3, date, sample, Some(issue))
1492}
1493
1494pub fn ops_ultra_clk(
1496 center: AnalysisCenter,
1497 date: ProductDate,
1498 sample: Option<&str>,
1499 issue: Option<&str>,
1500) -> Result<ProductSpec, DataCatalogError> {
1501 let issue = issue.unwrap_or("0000");
1502 product(center, ProductType::Clk, date, sample, Some(issue))
1503}
1504
1505pub fn latest_ops_ultra_sp3(
1507 center: AnalysisCenter,
1508 target: ProductDateTime,
1509 sample: Option<&str>,
1510 available_issues: Option<&[UltraIssue]>,
1511) -> Result<ProductSpec, DataCatalogError> {
1512 let selected = latest_ultra_issue(center, target, available_issues)?;
1513 ops_ultra_sp3(center, selected.date, sample, Some(&selected.issue))
1514}
1515
1516pub fn ultra_issue_candidates(
1518 center: AnalysisCenter,
1519 target: ProductDateTime,
1520) -> Result<Vec<UltraIssue>, DataCatalogError> {
1521 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1522 let _ = product_convention(center, ProductType::Sp3)?;
1523 if entry.issues.is_empty() {
1524 return Err(DataCatalogError::UnsupportedProduct {
1525 center,
1526 product_type: ProductType::Sp3,
1527 });
1528 }
1529
1530 let mut candidates = Vec::new();
1531 for date in [target.date, target.date.add_days(-1)?] {
1532 for issue in entry.issues.iter().rev() {
1533 if issue_ordering_minutes(date, issue)? <= target.ordering_minutes() {
1534 candidates.push(UltraIssue::new(date, issue)?);
1535 }
1536 }
1537 }
1538 Ok(candidates)
1539}
1540
1541pub fn latest_ultra_issue(
1543 center: AnalysisCenter,
1544 target: ProductDateTime,
1545 available_issues: Option<&[UltraIssue]>,
1546) -> Result<UltraIssue, DataCatalogError> {
1547 let candidates = ultra_issue_candidates(center, target)?;
1548 if candidates.is_empty() {
1549 return Err(DataCatalogError::NoUltraIssue);
1550 }
1551 if let Some(available) = available_issues {
1552 candidates
1553 .into_iter()
1554 .find(|candidate| {
1555 available
1556 .iter()
1557 .any(|issue| issue.date == candidate.date && issue.issue == candidate.issue)
1558 })
1559 .ok_or(DataCatalogError::NoAvailableUltraIssue)
1560 } else {
1561 Ok(candidates[0].clone())
1562 }
1563}
1564
1565pub fn gim_date_candidates(
1567 center: AnalysisCenter,
1568 target: ProductDate,
1569 lookback: u32,
1570) -> Result<Vec<ProductDate>, DataCatalogError> {
1571 let _ = product_convention(center, ProductType::Ionex)?;
1572 let base = target.add_days(predicted_day_offset(center))?;
1573 let mut out = Vec::with_capacity(usize::try_from(lookback).unwrap_or(usize::MAX));
1574 for back in 0..=lookback {
1575 out.push(base.add_days(-i64::from(back))?);
1576 }
1577 Ok(out)
1578}
1579
1580pub fn station_obs(
1582 station: &str,
1583 date: ProductDate,
1584 sample: Option<&str>,
1585) -> Result<StationObservationSpec, DataCatalogError> {
1586 StationObservationSpec::new(station, date, sample.unwrap_or("30S"))
1587}
1588
1589pub fn station_obs_filename(
1591 station: &str,
1592 date: ProductDate,
1593 sample: &str,
1594) -> Result<String, DataCatalogError> {
1595 validate_station(station)?;
1596 validate_sample(sample)?;
1597 Ok(format!(
1598 "{}_R_{}_01D_{}_MO.crx",
1599 station,
1600 date_block(date, None),
1601 sample
1602 ))
1603}
1604
1605pub fn station_obs_url(
1607 station: &str,
1608 date: ProductDate,
1609 sample: &str,
1610) -> Result<String, DataCatalogError> {
1611 let filename = station_obs_filename(station, date, sample)?;
1612 Ok(format!(
1613 "https://igs.bkg.bund.de/root_ftp/IGS/{}/{}.gz",
1614 dir_path(ArchiveLayout::BkgObsYearDoy, date)?,
1615 filename
1616 ))
1617}
1618
1619#[must_use]
1621pub const fn station_obs_protocol() -> ArchiveProtocol {
1622 ArchiveProtocol::Https
1623}
1624
1625fn validate_terrain_lat_index(lat_index: i32) -> Result<(), DataCatalogError> {
1626 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index) {
1627 Ok(())
1628 } else {
1629 Err(DataCatalogError::InvalidTileIndex {
1630 lat_index,
1631 lon_index: 0,
1632 })
1633 }
1634}
1635
1636fn validate_terrain_tile_index(lat_index: i32, lon_index: i32) -> Result<(), DataCatalogError> {
1637 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1638 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1639 {
1640 Ok(())
1641 } else {
1642 Err(DataCatalogError::InvalidTileIndex {
1643 lat_index,
1644 lon_index,
1645 })
1646 }
1647}
1648
1649fn validate_hgt_tile_index(lat_index: i32, lon_index: i32) -> Result<(), HgtConversionError> {
1650 if (MIN_TERRAIN_LAT_INDEX..=MAX_TERRAIN_LAT_INDEX).contains(&lat_index)
1651 && (MIN_TERRAIN_LON_INDEX..=MAX_TERRAIN_LON_INDEX).contains(&lon_index)
1652 {
1653 Ok(())
1654 } else {
1655 Err(HgtConversionError::InvalidTileIndex {
1656 lat_index,
1657 lon_index,
1658 })
1659 }
1660}
1661
1662fn dted_coord_field(index: i32, is_longitude: bool) -> String {
1663 let hemi = match (is_longitude, index >= 0) {
1664 (true, true) => 'E',
1665 (true, false) => 'W',
1666 (false, true) => 'N',
1667 (false, false) => 'S',
1668 };
1669 format!("{:03}0000{hemi}", index.abs())
1670}
1671
1672fn encode_dted_signed_magnitude(sample: i16) -> u16 {
1673 if sample == i16::MIN {
1674 0
1675 } else if sample >= 0 {
1676 sample as u16
1677 } else {
1678 0x8000 | (-i32::from(sample) as u16)
1679 }
1680}
1681
1682fn product_type_convention(product_type: ProductType) -> &'static ProductTypeConvention {
1683 PRODUCT_TYPE_CONVENTIONS
1684 .iter()
1685 .find(|descriptor| descriptor.product_type == product_type)
1686 .expect("product descriptor exists for enum variant")
1687}
1688
1689fn validate_product(
1690 center: AnalysisCenter,
1691 product_type: ProductType,
1692 sample: &str,
1693 issue: Option<&str>,
1694) -> Result<&'static CenterProductConvention, DataCatalogError> {
1695 let convention = product_convention(center, product_type)?;
1696 validate_sample(sample)?;
1697 validate_issue_for_center(center, issue)?;
1698 Ok(convention)
1699}
1700
1701fn validate_issue_for_center(
1702 center: AnalysisCenter,
1703 issue: Option<&str>,
1704) -> Result<(), DataCatalogError> {
1705 let entry = center_catalog(center).expect("catalog entry exists for enum variant");
1706 match (entry.issues.is_empty(), issue) {
1707 (true, None) => Ok(()),
1708 (true, Some(_)) => Err(DataCatalogError::UnexpectedIssue { center }),
1709 (false, None) => Err(DataCatalogError::MissingIssue { center }),
1710 (false, Some(issue)) => {
1711 validate_issue(issue)?;
1712 if entry.issues.contains(&issue) {
1713 Ok(())
1714 } else {
1715 Err(DataCatalogError::UnsupportedIssue {
1716 center,
1717 issue: issue.to_string(),
1718 })
1719 }
1720 }
1721 }
1722}
1723
1724fn validate_sample(sample: &str) -> Result<(), DataCatalogError> {
1725 let bytes = sample.as_bytes();
1726 let valid = bytes.len() == 3
1727 && bytes[0].is_ascii_digit()
1728 && bytes[1].is_ascii_digit()
1729 && bytes[2].is_ascii_uppercase();
1730 if valid {
1731 Ok(())
1732 } else {
1733 Err(DataCatalogError::InvalidSample(sample.to_string()))
1734 }
1735}
1736
1737fn validate_issue(issue: &str) -> Result<(), DataCatalogError> {
1738 let bytes = issue.as_bytes();
1739 let valid_digits = bytes.len() == 4 && bytes.iter().all(u8::is_ascii_digit);
1740 if !valid_digits {
1741 return Err(DataCatalogError::InvalidIssue(issue.to_string()));
1742 }
1743 let hour = issue[0..2]
1744 .parse::<u8>()
1745 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1746 let minute = issue[2..4]
1747 .parse::<u8>()
1748 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1749 if hour <= 23 && minute <= 59 {
1750 Ok(())
1751 } else {
1752 Err(DataCatalogError::InvalidIssue(issue.to_string()))
1753 }
1754}
1755
1756fn validate_station(station: &str) -> Result<(), DataCatalogError> {
1757 let bytes = station.as_bytes();
1758 let valid = bytes.len() == 9
1759 && bytes
1760 .iter()
1761 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit());
1762 if valid {
1763 Ok(())
1764 } else {
1765 Err(DataCatalogError::InvalidStation(station.to_string()))
1766 }
1767}
1768
1769fn issue_minutes(issue: &str) -> Result<u16, DataCatalogError> {
1770 validate_issue(issue)?;
1771 let hour = issue[0..2]
1772 .parse::<u16>()
1773 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1774 let minute = issue[2..4]
1775 .parse::<u16>()
1776 .map_err(|_| DataCatalogError::InvalidIssue(issue.to_string()))?;
1777 Ok(hour * 60 + minute)
1778}
1779
1780fn issue_ordering_minutes(date: ProductDate, issue: &str) -> Result<i64, DataCatalogError> {
1781 Ok(date.julian_day_number() * 1_440 + i64::from(issue_minutes(issue)?))
1782}
1783
1784fn date_block(date: ProductDate, issue: Option<&str>) -> String {
1785 format!(
1786 "{}{:03}{}",
1787 date.year,
1788 date.day_of_year(),
1789 issue.unwrap_or("0000")
1790 )
1791}
1792
1793fn dir_path(layout: ArchiveLayout, date: ProductDate) -> Result<String, DataCatalogError> {
1794 Ok(match layout {
1795 ArchiveLayout::GfzRapidWeek => format!("rapid/w{}", date.gps_week()?),
1796 ArchiveLayout::GfzUltraWeek => format!("ultra/w{}", date.gps_week()?),
1797 ArchiveLayout::GpsWeek => date.gps_week()?.to_string(),
1798 ArchiveLayout::BkgProductsWeek => format!("products/{}", date.gps_week()?),
1799 ArchiveLayout::BkgBrdcYearDoy => {
1800 format!("BRDC/{}/{:03}", date.year, date.day_of_year())
1801 }
1802 ArchiveLayout::BkgObsYearDoy => format!("obs/{}/{:03}", date.year, date.day_of_year()),
1803 ArchiveLayout::AiubCodeMgexYear => format!("CODE_MGEX/CODE/{}", date.year),
1804 ArchiveLayout::AiubCodeYear => format!("CODE/{}", date.year),
1805 ArchiveLayout::AiubCodeRoot => "CODE".to_string(),
1806 })
1807}
1808
1809fn product_date_from_jdn(jdn: i64) -> Result<ProductDate, DataCatalogError> {
1810 let (year, month, day) = civil_from_julian_day_number(jdn);
1811 let year = i32::try_from(year).map_err(|_| DataCatalogError::DateOutOfRange)?;
1812 let month = u8::try_from(month).map_err(|_| DataCatalogError::DateOutOfRange)?;
1813 let day = u8::try_from(day).map_err(|_| DataCatalogError::DateOutOfRange)?;
1814 ProductDate::new(year, month, day).map_err(|_| DataCatalogError::DateOutOfRange)
1815}