Skip to main content

sidereon_core/
data.rs

1//! Data product filename, cache path, and archive URL catalog.
2//!
3//! This module is sans-IO: it performs no network access, reads no files, and
4//! writes no cache entries. It only turns cataloged product inputs into
5//! canonical archive filenames, URLs, cache relative paths, and deterministic
6//! converted bytes for pure terrain ingestion.
7
8use 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/// Analysis-center code supported by the data-product catalog.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub enum AnalysisCenter {
20    /// `igs`.
21    Igs,
22    /// `cod_rap`.
23    CodRap,
24    /// `cod_prd1`.
25    CodPrd1,
26    /// `cod_prd2`.
27    CodPrd2,
28    /// `esa`.
29    Esa,
30    /// `cod`.
31    Cod,
32    /// `gfz`.
33    Gfz,
34    /// `igs_ult`.
35    IgsUlt,
36    /// `cod_ult`.
37    CodUlt,
38    /// `esa_ult`.
39    EsaUlt,
40    /// `gfz_ult`.
41    GfzUlt,
42}
43
44impl AnalysisCenter {
45    /// The lower-case catalog code.
46    #[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    /// Parse a lower-case catalog code.
64    #[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/// Product type supported by the data-product catalog.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
99pub enum ProductType {
100    /// Precise orbit SP3.
101    Sp3,
102    /// RINEX clock.
103    Clk,
104    /// Merged broadcast navigation.
105    Nav,
106    /// IONEX global ionosphere map.
107    Ionex,
108}
109
110impl ProductType {
111    /// The lower-case product code.
112    #[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    /// Parse a lower-case product code.
123    #[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/// CelesTrak space-weather product served by the data catalog.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
151pub enum SpaceWeatherProduct {
152    /// `SW-All.csv`: full history plus daily and monthly predictions.
153    All,
154    /// `SW-Last5Years.csv`: observed rolling window.
155    Last5Years,
156}
157
158impl SpaceWeatherProduct {
159    /// The lower-case catalog code.
160    #[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    /// Parse a lower-case catalog code.
169    #[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/// Archive transport protocol recorded by the catalog.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
195pub enum ArchiveProtocol {
196    /// HTTP.
197    Http,
198    /// HTTPS.
199    Https,
200}
201
202impl ArchiveProtocol {
203    /// URI scheme text.
204    #[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/// Archive compression for a cataloged product.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum ArchiveCompression {
216    /// Archive URL has a `.gz` suffix.
217    Gzip,
218    /// Archive URL is the plain product filename.
219    None,
220}
221
222impl ArchiveCompression {
223    /// Catalog text for the compression format.
224    #[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/// Directory layout used below an archive root.
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
242pub enum ArchiveLayout {
243    /// `rapid/w<gps-week>`.
244    GfzRapidWeek,
245    /// `ultra/w<gps-week>`.
246    GfzUltraWeek,
247    /// `<gps-week>`.
248    GpsWeek,
249    /// `products/<gps-week>`.
250    BkgProductsWeek,
251    /// `BRDC/<year>/<day-of-year>`.
252    BkgBrdcYearDoy,
253    /// `obs/<year>/<day-of-year>`.
254    BkgObsYearDoy,
255    /// `CODE_MGEX/CODE/<year>`.
256    AiubCodeMgexYear,
257    /// `CODE/<year>`.
258    AiubCodeYear,
259    /// `CODE`.
260    AiubCodeRoot,
261}
262
263/// Product filename convention.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum ProductFilenameKind {
266    /// `TOKEN_DATE_LEN_SAMPLE_CODE.EXT`.
267    Sampled,
268    /// `TOKEN_R_DATE_LEN_CODE.ext`.
269    Nav,
270}
271
272/// Product-type filename convention.
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub struct ProductTypeConvention {
275    /// Product type.
276    pub product_type: ProductType,
277    /// Filename content code, for example `ORB`.
278    pub content_code: &'static str,
279    /// Filename extension, preserving archive case.
280    pub extension: &'static str,
281    /// Filename convention.
282    pub kind: ProductFilenameKind,
283}
284
285/// Per-center convention for one product type.
286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub struct CenterProductConvention {
288    /// Product type.
289    pub product_type: ProductType,
290    /// IGS long-name token prefix.
291    pub token: &'static str,
292    /// Directory layout under the archive root.
293    pub layout: ArchiveLayout,
294    /// Product span token.
295    pub span: &'static str,
296    /// Default sampling token.
297    pub default_sample: &'static str,
298    /// Archive compression.
299    pub compression: ArchiveCompression,
300}
301
302/// Static catalog entry for one analysis-center code.
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub struct CenterCatalogEntry {
305    /// Analysis-center code.
306    pub center: AnalysisCenter,
307    /// Lower-case catalog code.
308    pub code: &'static str,
309    /// Archive URI scheme.
310    pub protocol: ArchiveProtocol,
311    /// Archive host.
312    pub host: &'static str,
313    /// Archive root URL without trailing slash.
314    pub root_url: &'static str,
315    /// Product conventions served by this center.
316    pub products: &'static [CenterProductConvention],
317    /// Valid issue times for sub-daily products.
318    pub issues: &'static [&'static str],
319}
320
321/// Static catalog entry for one terrain source.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub struct TerrainSourceEntry {
324    /// Archive URI scheme.
325    pub protocol: ArchiveProtocol,
326    /// Archive host.
327    pub host: &'static str,
328    /// Archive compression.
329    pub compression: ArchiveCompression,
330    /// Archive root URL without trailing slash.
331    pub root_url: &'static str,
332}
333
334/// Static catalog entry for the CelesTrak space-weather source.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub struct SpaceWeatherSourceEntry {
337    /// Archive URI scheme.
338    pub protocol: ArchiveProtocol,
339    /// Archive host.
340    pub host: &'static str,
341    /// Archive compression.
342    pub compression: ArchiveCompression,
343    /// Archive root URL without trailing slash.
344    pub root_url: &'static str,
345}
346
347/// Product pair that is intentionally not offered because no open mirror exists.
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
349pub struct NoOpenMirrorProduct {
350    /// Analysis-center code.
351    pub center: &'static str,
352    /// Product type code.
353    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/// Error returned by the pure data-product catalog.
693#[derive(Debug, Clone, PartialEq, Eq)]
694pub enum DataCatalogError {
695    /// Unknown analysis-center code.
696    UnknownCenter(String),
697    /// Unknown product type code.
698    UnknownProductType(String),
699    /// The center does not serve the requested product type.
700    UnsupportedProduct {
701        /// Analysis center.
702        center: AnalysisCenter,
703        /// Product type.
704        product_type: ProductType,
705    },
706    /// The product has no verified anonymous HTTP(S) mirror.
707    NoOpenMirror {
708        /// Analysis-center code.
709        center: String,
710        /// Product type code.
711        product_type: String,
712    },
713    /// Bad civil date.
714    InvalidDate {
715        /// Year.
716        year: i32,
717        /// Month.
718        month: u8,
719        /// Day.
720        day: u8,
721    },
722    /// Date cannot be represented by this API.
723    DateOutOfRange,
724    /// Date precedes the GPS week epoch.
725    DateBeforeGpsEpoch(ProductDate),
726    /// GPS day-of-week must be `0..=6`.
727    InvalidGpsDayOfWeek(u8),
728    /// Sampling token is not `NNX` with an upper-case unit.
729    InvalidSample(String),
730    /// Issue time is malformed.
731    InvalidIssue(String),
732    /// The center requires an issue time.
733    MissingIssue {
734        /// Analysis center.
735        center: AnalysisCenter,
736    },
737    /// The center does not use issue times.
738    UnexpectedIssue {
739        /// Analysis center.
740        center: AnalysisCenter,
741    },
742    /// Issue time is valid text but not published by this center.
743    UnsupportedIssue {
744        /// Analysis center.
745        center: AnalysisCenter,
746        /// Issue time.
747        issue: String,
748    },
749    /// The target datetime was invalid.
750    InvalidDateTime {
751        /// Hour.
752        hour: u8,
753        /// Minute.
754        minute: u8,
755        /// Second.
756        second: u8,
757    },
758    /// No ultra-rapid issue exists at or before the requested target.
759    NoUltraIssue,
760    /// No available ultra-rapid issue exists at or before the requested target.
761    NoAvailableUltraIssue,
762    /// Station identifier is not a 9-character upper-case alphanumeric token.
763    InvalidStation(String),
764    /// Terrain lookup coordinate is non-finite or outside the reader range.
765    InvalidCoordinate {
766        /// Latitude as `f64::to_bits()`.
767        lat_deg_bits: u64,
768        /// Longitude as `f64::to_bits()`.
769        lon_deg_bits: u64,
770    },
771    /// Terrain tile index is outside the valid one-degree cell range.
772    InvalidTileIndex {
773        /// Latitude index.
774        lat_index: i32,
775        /// Longitude index.
776        lon_index: i32,
777    },
778    /// Skadi tile identifier is malformed.
779    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/// Error returned by SRTM HGT to DTED conversion.
848#[derive(Debug, Clone, PartialEq, Eq)]
849pub enum HgtConversionError {
850    /// The decompressed HGT payload is not the SRTM1 byte length.
851    BadLength {
852        /// Expected byte length.
853        expected: usize,
854        /// Actual byte length.
855        got: usize,
856    },
857    /// Terrain tile index is outside the valid one-degree cell range.
858    InvalidTileIndex {
859        /// Latitude index.
860        lat_index: i32,
861        /// Longitude index.
862        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/// Civil UTC date used by product archive names.
903#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
904pub struct ProductDate {
905    /// Year.
906    pub year: i32,
907    /// Month in `1..=12`.
908    pub month: u8,
909    /// Day of month.
910    pub day: u8,
911}
912
913impl ProductDate {
914    /// Build and validate a civil date.
915    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    /// Build a date from GPS week and day-of-week (`0` = Sunday).
924    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    /// GPS week for this date.
942    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    /// Day-of-year in `1..=366`.
953    #[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/// Civil UTC date and time used for ultra-rapid issue selection.
978#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
979pub struct ProductDateTime {
980    /// Date.
981    pub date: ProductDate,
982    /// Hour in `0..=23`.
983    pub hour: u8,
984    /// Minute in `0..=59`.
985    pub minute: u8,
986    /// Second in `0..=59`.
987    pub second: u8,
988}
989
990impl ProductDateTime {
991    /// Build and validate a civil date and time.
992    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/// Ultra-rapid issue date and `HHMM` issue time.
1019#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1020pub struct UltraIssue {
1021    /// Product date.
1022    pub date: ProductDate,
1023    /// Issue time.
1024    pub issue: String,
1025}
1026
1027impl UltraIssue {
1028    /// Build and validate an ultra-rapid issue.
1029    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/// A pure product specification that resolves to one archive filename and URL.
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct ProductSpec {
1041    /// Analysis center.
1042    pub center: AnalysisCenter,
1043    /// Product type.
1044    pub product_type: ProductType,
1045    /// Product date.
1046    pub date: ProductDate,
1047    /// Sampling token.
1048    pub sample: String,
1049    /// Optional issue time for ultra-rapid products.
1050    pub issue: Option<String>,
1051}
1052
1053impl ProductSpec {
1054    /// Build a product specification and validate it against the catalog.
1055    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    /// GPS week for the product date.
1073    pub fn gps_week(&self) -> Result<u32, DataCatalogError> {
1074        self.date.gps_week()
1075    }
1076
1077    /// Day-of-year for the product date.
1078    #[must_use]
1079    pub fn day_of_year(&self) -> u16 {
1080        self.date.day_of_year()
1081    }
1082
1083    /// Canonical IGS long-name filename without archive compression suffix.
1084    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    /// Full archive URL, including `.gz` when the cataloged archive is gzipped.
1114    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/// A pure station observation specification.
1134#[derive(Debug, Clone, PartialEq, Eq)]
1135pub struct StationObservationSpec {
1136    /// 9-character RINEX 3 site identifier.
1137    pub station: String,
1138    /// Observation date.
1139    pub date: ProductDate,
1140    /// Sampling token.
1141    pub sample: String,
1142}
1143
1144impl StationObservationSpec {
1145    /// Build and validate a daily station observation product.
1146    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    /// Canonical RINEX 3 CRINEX filename without archive compression suffix.
1157    pub fn canonical_filename(&self) -> Result<String, DataCatalogError> {
1158        station_obs_filename(&self.station, self.date, &self.sample)
1159    }
1160
1161    /// Full archive URL, including `.gz`.
1162    pub fn archive_url(&self) -> Result<String, DataCatalogError> {
1163        station_obs_url(&self.station, self.date, &self.sample)
1164    }
1165}
1166
1167/// Static catalog entries, in the same order as the binding data catalog.
1168#[must_use]
1169pub const fn catalog() -> &'static [CenterCatalogEntry] {
1170    &CATALOG
1171}
1172
1173/// Supported center codes, in catalog order.
1174#[must_use]
1175pub const fn centers() -> &'static [AnalysisCenter] {
1176    &CENTER_ORDER
1177}
1178
1179/// Supported product types.
1180#[must_use]
1181pub const fn product_types() -> &'static [ProductTypeConvention] {
1182    &PRODUCT_TYPE_CONVENTIONS
1183}
1184
1185/// Archive hosts present in the catalog.
1186#[must_use]
1187pub const fn allowed_hosts() -> &'static [&'static str] {
1188    &ALLOWED_HOSTS
1189}
1190
1191/// Catalog entry for the Skadi SRTM terrain source.
1192#[must_use]
1193pub const fn skadi_source_entry() -> TerrainSourceEntry {
1194    SKADI_SOURCE
1195}
1196
1197/// Catalog entry for the CelesTrak CSSI space-weather source.
1198#[must_use]
1199pub const fn space_weather_source_entry() -> SpaceWeatherSourceEntry {
1200    CELESTRAK_SPACE_WEATHER_SOURCE
1201}
1202
1203/// Filename for a CelesTrak space-weather product.
1204#[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/// Build the CelesTrak archive URL for a space-weather product.
1213#[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/// Build the cache relative path for a space-weather product.
1223#[must_use]
1224pub fn space_weather_cache_relpath(product: SpaceWeatherProduct) -> String {
1225    format!("space-weather/{}", space_weather_filename(product))
1226}
1227
1228/// Build the Skadi SRTM tile id, for example `N36W107`.
1229pub 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
1240/// Build the Skadi latitude band directory, for example `N36`.
1241pub 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
1247/// Build the Skadi SRTM archive URL for a tile.
1248pub 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
1260/// Build the DTED tile filename read by the terrain module.
1261pub 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
1271/// Build the DTED ten-degree cache block directory read by the terrain module.
1272pub 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
1277/// Build the DTED cache relative path read by the terrain module.
1278pub 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
1286/// Parse a Skadi SRTM tile id into `(lat_index, lon_index)`.
1287pub 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
1314/// Derive the terrain tile index covering a latitude/longitude coordinate.
1315pub 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
1338/// Convert decompressed SRTM1 HGT bytes into deterministic DTED `.dt2` bytes.
1339///
1340/// The HGT payload must be 3601 by 3601 big-endian `i16` samples in row-major
1341/// order. HGT rows run north to south; DTED data records are longitude columns
1342/// with postings south to north, so output posting `(i, j)` reads source sample
1343/// `hgt[r = 3600 - i][c = j]`. SRTM void samples (`-32768`) are written as sea
1344/// level (`0`) so the existing terrain reader returns `0` for those postings.
1345pub 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/// Product pairs intentionally withheld because no open mirror is known.
1395#[must_use]
1396pub const fn no_open_mirrors() -> &'static [NoOpenMirrorProduct] {
1397    &NO_OPEN_MIRRORS
1398}
1399
1400/// Confirm that a center/product pair has an open catalog mirror.
1401pub fn open_mirror(
1402    center: AnalysisCenter,
1403    product_type: ProductType,
1404) -> Result<(), DataCatalogError> {
1405    open_mirror_code(center.code(), product_type.code())
1406}
1407
1408/// Confirm that a center/product code pair is not in the no-open-mirror list.
1409pub 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/// Look up a center's static catalog entry.
1424#[must_use]
1425pub fn center_catalog(center: AnalysisCenter) -> Option<&'static CenterCatalogEntry> {
1426    CATALOG.iter().find(|entry| entry.center == center)
1427}
1428
1429/// Look up the convention for one center and product type.
1430pub 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
1446/// Default sampling token for a center/product pair.
1447pub 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
1454/// GPS week number for a product date.
1455pub fn gps_week(date: ProductDate) -> Result<u32, DataCatalogError> {
1456    date.gps_week()
1457}
1458
1459/// Day-of-year in `1..=366` for a product date.
1460#[must_use]
1461pub fn day_of_year(date: ProductDate) -> u16 {
1462    date.day_of_year()
1463}
1464
1465/// Build a product specification for any center/product/date combination.
1466pub 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
1480/// Build the canonical IGS long-name filename for a product.
1481pub 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
1491/// Build the full archive URL for a product.
1492pub 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
1502/// Build a clock product for a center and date.
1503pub 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
1511/// Build a merged broadcast-navigation product for a center and date.
1512pub 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
1520/// Build an IONEX product for a center and date.
1521pub 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
1529/// Build the CODE rapid IONEX product for a date.
1530pub 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/// Day offset for predicted IONEX aliases.
1544#[must_use]
1545pub const fn predicted_day_offset(center: AnalysisCenter) -> i64 {
1546    match center {
1547        AnalysisCenter::CodPrd2 => 1,
1548        _ => 0,
1549    }
1550}
1551
1552/// Build a CODE predicted IONEX product for a target date.
1553pub 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
1570/// Build an SP3 product for a center and date.
1571pub 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
1579/// Build an ultra-rapid OPS SP3 product for a date and issue time.
1580pub 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
1590/// Build an ultra-rapid OPS clock product for a date and issue time.
1591pub 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
1601/// Select the latest ultra-rapid OPS SP3 issue at or before a target time.
1602pub 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
1612/// Candidate ultra-rapid issues at or before a target time, newest first.
1613pub 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
1637/// Latest ultra-rapid issue at or before a target time.
1638pub 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
1661/// Candidate IONEX dates at or before a target date, newest first.
1662pub 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
1676/// Build a daily station observation product.
1677pub 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
1685/// Build the canonical RINEX 3 CRINEX filename for a daily station observation.
1686pub 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
1701/// Build the full BKG IGS archive URL for a daily station observation.
1702pub 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/// The transfer protocol for the daily station observation archive.
1716#[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}